From 36a9a01d77af84e35a2788d750908b06b7145091 Mon Sep 17 00:00:00 2001 From: mahdiall99 Date: Thu, 8 May 2025 16:49:51 -0400 Subject: [PATCH 1/7] Fixed issue in user set min value check --- MEDimage/processing/discretisation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MEDimage/processing/discretisation.py b/MEDimage/processing/discretisation.py index 12376b9..66a0f02 100644 --- a/MEDimage/processing/discretisation.py +++ b/MEDimage/processing/discretisation.py @@ -111,7 +111,7 @@ def discretize(vol_re: np.ndarray, # DISCRETISATION if discr_type in ["FBS", "FBSequal"]: - if user_set_min_val is not None: + if user_set_min_val: min_val = deepcopy(user_set_min_val) else: min_val = np.nanmin(vol_quant_re) From 3f33be900406e55f0a86f793adc16726b045293d Mon Sep 17 00:00:00 2001 From: MahdiAll99 Date: Tue, 20 Jan 2026 11:40:57 -0500 Subject: [PATCH 2/7] MEDimage to MEDiml --- MEDiml/MEDscan.py | 1696 +++++++++++++ MEDiml/__init__.py | 21 + MEDiml/biomarkers/BatchExtractor.py | 806 ++++++ .../BatchExtractorTexturalFilters.py | 840 +++++++ MEDiml/biomarkers/__init__.py | 16 + MEDiml/biomarkers/diagnostics.py | 125 + MEDiml/biomarkers/get_oriented_bound_box.py | 158 ++ MEDiml/biomarkers/glcm.py | 1602 ++++++++++++ MEDiml/biomarkers/gldzm.py | 523 ++++ MEDiml/biomarkers/glrlm.py | 1315 ++++++++++ MEDiml/biomarkers/glszm.py | 555 ++++ MEDiml/biomarkers/int_vol_hist.py | 527 ++++ MEDiml/biomarkers/intensity_histogram.py | 615 +++++ MEDiml/biomarkers/local_intensity.py | 89 + MEDiml/biomarkers/morph.py | 1756 +++++++++++++ MEDiml/biomarkers/ngldm.py | 780 ++++++ MEDiml/biomarkers/ngtdm.py | 414 +++ MEDiml/biomarkers/stats.py | 373 +++ MEDiml/biomarkers/utils.py | 389 +++ MEDiml/filters/TexturalFilter.py | 299 +++ MEDiml/filters/__init__.py | 9 + MEDiml/filters/apply_filter.py | 134 + MEDiml/filters/gabor.py | 215 ++ MEDiml/filters/laws.py | 283 +++ MEDiml/filters/log.py | 147 ++ MEDiml/filters/mean.py | 121 + MEDiml/filters/textural_filters_kernels.py | 1738 +++++++++++++ MEDiml/filters/utils.py | 107 + MEDiml/filters/wavelet.py | 237 ++ MEDiml/learning/DataCleaner.py | 198 ++ MEDiml/learning/DesignExperiment.py | 480 ++++ MEDiml/learning/FSR.py | 667 +++++ MEDiml/learning/Normalization.py | 112 + MEDiml/learning/RadiomicsLearner.py | 714 ++++++ MEDiml/learning/Results.py | 2237 +++++++++++++++++ MEDiml/learning/Stats.py | 694 +++++ MEDiml/learning/__init__.py | 10 + MEDiml/learning/cleaning_utils.py | 107 + MEDiml/learning/ml_utils.py | 1015 ++++++++ MEDiml/processing/__init__.py | 6 + MEDiml/processing/compute_suv_map.py | 121 + MEDiml/processing/discretisation.py | 149 ++ MEDiml/processing/interpolation.py | 275 ++ MEDiml/processing/resegmentation.py | 66 + MEDiml/processing/segmentation.py | 912 +++++++ MEDiml/utils/__init__.py | 25 + MEDiml/utils/batch_patients.py | 45 + MEDiml/utils/create_radiomics_table.py | 131 + MEDiml/utils/data_frame_export.py | 42 + MEDiml/utils/find_process_names.py | 16 + MEDiml/utils/get_file_paths.py | 34 + MEDiml/utils/get_full_rad_names.py | 21 + MEDiml/utils/get_institutions_from_ids.py | 16 + MEDiml/utils/get_patient_id_from_scan_name.py | 22 + MEDiml/utils/get_patient_names.py | 26 + MEDiml/utils/get_radiomic_names.py | 27 + MEDiml/utils/get_scan_name_from_rad_name.py | 22 + MEDiml/utils/image_reader_SITK.py | 37 + MEDiml/utils/image_volume_obj.py | 22 + MEDiml/utils/imref.py | 340 +++ MEDiml/utils/initialize_features_names.py | 62 + MEDiml/utils/inpolygon.py | 159 ++ MEDiml/utils/interp3.py | 43 + MEDiml/utils/json_utils.py | 78 + MEDiml/utils/mode.py | 31 + MEDiml/utils/parse_contour_string.py | 58 + MEDiml/utils/save_MEDscan.py | 30 + MEDiml/utils/strfind.py | 32 + MEDiml/utils/textureTools.py | 188 ++ MEDiml/utils/texture_features_names.py | 115 + MEDiml/utils/write_radiomics_csv.py | 47 + MEDiml/wrangling/DataManager.py | 1724 +++++++++++++ MEDiml/wrangling/ProcessDICOM.py | 512 ++++ MEDiml/wrangling/__init__.py | 3 + 74 files changed, 27561 insertions(+) create mode 100644 MEDiml/MEDscan.py create mode 100644 MEDiml/__init__.py create mode 100644 MEDiml/biomarkers/BatchExtractor.py create mode 100644 MEDiml/biomarkers/BatchExtractorTexturalFilters.py create mode 100644 MEDiml/biomarkers/__init__.py create mode 100644 MEDiml/biomarkers/diagnostics.py create mode 100644 MEDiml/biomarkers/get_oriented_bound_box.py create mode 100644 MEDiml/biomarkers/glcm.py create mode 100644 MEDiml/biomarkers/gldzm.py create mode 100644 MEDiml/biomarkers/glrlm.py create mode 100644 MEDiml/biomarkers/glszm.py create mode 100644 MEDiml/biomarkers/int_vol_hist.py create mode 100644 MEDiml/biomarkers/intensity_histogram.py create mode 100644 MEDiml/biomarkers/local_intensity.py create mode 100644 MEDiml/biomarkers/morph.py create mode 100644 MEDiml/biomarkers/ngldm.py create mode 100644 MEDiml/biomarkers/ngtdm.py create mode 100644 MEDiml/biomarkers/stats.py create mode 100644 MEDiml/biomarkers/utils.py create mode 100644 MEDiml/filters/TexturalFilter.py create mode 100644 MEDiml/filters/__init__.py create mode 100644 MEDiml/filters/apply_filter.py create mode 100644 MEDiml/filters/gabor.py create mode 100644 MEDiml/filters/laws.py create mode 100644 MEDiml/filters/log.py create mode 100644 MEDiml/filters/mean.py create mode 100644 MEDiml/filters/textural_filters_kernels.py create mode 100644 MEDiml/filters/utils.py create mode 100644 MEDiml/filters/wavelet.py create mode 100644 MEDiml/learning/DataCleaner.py create mode 100644 MEDiml/learning/DesignExperiment.py create mode 100644 MEDiml/learning/FSR.py create mode 100644 MEDiml/learning/Normalization.py create mode 100644 MEDiml/learning/RadiomicsLearner.py create mode 100644 MEDiml/learning/Results.py create mode 100644 MEDiml/learning/Stats.py create mode 100644 MEDiml/learning/__init__.py create mode 100644 MEDiml/learning/cleaning_utils.py create mode 100644 MEDiml/learning/ml_utils.py create mode 100644 MEDiml/processing/__init__.py create mode 100644 MEDiml/processing/compute_suv_map.py create mode 100644 MEDiml/processing/discretisation.py create mode 100644 MEDiml/processing/interpolation.py create mode 100644 MEDiml/processing/resegmentation.py create mode 100644 MEDiml/processing/segmentation.py create mode 100644 MEDiml/utils/__init__.py create mode 100644 MEDiml/utils/batch_patients.py create mode 100644 MEDiml/utils/create_radiomics_table.py create mode 100644 MEDiml/utils/data_frame_export.py create mode 100644 MEDiml/utils/find_process_names.py create mode 100644 MEDiml/utils/get_file_paths.py create mode 100644 MEDiml/utils/get_full_rad_names.py create mode 100644 MEDiml/utils/get_institutions_from_ids.py create mode 100644 MEDiml/utils/get_patient_id_from_scan_name.py create mode 100644 MEDiml/utils/get_patient_names.py create mode 100644 MEDiml/utils/get_radiomic_names.py create mode 100644 MEDiml/utils/get_scan_name_from_rad_name.py create mode 100644 MEDiml/utils/image_reader_SITK.py create mode 100644 MEDiml/utils/image_volume_obj.py create mode 100644 MEDiml/utils/imref.py create mode 100644 MEDiml/utils/initialize_features_names.py create mode 100644 MEDiml/utils/inpolygon.py create mode 100644 MEDiml/utils/interp3.py create mode 100644 MEDiml/utils/json_utils.py create mode 100644 MEDiml/utils/mode.py create mode 100644 MEDiml/utils/parse_contour_string.py create mode 100644 MEDiml/utils/save_MEDscan.py create mode 100644 MEDiml/utils/strfind.py create mode 100644 MEDiml/utils/textureTools.py create mode 100644 MEDiml/utils/texture_features_names.py create mode 100644 MEDiml/utils/write_radiomics_csv.py create mode 100644 MEDiml/wrangling/DataManager.py create mode 100644 MEDiml/wrangling/ProcessDICOM.py create mode 100644 MEDiml/wrangling/__init__.py diff --git a/MEDiml/MEDscan.py b/MEDiml/MEDscan.py new file mode 100644 index 0000000..857a185 --- /dev/null +++ b/MEDiml/MEDscan.py @@ -0,0 +1,1696 @@ +import logging +import os +from json import dump +from pathlib import Path +from typing import Dict, List, Union + +import matplotlib.pyplot as plt +import nibabel as nib +import numpy as np +from numpyencoder import NumpyEncoder +from PIL import Image + +from .utils.image_volume_obj import image_volume_obj +from .utils.imref import imref3d +from .utils.json_utils import load_json + + +class MEDscan(object): + """Organizes all scan data (patientID, imaging data, scan type...). + + Attributes: + patientID (str): Patient ID. + type (str): Scan type (MRscan, CTscan...). + format (str): Scan file format. Either 'npy' or 'nifti'. + dicomH (pydicom.dataset.FileDataset): DICOM header. + data (MEDscan.data): Instance of MEDscan.data inner class. + + """ + + def __init__(self, medscan=None) -> None: + """Constructor of the MEDscan class + + Args: + medscan(MEDscan): A MEDscan class instance. + + Returns: + None + """ + try: + self.patientID = medscan.patientID + except: + self.patientID = "" + try: + self.type = medscan.type + except: + self.type = "" + try: + self.series_description = medscan.series_description + except: + self.series_description = "" + try: + self.format = medscan.format + except: + self.format = "" + try: + self.dicomH = medscan.dicomH + except: + self.dicomH = [] + try: + self.data = medscan.data + except: + self.data = self.data() + + self.params = self.Params() + self.radiomics = self.Radiomics() + self.skip = False + + def __init_process_params(self, im_params: Dict) -> None: + """Initializes the processing params from a given Dict. + + Args: + im_params(Dict): Dictionary of different processing params. + + Returns: + None. + """ + if self.type == 'CTscan' and 'imParamCT' in im_params: + im_params = im_params['imParamCT'] + elif self.type == 'MRscan' and 'imParamMR' in im_params: + im_params = im_params['imParamMR'] + elif self.type == 'PTscan' and 'imParamPET' in im_params: + im_params = im_params['imParamPET'] + else: + raise ValueError(f"The given parameters dict is not valid, no params found for {self.type} modality") + + # re-segmentation range processing + if(im_params['reSeg']['range'] and (im_params['reSeg']['range'][0] == "inf" or im_params['reSeg']['range'][0] == "-inf")): + im_params['reSeg']['range'][0] = -np.inf + if(im_params['reSeg']['range'] and im_params['reSeg']['range'][1] == "inf"): + im_params['reSeg']['range'][1] = np.inf + + if 'box_string' in im_params: + box_string = im_params['box_string'] + else: + # By default, we add 10 voxels in all three dimensions are added to the smallest + # bounding box. This setting is used to speed up interpolation + # processes (mostly) prior to the computation of radiomics + # features. Optional argument in the function computeRadiomics. + box_string = 'box10' + if 'compute_diag_features' in im_params: + compute_diag_features = im_params['compute_diag_features'] + else: + compute_diag_features = False + if compute_diag_features: # If compute_diag_features is true. + box_string = 'full' # This is required for proper comparison. + + self.params.process.box_string = box_string + + # get default scan parameters from im_param_scan + self.params.process.scale_non_text = im_params['interp']['scale_non_text'] + self.params.process.vol_interp = im_params['interp']['vol_interp'] + self.params.process.roi_interp = im_params['interp']['roi_interp'] + self.params.process.gl_round = im_params['interp']['gl_round'] + self.params.process.roi_pv = im_params['interp']['roi_pv'] + self.params.process.im_range = im_params['reSeg']['range'] if 'range' in im_params['reSeg'] else None + self.params.process.outliers = im_params['reSeg']['outliers'] + self.params.process.ih = im_params['discretisation']['IH'] + self.params.process.ivh = im_params['discretisation']['IVH'] + self.params.process.scale_text = im_params['interp']['scale_text'] + self.params.process.algo = im_params['discretisation']['texture']['type'] if 'type' in im_params['discretisation']['texture'] else [] + self.params.process.gray_levels = im_params['discretisation']['texture']['val'] if 'val' in im_params['discretisation']['texture'] else [[]] + self.params.process.im_type = self.type + + # Voxels dimension + self.params.process.n_scale = len(self.params.process.scale_text) + # Setting up discretisation params + self.params.process.n_algo = len(self.params.process.algo) + self.params.process.n_gl = len(self.params.process.gray_levels[0]) + self.params.process.n_exp = self.params.process.n_scale * self.params.process.n_algo * self.params.process.n_gl + + # Setting up user_set_min_value + if self.params.process.im_range is not None and type(self.params.process.im_range) is list and self.params.process.im_range: + user_set_min_value = self.params.process.im_range[0] + if user_set_min_value == -np.inf: + # In case no re-seg im_range is defined for the FBS algorithm, + # the minimum value of ROI will be used (not recommended). + user_set_min_value = [] + else: + # In case no re-seg im_range is defined for the FBS algorithm, + # the minimum value of ROI will be used (not recommended). + user_set_min_value = [] + self.params.process.user_set_min_value = user_set_min_value + + # box_string argument is optional. If not present, we use the full box. + if self.params.process.box_string is None: + self.params.process.box_string = 'full' + + # set filter type for the modality + if 'filter_type' in im_params: + self.params.filter.filter_type = im_params['filter_type'] + + # Set intensity type + if 'intensity_type' in im_params and im_params['intensity_type'] != "": + self.params.process.intensity_type = im_params['intensity_type'] + elif self.params.filter.filter_type != "": + self.params.process.intensity_type = 'filtered' + elif self.type == 'MRscan': + self.params.process.intensity_type = 'arbitrary' + else: + self.params.process.intensity_type = 'definite' + + def __init_extraction_params(self, im_params: Dict): + """Initializes the extraction params from a given Dict. + + Args: + im_params(Dict): Dictionary of different extraction params. + + Returns: + None. + """ + if self.type == 'CTscan' and 'imParamCT' in im_params: + im_params = im_params['imParamCT'] + elif self.type == 'MRscan' and 'imParamMR' in im_params: + im_params = im_params['imParamMR'] + elif self.type == 'PTscan' and 'imParamPET' in im_params: + im_params = im_params['imParamPET'] + else: + raise ValueError(f"The given parameters dict is not valid, no params found for {self.type} modality") + + # glcm features extraction params + if 'glcm' in im_params: + if 'dist_correction' in im_params['glcm']: + self.params.radiomics.glcm.dist_correction = im_params['glcm']['dist_correction'] + else: + self.params.radiomics.glcm.dist_correction = False + if 'merge_method' in im_params['glcm']: + self.params.radiomics.glcm.merge_method = im_params['glcm']['merge_method'] + else: + self.params.radiomics.glcm.merge_method = "vol_merge" + else: + self.params.radiomics.glcm.dist_correction = False + self.params.radiomics.glcm.merge_method = "vol_merge" + + # glrlm features extraction params + if 'glrlm' in im_params: + if 'dist_correction' in im_params['glrlm']: + self.params.radiomics.glrlm.dist_correction = im_params['glrlm']['dist_correction'] + else: + self.params.radiomics.glrlm.dist_correction = False + if 'merge_method' in im_params['glrlm']: + self.params.radiomics.glrlm.merge_method = im_params['glrlm']['merge_method'] + else: + self.params.radiomics.glrlm.merge_method = "vol_merge" + else: + self.params.radiomics.glrlm.dist_correction = False + self.params.radiomics.glrlm.merge_method = "vol_merge" + + + # ngtdm features extraction params + if 'ngtdm' in im_params: + if 'dist_correction' in im_params['ngtdm']: + self.params.radiomics.ngtdm.dist_correction = im_params['ngtdm']['dist_correction'] + else: + self.params.radiomics.ngtdm.dist_correction = False + else: + self.params.radiomics.ngtdm.dist_correction = False + + # Features to extract + features = [ + "Morph", "LocalIntensity", "Stats", "IntensityHistogram", "IntensityVolumeHistogram", + "GLCM", "GLRLM", "GLSZM", "GLDZM", "NGTDM", "NGLDM" + ] + if "extract" in im_params.keys(): + self.params.radiomics.extract = im_params['extract'] + for key in self.params.radiomics.extract: + if key not in features: + raise ValueError(f"Invalid key in 'extract' parameter: {key} (Modality {self.type}).") + + # Ensure each feature is in the extract dictionary with a default value of True + for feature in features: + if feature not in self.params.radiomics.extract: + self.params.radiomics.extract[feature] = True + + def __init_filter_params(self, filter_params: Dict) -> None: + """Initializes the filtering params from a given Dict. + + Args: + filter_params(Dict): Dictionary of the filtering parameters. + + Returns: + None. + """ + if 'imParamFilter' in filter_params: + filter_params = filter_params['imParamFilter'] + + # Initializae filter attribute + self.params.filter = self.params.Filter() + + # mean filter params + if 'mean' in filter_params: + self.params.filter.mean.init_from_json(filter_params['mean']) + + # log filter params + if 'log' in filter_params: + self.params.filter.log.init_from_json(filter_params['log']) + + # laws filter params + if 'laws' in filter_params: + self.params.filter.laws.init_from_json(filter_params['laws']) + + # gabor filter params + if 'gabor' in filter_params: + self.params.filter.gabor.init_from_json(filter_params['gabor']) + + # wavelet filter params + if 'wavelet' in filter_params: + self.params.filter.wavelet.init_from_json(filter_params['wavelet']) + + # Textural filter params + if 'textural' in filter_params: + self.params.filter.textural.init_from_json(filter_params['textural']) + + def init_params(self, im_param_scan: Dict) -> None: + """Initializes the Params class from a dictionary. + + Args: + im_param_scan(Dict): Dictionary of different processing, extraction and filtering params. + + Returns: + None. + """ + try: + # get default scan parameters from im_param_scan + self.__init_filter_params(im_param_scan['imParamFilter']) + self.__init_process_params(im_param_scan) + self.__init_extraction_params(im_param_scan) + + # compute suv map for PT scans + if self.type == 'PTscan': + _compute_suv_map = im_param_scan['imParamPET']['compute_suv_map'] + else : + _compute_suv_map = False + + if self.type == 'PTscan' and _compute_suv_map and self.format != 'nifti': + try: + from .processing.compute_suv_map import compute_suv_map + self.data.volume.array = compute_suv_map(self.data.volume.array, self.dicomH[0]) + except Exception as e : + message = f"\n ERROR COMPUTING SUV MAP - SOME FEATURES WILL BE INVALID: \n {e}" + logging.error(message) + print(message) + self.skip = True + + # initialize radiomics structure + self.radiomics.image = {} + self.radiomics.params = im_param_scan + self.params.radiomics.scale_name = '' + self.params.radiomics.ih_name = '' + self.params.radiomics.ivh_name = '' + + except Exception as e: + message = f"\n ERROR IN INITIALIZATION OF RADIOMICS FEATURE COMPUTATION\n {e}" + logging.error(message) + print(message) + self.skip = True + + def init_ntf_calculation(self, vol_obj: image_volume_obj) -> None: + """ + Initializes all the computation parameters for non-texture features as well as the results dict. + + Args: + vol_obj(image_volume_obj): Imaging volume. + + Returns: + None. + """ + try: + if sum(self.params.process.scale_non_text) == 0: # In case the user chose to not interpolate + self.params.process.scale_non_text = [ + vol_obj.spatialRef.PixelExtentInWorldX, + vol_obj.spatialRef.PixelExtentInWorldY, + vol_obj.spatialRef.PixelExtentInWorldZ] + else: + if len(self.params.process.scale_non_text) == 2: + # In case not interpolation is performed in + # the slice direction (e.g. 2D case) + self.params.process.scale_non_text = self.params.process.scale_non_text + \ + [vol_obj.spatialRef.PixelExtentInWorldZ] + + # Scale name + # Always isotropic resampling, so the first entry is ok. + self.params.radiomics.scale_name = 'scale' + (str(self.params.process.scale_non_text[0])).replace('.', 'dot') + + # IH name + if 'val' in self.params.process.ih: + ih_val_name = 'bin' + (str(self.params.process.ih['val'])).replace('.', 'dot') + else: + ih_val_name = 'binNone' + + # The minimum value defines the computation. + if self.params.process.ih['type'].find('FBS')>=0: + if type(self.params.process.user_set_min_value) is list and self.params.process.user_set_min_value: + min_val_name = '_min' + \ + ((str(self.params.process.user_set_min_value)).replace('.', 'dot')).replace('-', 'M') + else: + # Otherwise, minimum value of ROI will be used (not recommended), + # so no need to report it. + min_val_name = '' + else: + min_val_name = '' + self.params.radiomics.ih_name = self.params.radiomics.scale_name + \ + '_algo' + self.params.process.ih['type'] + \ + '_' + ih_val_name + min_val_name + + # IVH name + if self.params.process.im_range: # The im_range defines the computation. + min_val_name = ((str(self.params.process.im_range[0])).replace('.', 'dot')).replace('-', 'M') + max_val_name = ((str(self.params.process.im_range[1])).replace('.', 'dot')).replace('-', 'M') + if max_val_name == 'inf': + # In this case, the maximum value of the ROI is used, + # so no need to report it. + range_name = '_min' + min_val_name + elif min_val_name == '-inf' or min_val_name == 'inf': + # In this case, the minimum value of the ROI is used, + # so no need to report it. + range_name = '_max' + max_val_name + else: + range_name = '_min' + min_val_name + '_max' + max_val_name + else: + # min-max of ROI will be used, no need to report it. + range_name = '' + if not self.params.process.ivh: # CT case for example + ivh_algo_name = 'algoNone' + ivh_val_name = 'bin1' + else: + ivh_algo_name = 'algo' + self.params.process.ivh['type'] if 'type' in self.params.process.ivh else 'algoNone' + if 'val' in self.params.process.ivh and self.params.process.ivh['val']: + ivh_val_name = 'bin' + (str(self.params.process.ivh['val'])).replace('.', 'dot') + else: + ivh_val_name = 'binNone' + self.params.radiomics.ivh_name = self.params.radiomics.scale_name + '_' + ivh_algo_name + '_' + ivh_val_name + range_name + + # Now initialize the attribute that will hold the computation results + self.radiomics.image.update({ + 'morph_3D': {self.params.radiomics.scale_name: {}}, + 'locInt_3D': {self.params.radiomics.scale_name: {}}, + 'stats_3D': {self.params.radiomics.scale_name: {}}, + 'intHist_3D': {self.params.radiomics.ih_name: {}}, + 'intVolHist_3D': {self.params.radiomics.ivh_name: {}} + }) + + except Exception as e: + message = f"\n PROBLEM WITH PRE-PROCESSING OF FEATURES IN init_ntf_calculation(): \n {e}" + logging.error(message) + print(message) + self.radiomics.image.update( + {('scale' + (str(self.params.process.scale_non_text[0])).replace('.', 'dot')): 'ERROR_PROCESSING'}) + + def init_tf_calculation(self, algo:int, gl:int, scale:int) -> None: + """ + Initializes all the computation parameters for the texture-features as well as the results dict. + + Args: + algo(int): Discretisation algorithms index. + gl(int): gray-level index. + scale(int): scale-text index. + + Returns: + None. + """ + # check glcm merge method + glcm_merge_method = self.params.radiomics.glcm.merge_method + if glcm_merge_method: + if glcm_merge_method == 'average': + glcm_merge_method = '_avg' + elif glcm_merge_method == 'vol_merge': + glcm_merge_method = '_comb' + else: + error_msg = f"{glcm_merge_method} Method not supported in glcm computation, \ + only 'average' or 'vol_merge' are supported. \ + Radiomics will be saved without any specific merge method." + logging.warning(error_msg) + print(error_msg) + + # check glrlm merge method + glrlm_merge_method = self.params.radiomics.glrlm.merge_method + if glrlm_merge_method: + if glrlm_merge_method == 'average': + glrlm_merge_method = '_avg' + elif glrlm_merge_method == 'vol_merge': + glrlm_merge_method = '_comb' + else: + error_msg = f"{glcm_merge_method} Method not supported in glrlm computation, \ + only 'average' or 'vol_merge' are supported. \ + Radiomics will be saved without any specific merge method" + logging.warning(error_msg) + print(error_msg) + # set texture features names and updates radiomics dict + self.params.radiomics.name_text_types = [ + 'glcm_3D' + glcm_merge_method, + 'glrlm_3D' + glrlm_merge_method, + 'glszm_3D', + 'gldzm_3D', + 'ngtdm_3D', + 'ngldm_3D'] + n_text_types = len(self.params.radiomics.name_text_types) + if not ('texture' in self.radiomics.image): + self.radiomics.image.update({'texture': {}}) + for t in range(n_text_types): + self.radiomics.image['texture'].update({self.params.radiomics.name_text_types[t]: {}}) + + # scale name + # Always isotropic resampling, so the first entry is ok. + scale_name = 'scale' + (str(self.params.process.scale_text[scale][0])).replace('.', 'dot') + if hasattr(self.params.radiomics, "scale_name"): + setattr(self.params.radiomics, 'scale_name', scale_name) + else: + self.params.radiomics.scale_name = scale_name + + # Discretisation name + gray_levels_name = (str(self.params.process.gray_levels[algo][gl])).replace('.', 'dot') + + if 'FBS' in self.params.process.algo[algo]: # The minimum value defines the computation. + if type(self.params.process.user_set_min_value) is list and self.params.process.user_set_min_value: + min_val_name = '_min' + ((str(self.params.process.user_set_min_value)).replace('.', 'dot')).replace('-', 'M') + else: + # Otherwise, minimum value of ROI will be used (not recommended), + # so no need to report it. + min_val_name = '' + else: + min_val_name = '' + + if 'equal'in self.params.process.algo[algo]: + # The number of gray-levels used for equalization is currently + # hard-coded to 64 in equalization.m + discretisation_name = 'algo' + self.params.process.algo[algo] + '256_bin' + gray_levels_name + min_val_name + else: + discretisation_name = 'algo' + self.params.process.algo[algo] + '_bin' + gray_levels_name + min_val_name + + # Processing full name + processing_name = scale_name + '_' + discretisation_name + if hasattr(self.params.radiomics, "processing_name"): + setattr(self.params.radiomics, 'processing_name', processing_name) + else: + self.params.radiomics.processing_name = processing_name + + def init_from_nifti(self, nifti_image_path: Path) -> None: + """Initializes the MEDscan class using a NIfTI file. + + Args: + nifti_image_path (Path): NIfTI file path. + + Returns: + None. + + """ + self.patientID = os.path.basename(nifti_image_path).split("_")[0] + self.type = os.path.basename(nifti_image_path).split(".")[-3] + self.format = "nifti" + self.data.set_orientation(orientation="Axial") + self.data.set_patient_position(patient_position="HFS") + self.data.ROI.get_roi_from_path(roi_path=os.path.dirname(nifti_image_path), + id=Path(nifti_image_path).name.split("(")[0]) + self.data.volume.array = nib.load(nifti_image_path).get_fdata() + # RAS to LPS + self.data.volume.convert_to_LPS() + self.data.volume.scan_rot = None + + def update_radiomics( + self, int_vol_hist_features: Dict = {}, + morph_features: Dict = {}, loc_int_features: Dict = {}, + stats_features: Dict = {}, int_hist_features: Dict = {}, + glcm_features: Dict = {}, glrlm_features: Dict = {}, + glszm_features: Dict = {}, gldzm_features: Dict = {}, + ngtdm_features: Dict = {}, ngldm_features: Dict = {}) -> None: + """Updates the results attribute with the extracted features. + + Args: + int_vol_hist_features(Dict, optional): Dictionary of the intensity volume histogram features. + morph_features(Dict, optional): Dictionary of the morphological features. + loc_int_features(Dict, optional): Dictionary of the intensity local intensity features. + stats_features(Dict, optional): Dictionary of the statistical features. + int_hist_features(Dict, optional): Dictionary of the intensity histogram features. + glcm_features(Dict, optional): Dictionary of the GLCM features. + glrlm_features(Dict, optional): Dictionary of the GLRLM features. + glszm_features(Dict, optional): Dictionary of the GLSZM features. + gldzm_features(Dict, optional): Dictionary of the GLDZM features. + ngtdm_features(Dict, optional): Dictionary of the NGTDM features. + ngldm_features(Dict, optional): Dictionary of the NGLDM features. + Returns: + None. + """ + # check glcm merge method + glcm_merge_method = self.params.radiomics.glcm.merge_method + if glcm_merge_method: + if glcm_merge_method == 'average': + glcm_merge_method = '_avg' + elif glcm_merge_method == 'vol_merge': + glcm_merge_method = '_comb' + + # check glrlm merge method + glrlm_merge_method = self.params.radiomics.glrlm.merge_method + if glrlm_merge_method: + if glrlm_merge_method == 'average': + glrlm_merge_method = '_avg' + elif glrlm_merge_method == 'vol_merge': + glrlm_merge_method = '_comb' + + # Non-texture Features + if int_vol_hist_features: + self.radiomics.image['intVolHist_3D'][self.params.radiomics.ivh_name] = int_vol_hist_features + if morph_features: + self.radiomics.image['morph_3D'][self.params.radiomics.scale_name] = morph_features + if loc_int_features: + self.radiomics.image['locInt_3D'][self.params.radiomics.scale_name] = loc_int_features + if stats_features: + self.radiomics.image['stats_3D'][self.params.radiomics.scale_name] = stats_features + if int_hist_features: + self.radiomics.image['intHist_3D'][self.params.radiomics.ih_name] = int_hist_features + + # Texture Features + if glcm_features: + self.radiomics.image['texture'][ + 'glcm_3D' + glcm_merge_method][self.params.radiomics.processing_name] = glcm_features + if glrlm_features: + self.radiomics.image['texture'][ + 'glrlm_3D' + glrlm_merge_method][self.params.radiomics.processing_name] = glrlm_features + if glszm_features: + self.radiomics.image['texture']['glszm_3D'][self.params.radiomics.processing_name] = glszm_features + if gldzm_features: + self.radiomics.image['texture']['gldzm_3D'][self.params.radiomics.processing_name] = gldzm_features + if ngtdm_features: + self.radiomics.image['texture']['ngtdm_3D'][self.params.radiomics.processing_name] = ngtdm_features + if ngldm_features: + self.radiomics.image['texture']['ngldm_3D'][self.params.radiomics.processing_name] = ngldm_features + + def save_radiomics( + self, scan_file_name: List, + path_save: Path, roi_type: str, + roi_type_label: str, patient_num: int = None) -> None: + """ + Saves extracted radiomics features in a JSON file. + + Args: + scan_file_name(List): List of scan files. + path_save(Path): Saving path. + roi_type(str): Type of the ROI. + roi_type_label(str): Label of the ROI type. + patient_num(int): Index of scan. + + Returns: + None. + """ + if path_save.name != f'features({roi_type})': + if not (path_save / f'features({roi_type})').exists(): + (path_save / f'features({roi_type})').mkdir() + path_save = Path(path_save / f'features({roi_type})') + else: + path_save = Path(path_save) / f'features({roi_type})' + else: + path_save = Path(path_save) + params = {} + params['roi_type'] = roi_type_label + params['patientID'] = self.patientID + params['vox_dim'] = list([ + self.data.volume.spatialRef.PixelExtentInWorldX, + self.data.volume.spatialRef.PixelExtentInWorldY, + self.data.volume.spatialRef.PixelExtentInWorldZ + ]) + self.radiomics.update_params(params) + if type(scan_file_name) is str: + index_dot = scan_file_name.find('.') + ext = scan_file_name.find('.npy') + name_save = scan_file_name[:index_dot] + \ + '(' + roi_type_label + ')' + \ + scan_file_name[index_dot : ext] + elif patient_num is not None: + index_dot = scan_file_name[patient_num].find('.') + ext = scan_file_name[patient_num].find('.npy') + name_save = scan_file_name[patient_num][:index_dot] + \ + '(' + roi_type_label + ')' + \ + scan_file_name[patient_num][index_dot : ext] + else: + raise ValueError("`patient_num` must be specified or `scan_file_name` must be str") + + with open(path_save / f"{name_save}.json", "w") as fp: + dump(self.radiomics.to_json(), fp, indent=4, cls=NumpyEncoder) + + + class Params: + """Organizes all processing, filtering and features extraction parameters""" + + def __init__(self) -> None: + """ + Organizes all processing, filtering and features extraction + """ + self.process = self.Process() + self.filter = self.Filter() + self.radiomics = self.Radiomics() + + + class Process: + """Organizes all processing parameters.""" + def __init__(self, **kwargs) -> None: + """ + Constructor of the `Process` class. + """ + self.algo = kwargs['algo'] if 'algo' in kwargs else None + self.box_string = kwargs['box_string'] if 'box_string' in kwargs else None + self.gl_round = kwargs['gl_round'] if 'gl_round' in kwargs else None + self.gray_levels = kwargs['gray_levels'] if 'gray_levels' in kwargs else None + self.ih = kwargs['ih'] if 'ih' in kwargs else None + self.im_range = kwargs['im_range'] if 'im_range' in kwargs else None + self.im_type = kwargs['im_type'] if 'im_type' in kwargs else None + self.intensity_type = kwargs['intensity_type'] if 'intensity_type' in kwargs else None + self.ivh = kwargs['ivh'] if 'ivh' in kwargs else None + self.n_algo = kwargs['n_algo'] if 'n_algo' in kwargs else None + self.n_exp = kwargs['n_exp'] if 'n_exp' in kwargs else None + self.n_gl = kwargs['n_gl'] if 'n_gl' in kwargs else None + self.n_scale = kwargs['n_scale'] if 'n_scale' in kwargs else None + self.outliers = kwargs['outliers'] if 'outliers' in kwargs else None + self.scale_non_text = kwargs['scale_non_text'] if 'scale_non_text' in kwargs else None + self.scale_text = kwargs['scale_text'] if 'scale_text' in kwargs else None + self.roi_interp = kwargs['roi_interp'] if 'roi_interp' in kwargs else None + self.roi_pv = kwargs['roi_pv'] if 'roi_pv' in kwargs else None + self.user_set_min_value = kwargs['user_set_min_value'] if 'user_set_min_value' in kwargs else None + self.vol_interp = kwargs['vol_interp'] if 'vol_interp' in kwargs else None + + def init_from_json(self, path_to_json: Union[Path, str]) -> None: + """ + Updates class attributes from json file. + + Args: + path_to_json(Union[Path, str]): Path to the JSON file with processing parameters. + + Returns: + None. + """ + __params = load_json(Path(path_to_json)) + + self.algo = __params['algo'] if 'algo' in __params else self.algo + self.box_string = __params['box_string'] if 'box_string' in __params else self.box_string + self.gl_round = __params['gl_round'] if 'gl_round' in __params else self.gl_round + self.gray_levels = __params['gray_levels'] if 'gray_levels' in __params else self.gray_levels + self.ih = __params['ih'] if 'ih' in __params else self.ih + self.im_range = __params['im_range'] if 'im_range' in __params else self.im_range + self.im_type = __params['im_type'] if 'im_type' in __params else self.im_type + self.ivh = __params['ivh'] if 'ivh' in __params else self.ivh + self.n_algo = __params['n_algo'] if 'n_algo' in __params else self.n_algo + self.n_exp = __params['n_exp'] if 'n_exp' in __params else self.n_exp + self.n_gl = __params['n_gl'] if 'n_gl' in __params else self.n_gl + self.n_scale = __params['n_scale'] if 'n_scale' in __params else self.n_scale + self.outliers = __params['outliers'] if 'outliers' in __params else self.outliers + self.scale_non_text = __params['scale_non_text'] if 'scale_non_text' in __params else self.scale_non_text + self.scale_text = __params['scale_text'] if 'scale_text' in __params else self.scale_text + self.roi_interp = __params['roi_interp'] if 'roi_interp' in __params else self.roi_interp + self.roi_pv = __params['roi_pv'] if 'roi_pv' in __params else self.roi_pv + self.user_set_min_value = __params['user_set_min_value'] if 'user_set_min_value' in __params else self.user_set_min_value + self.vol_interp = __params['vol_interp'] if 'vol_interp' in __params else self.vol_interp + + + class Filter: + """Organizes all filtering parameters""" + def __init__(self, filter_type: str = "") -> None: + """ + Constructor of the Filter class. + + Args: + filter_type(str): Type of the filter that will be used (Must be 'mean', 'log', 'laws', + 'gabor' or 'wavelet'). + + Returns: + None. + """ + self.filter_type = filter_type + self.mean = self.Mean() + self.log = self.Log() + self.gabor = self.Gabor() + self.laws = self.Laws() + self.wavelet = self.Wavelet() + self.textural = self.Textural() + + + class Mean: + """Organizes the Mean filter parameters""" + def __init__( + self, ndims: int = 0, name_save: str = '', + padding: str = '', size: int = 0, orthogonal_rot: bool = False + ) -> None: + """ + Constructor of the Mean class. + + Args: + ndims(int): Filter dimension. + name_save(str): Specific name added to final extraction results file. + padding(str): padding mode. + size(int): Filter size. + + Returns: + None. + """ + self.name_save = name_save + self.ndims = ndims + self.orthogonal_rot = orthogonal_rot + self.padding = padding + self.size = size + + def init_from_json(self, params: Dict) -> None: + """ + Updates class attributes from json file. + + Args: + params(Dict): Dictionary of the Mean filter parameters. + + Returns: + None. + """ + self.name_save = params['name_save'] + self.ndims = params['ndims'] + self.padding = params['padding'] + self.size = params['size'] + self.orthogonal_rot = params['orthogonal_rot'] + + + class Log: + """Organizes the Log filter parameters""" + def __init__( + self, ndims: int = 0, sigma: float = 0.0, + padding: str = '', orthogonal_rot: bool = False, + name_save: str = '' + ) -> None: + """ + Constructor of the Log class. + + Args: + ndims(int): Filter dimension. + sigma(float): Float of the sigma value. + padding(str): padding mode. + orthogonal_rot(bool): If True will compute average response over orthogonal planes. + name_save(str): Specific name added to final extraction results file. + + Returns: + None. + """ + self.name_save = name_save + self.ndims = ndims + self.orthogonal_rot = orthogonal_rot + self.padding = padding + self.sigma = sigma + + def init_from_json(self, params: Dict) -> None: + """ + Updates class attributes from json file. + + Args: + params(Dict): Dictionary of the Log filter parameters. + + Returns: + None. + """ + self.name_save = params['name_save'] + self.ndims = params['ndims'] + self.orthogonal_rot = params['orthogonal_rot'] + self.padding = params['padding'] + self.sigma = params['sigma'] + + + class Gabor: + """Organizes the gabor filter parameters""" + def __init__( + self, sigma: float = 0.0, _lambda: float = 0.0, + gamma: float = 0.0, theta: str = '', rot_invariance: bool = False, + orthogonal_rot: bool= False, name_save: str = '', + padding: str = '' + ) -> None: + """ + Constructor of the Gabor class. + + Args: + sigma(float): Float of the sigma value. + _lambda(float): Float of the lambda value. + gamma(float): Float of the gamma value. + theta(str): String of the theta angle value. + rot_invariance(bool): If True the filter will be rotation invariant. + orthogonal_rot(bool): If True will compute average response over orthogonal planes. + name_save(str): Specific name added to final extraction results file. + padding(str): padding mode. + + Returns: + None. + """ + self._lambda = _lambda + self.gamma = gamma + self.name_save = name_save + self.orthogonal_rot = orthogonal_rot + self.padding = padding + self.rot_invariance = rot_invariance + self.sigma = sigma + self.theta = theta + + def init_from_json(self, params: Dict) -> None: + """ + Updates class attributes from json file. + + Args: + params(Dict): Dictionary of the gabor filter parameters. + + Returns: + None. + """ + self._lambda = params['lambda'] + self.gamma = params['gamma'] + self.name_save = params['name_save'] + self.orthogonal_rot = params['orthogonal_rot'] + self.padding = params['padding'] + self.rot_invariance = params['rot_invariance'] + self.sigma = params['sigma'] + if type(params["theta"]) is str: + if params["theta"].lower().startswith('pi/'): + self.theta = np.pi / int(params["theta"].split('/')[1]) + elif params["theta"].lower().startswith('-'): + if params["theta"].lower().startswith('-pi/'): + self.theta = -np.pi / int(params["theta"].split('/')[1]) + else: + nom, denom = params["theta"].replace('-', '').replace('Pi', '').split('/') + self.theta = -np.pi*int(nom) / int(denom) + else: + self.theta = float(params["theta"]) + + + class Laws: + """Organizes the laws filter parameters""" + def __init__( + self, config: List = [], energy_distance: int = 0, + energy_image: bool = False, rot_invariance: bool = False, + orthogonal_rot: bool = False, name_save: str = '', padding: str = '' + ) -> None: + """ + Constructor of the Laws class. + + Args: + config(List): Configuration of the Laws filter, for ex: ['E5', 'L5', 'E5']. + energy_distance(int): Chebyshev distance. + energy_image(bool): If True will compute the Laws texture energy image. + rot_invariance(bool): If True the filter will be rotation invariant. + orthogonal_rot(bool): If True will compute average response over orthogonal planes. + name_save(str): Specific name added to final extraction results file. + padding(str): padding mode. + + Returns: + None. + """ + self.config = config + self.energy_distance = energy_distance + self.energy_image = energy_image + self.name_save = name_save + self.orthogonal_rot = orthogonal_rot + self.padding = padding + self.rot_invariance = rot_invariance + + def init_from_json(self, params: Dict) -> None: + """ + Updates class attributes from json file. + + Args: + params(Dict): Dictionary of the laws filter parameters. + + Returns: + None. + """ + self.config = params['config'] + self.energy_distance = params['energy_distance'] + self.energy_image = params['energy_image'] + self.name_save = params['name_save'] + self.orthogonal_rot = params['orthogonal_rot'] + self.padding = params['padding'] + self.rot_invariance = params['rot_invariance'] + + + class Wavelet: + """Organizes the Wavelet filter parameters""" + def __init__( + self, ndims: int = 0, name_save: str = '', + basis_function: str = '', subband: str = '', level: int = 0, + rot_invariance: bool = False, padding: str = '' + ) -> None: + """ + Constructor of the Wavelet class. + + Args: + ndims(int): Dimension of the filter. + name_save(str): Specific name added to final extraction results file. + basis_function(str): Wavelet basis function. + subband(str): Wavelet subband. + level(int): Decomposition level. + rot_invariance(bool): If True the filter will be rotation invariant. + padding(str): padding mode. + + Returns: + None. + """ + self.basis_function = basis_function + self.level = level + self.ndims = ndims + self.name_save = name_save + self.padding = padding + self.rot_invariance = rot_invariance + self.subband = subband + + def init_from_json(self, params: Dict) -> None: + """ + Updates class attributes from json file. + + Args: + params(Dict): Dictionary of the wavelet filter parameters. + + Returns: + None. + """ + self.basis_function = params['basis_function'] + self.level = params['level'] + self.ndims = params['ndims'] + self.name_save = params['name_save'] + self.padding = params['padding'] + self.rot_invariance = params['rot_invariance'] + self.subband = params['subband'] + + + class Textural: + """Organizes the Textural filters parameters""" + def __init__( + self, + family: str = '', + size: int = 0, + discretization: dict = {}, + local: bool = False, + name_save: str = '' + ) -> None: + """ + Constructor of the Textural class. + + Args: + family (str, optional): The family of the textural filter. + size (int, optional): The filter size. + discretization (dict, optional): The discretization parameters. + local (bool, optional): If true, the discretization will be computed locally, else globally. + name_save (str, optional): Specific name added to final extraction results file. + + Returns: + None. + """ + self.family = family + self.size = size + self.discretization = discretization + self.local = local + self.name_save = name_save + + def init_from_json(self, params: Dict) -> None: + """ + Updates class attributes from json file. + + Args: + params(Dict): Dictionary of the wavelet filter parameters. + + Returns: + None. + """ + self.family = params['family'] + self.size = params['size'] + self.discretization = params['discretization'] + self.local = params['local'] + self.name_save = params['name_save'] + + + class Radiomics: + """Organizes the radiomics extraction parameters""" + def __init__(self, **kwargs) -> None: + """ + Constructor of the Radiomics class. + """ + self.ih_name = kwargs['ih_name'] if 'ih_name' in kwargs else None + self.ivh_name = kwargs['ivh_name'] if 'ivh_name' in kwargs else None + self.glcm = self.GLCM() + self.glrlm = self.GLRLM() + self.ngtdm = self.NGTDM() + self.name_text_types = kwargs['name_text_types'] if 'name_text_types' in kwargs else None + self.processing_name = kwargs['processing_name'] if 'processing_name' in kwargs else None + self.scale_name = kwargs['scale_name'] if 'scale_name' in kwargs else None + self.extract = kwargs['extract'] if 'extract' in kwargs else {} + + class GLCM: + """Organizes the GLCM features extraction parameters""" + def __init__( + self, + dist_correction: Union[bool, str] = False, + merge_method: str = "vol_merge" + ) -> None: + """ + Constructor of the GLCM class + + Args: + dist_correction(Union[bool, str]): norm for distance weighting, must be + "manhattan", "euclidean" or "chebyshev". If True the norm for distance weighting + is gonna be "euclidean". + merge_method(str): merging method which determines how features are + calculated. Must be "average", "slice_merge", "dir_merge" and "vol_merge". + + Returns: + None. + """ + self.dist_correction = dist_correction + self.merge_method = merge_method + + + class GLRLM: + """Organizes the GLRLM features extraction parameters""" + def __init__( + self, + dist_correction: Union[bool, str] = False, + merge_method: str = "vol_merge" + ) -> None: + """ + Constructor of the GLRLM class + + Args: + dist_correction(Union[bool, str]): If True the norm for distance weighting is gonna be "euclidean". + merge_method(str): merging method which determines how features are + calculated. Must be "average", "slice_merge", "dir_merge" and "vol_merge". + + Returns: + None. + """ + self.dist_correction = dist_correction + self.merge_method = merge_method + + + class NGTDM: + """Organizes the NGTDM features extraction parameters""" + def __init__( + self, + dist_correction: Union[bool, str] = None + ) -> None: + """ + Constructor of the NGTDM class + + Args: + dist_correction(Union[bool, str]): If True the norm for distance weighting is gonna be "euclidean". + + Returns: + None. + """ + self.dist_correction = dist_correction + + + class Radiomics: + """Organizes all the extracted features. + """ + def __init__(self, image: Dict = None, params: Dict = None) -> None: + """Constructor of the Radiomics class + Args: + image(Dict): Dict of the extracted features. + params(Dict): Dict of the parameters used in features extraction (roi type, voxels diemension...) + + Returns: + None + """ + self.image = image if image else {} + self.params = params if params else {} + + def update_params(self, params: Dict) -> None: + """Updates `params` attribute from a given Dict + Args: + params(Dict): Dict of the parameters used in features extraction (roi type, voxels diemension...) + + Returns: + None + """ + self.params['roi_type'] = params['roi_type'] + self.params['patientID'] = params['patientID'] + self.params['vox_dim'] = params['vox_dim'] + + def to_json(self) -> Dict: + """Summarizes the class attributes in a Dict + Args: + None + + Returns: + Dict: Dictionay of radiomics structure (extracted features and extraction params) + """ + radiomics = { + 'image': self.image, + 'params': self.params + } + return radiomics + + + class data: + """Organizes all imaging data (volume and ROI). + + Attributes: + volume (object): Instance of MEDscan.data.volume inner class. + ROI (object): Instance of MEDscan.data.ROI inner class. + orientation (str): Imaging data orientation (axial, sagittal or coronal). + patient_position (str): Patient position specifies the position of the + patient relative to the imaging equipment space (HFS, HFP...). + + """ + def __init__(self, orientation: str=None, patient_position: str=None) -> None: + """Constructor of the scan class + + Args: + orientation (str, optional): Imaging data orientation (axial, sagittal or coronal). + patient_position (str, optional): Patient position specifies the position of the + patient relative to the imaging equipment space (HFS, HFP...). + + Returns: + None. + """ + self.volume = self.volume() + self.volume_process = self.volume_process() + self.ROI = self.ROI() + self.orientation = orientation + self.patient_position = patient_position + + def set_patient_position(self, patient_position): + self.patient_position = patient_position + + def set_orientation(self, orientation): + self.orientation = orientation + + def set_volume(self, volume): + self.volume = volume + + def set_ROI(self, *args): + self.ROI = self.ROI(args) + + def get_roi_from_indexes(self, key: int) -> np.ndarray: + """ + Extracts ROI data using the saved indexes (Indexes of non-null values). + + Args: + key (int): Key of ROI indexes list (A volume can have multiple ROIs). + + Returns: + ndarray: n-dimensional array of ROI data. + + """ + roi_volume = np.zeros_like(self.volume.array).flatten() + roi_volume[self.ROI.get_indexes(key)] = 1 + return roi_volume.reshape(self.volume.array.shape) + + def get_indexes_by_roi_name(self, roi_name : str) -> np.ndarray: + """ + Extract ROI data using the ROI name. + + Args: + roi_name (str): String of the ROI name (A volume can have multiple ROIs). + + Returns: + ndarray: n-dimensional array of the ROI data. + + """ + roi_name_key = list(self.ROI.roi_names.values()).index(roi_name) + roi_volume = np.zeros_like(self.volume.array).flatten() + roi_volume[self.ROI.get_indexes(roi_name_key)] = 1 + return roi_volume.reshape(self.volume.array.shape) + + def display(self, _slice: int = None, roi: Union[str, int] = 0) -> None: + """Displays slices from imaging data with the ROI contour in XY-Plane. + + Args: + _slice (int, optional): Index of the slice you want to plot. + roi (Union[str, int], optional): ROI name or index. If not specified will use the first ROI. + + Returns: + None. + + """ + # extract slices containing ROI + size_m = self.volume.array.shape + i = np.arange(0, size_m[0]) + j = np.arange(0, size_m[1]) + k = np.arange(0, size_m[2]) + ind_mask = np.nonzero(self.get_roi_from_indexes(roi)) + J, I, K = np.meshgrid(i, j, k, indexing='ij') + I = I[ind_mask] + J = J[ind_mask] + K = K[ind_mask] + slices = np.unique(K) + + vol_data = self.volume.array.swapaxes(0, 1)[:, :, slices] + roi_data = self.get_roi_from_indexes(roi).swapaxes(0, 1)[:, :, slices] + + rows = int(np.round(np.sqrt(len(slices)))) + columns = int(np.ceil(len(slices) / rows)) + + plt.set_cmap(plt.gray()) + + # plot only one slice + if _slice: + fig, ax = plt.subplots(1, 1, figsize=(10, 5)) + ax.axis('off') + ax.set_title(_slice) + ax.imshow(vol_data[:, :, _slice]) + im = Image.fromarray((roi_data[:, :, _slice])) + ax.contour(im, colors='red', linewidths=0.4, alpha=0.45) + lps_ax = fig.add_subplot(1, columns, 1) + + # plot multiple slices containing an ROI. + else: + fig, axs = plt.subplots(rows, columns+1, figsize=(20, 10)) + s = 0 + for i in range(0,rows): + for j in range(0,columns): + axs[i,j].axis('off') + if s < len(slices): + axs[i,j].set_title(str(s)) + axs[i,j].imshow(vol_data[:, :, s]) + im = Image.fromarray((roi_data[:, :, s])) + axs[i,j].contour(im, colors='red', linewidths=0.4, alpha=0.45) + s += 1 + axs[i,columns].axis('off') + lps_ax = fig.add_subplot(1, columns+1, axs.shape[1]) + + fig.suptitle('XY-Plane') + fig.tight_layout() + + # add the coordinates system + lps_ax.axis([-1.5, 1.5, -1.5, 1.5]) + lps_ax.set_title("Coordinates system") + + lps_ax.quiver([-0.5], [0], [1.5], [0], scale_units='xy', angles='xy', scale=1.0, color='green') + lps_ax.quiver([-0.5], [0], [0], [-1.5], scale_units='xy', angles='xy', scale=3, color='blue') + lps_ax.quiver([-0.5], [0], [1.5], [1.5], scale_units='xy', angles='xy', scale=3, color='red') + lps_ax.text(1.0, 0, "L") + lps_ax.text(-0.3, -0.5, "P") + lps_ax.text(0.3, 0.4, "S") + + lps_ax.set_xticks([]) + lps_ax.set_yticks([]) + + plt.show() + + def display_process(self, _slice: int = None, roi: Union[str, int] = 0) -> None: + """Displays slices from imaging data with the ROI contour in XY-Plane. + + Args: + _slice (int, optional): Index of the slice you want to plot. + roi (Union[str, int], optional): ROI name or index. If not specified will use the first ROI. + + Returns: + None. + + """ + # extract slices containing ROI + size_m = self.volume_process.array.shape + i = np.arange(0, size_m[0]) + j = np.arange(0, size_m[1]) + k = np.arange(0, size_m[2]) + ind_mask = np.nonzero(self.get_roi_from_indexes(roi)) + J, I, K = np.meshgrid(j, i, k, indexing='ij') + I = I[ind_mask] + J = J[ind_mask] + K = K[ind_mask] + slices = np.unique(K) + + vol_data = self.volume_process.array.swapaxes(0, 1)[:, :, slices] + roi_data = self.get_roi_from_indexes(roi).swapaxes(0, 1)[:, :, slices] + + rows = int(np.round(np.sqrt(len(slices)))) + columns = int(np.ceil(len(slices) / rows)) + + plt.set_cmap(plt.gray()) + + # plot only one slice + if _slice: + fig, ax = plt.subplots(1, 1, figsize=(10, 5)) + ax.axis('off') + ax.set_title(_slice) + ax.imshow(vol_data[:, :, _slice]) + im = Image.fromarray((roi_data[:, :, _slice])) + ax.contour(im, colors='red', linewidths=0.4, alpha=0.45) + lps_ax = fig.add_subplot(1, columns, 1) + + # plot multiple slices containing an ROI. + else: + fig, axs = plt.subplots(rows, columns+1, figsize=(20, 10)) + s = 0 + for i in range(0,rows): + for j in range(0,columns): + axs[i,j].axis('off') + if s < len(slices): + axs[i,j].set_title(str(s)) + axs[i,j].imshow(vol_data[:, :, s]) + im = Image.fromarray((roi_data[:, :, s])) + axs[i,j].contour(im, colors='red', linewidths=0.4, alpha=0.45) + s += 1 + axs[i,columns].axis('off') + lps_ax = fig.add_subplot(1, columns+1, axs.shape[1]) + + fig.suptitle('XY-Plane') + fig.tight_layout() + + # add the coordinates system + lps_ax.axis([-1.5, 1.5, -1.5, 1.5]) + lps_ax.set_title("Coordinates system") + + lps_ax.quiver([-0.5], [0], [1.5], [0], scale_units='xy', angles='xy', scale=1.0, color='green') + lps_ax.quiver([-0.5], [0], [0], [-1.5], scale_units='xy', angles='xy', scale=3, color='blue') + lps_ax.quiver([-0.5], [0], [1.5], [1.5], scale_units='xy', angles='xy', scale=3, color='red') + lps_ax.text(1.0, 0, "L") + lps_ax.text(-0.3, -0.5, "P") + lps_ax.text(0.3, 0.4, "S") + + lps_ax.set_xticks([]) + lps_ax.set_yticks([]) + + plt.show() + + + class volume: + """Organizes all volume data and information related to imaging volume. + + Attributes: + spatialRef (imref3d): Imaging data orientation (axial, sagittal or coronal). + scan_rot (ndarray): Array of the rotation applied to the XYZ points of the ROI. + array (ndarray): n-dimensional of the imaging data. + + """ + def __init__(self, spatialRef: imref3d=None, scan_rot: str=None, array: np.ndarray=None) -> None: + """Organizes all volume data and information. + + Args: + spatialRef (imref3d, optional): Imaging data orientation (axial, sagittal or coronal). + scan_rot (ndarray, optional): Array of the rotation applied to the XYZ points of the ROI. + array (ndarray, optional): n-dimensional of the imaging data. + + """ + self.spatialRef = spatialRef + self.scan_rot = scan_rot + self.array = array + + def update_spatialRef(self, spatialRef_value): + self.spatialRef = spatialRef_value + + def update_scan_rot(self, scan_rot_value): + self.scan_rot = scan_rot_value + + def update_transScanToModel(self, transScanToModel_value): + self.transScanToModel = transScanToModel_value + + def update_array(self, array): + self.array = array + + def convert_to_LPS(self): + """Convert Imaging data to LPS (Left-Posterior-Superior) coordinates system. + . + + Returns: + None. + + """ + # flip x + self.array = np.flip(self.array, 0) + # flip y + self.array = np.flip(self.array, 1) + + def spatialRef_from_nifti(self, nifti_image_path: Union[Path, str]) -> None: + """Computes the imref3d spatialRef using a NIFTI file and + updates the `spatialRef` attribute. + + Args: + nifti_image_path (str): String of the NIFTI file path. + + Returns: + None. + + """ + # Loading the nifti file: + nifti_image_path = Path(nifti_image_path) + nifti = nib.load(nifti_image_path) + nifti_data = self.array + + # spatialRef Creation + pixelX = nifti.affine[0, 0] + pixelY = nifti.affine[1, 1] + sliceS = nifti.affine[2, 2] + min_grid = nifti.affine[:3, 3] + min_Xgrid = min_grid[0] + min_Ygrid = min_grid[1] + min_Zgrid = min_grid[2] + size_image = np.shape(nifti_data) + spatialRef = imref3d(size_image, abs(pixelX), abs(pixelY), abs(sliceS)) + spatialRef.XWorldLimits = (np.array(spatialRef.XWorldLimits) - + (spatialRef.XWorldLimits[0] - + (min_Xgrid-pixelX/2)) + ).tolist() + spatialRef.YWorldLimits = (np.array(spatialRef.YWorldLimits) - + (spatialRef.YWorldLimits[0] - + (min_Ygrid-pixelY/2)) + ).tolist() + spatialRef.ZWorldLimits = (np.array(spatialRef.ZWorldLimits) - + (spatialRef.ZWorldLimits[0] - + (min_Zgrid-sliceS/2)) + ).tolist() + + # Converting the results into lists + spatialRef.ImageSize = spatialRef.ImageSize.tolist() + spatialRef.XIntrinsicLimits = spatialRef.XIntrinsicLimits.tolist() + spatialRef.YIntrinsicLimits = spatialRef.YIntrinsicLimits.tolist() + spatialRef.ZIntrinsicLimits = spatialRef.ZIntrinsicLimits.tolist() + + # update spatialRef + self.update_spatialRef(spatialRef) + + def convert_spatialRef(self): + """converts the `spatialRef` attribute from RAS to LPS coordinates system. + . + + Args: + None. + + Returns: + None. + + """ + # swap x and y data + temp = self.spatialRef.ImageExtentInWorldX + self.spatialRef.ImageExtentInWorldX = self.spatialRef.ImageExtentInWorldY + self.spatialRef.ImageExtentInWorldY = temp + + temp = self.spatialRef.PixelExtentInWorldX + self.spatialRef.PixelExtentInWorldX = self.spatialRef.PixelExtentInWorldY + self.spatialRef.PixelExtentInWorldY = temp + + temp = self.spatialRef.XIntrinsicLimits + self.spatialRef.XIntrinsicLimits = self.spatialRef.YIntrinsicLimits + self.spatialRef.YIntrinsicLimits = temp + + temp = self.spatialRef.XWorldLimits + self.spatialRef.XWorldLimits = self.spatialRef.YWorldLimits + self.spatialRef.YWorldLimits = temp + del temp + + class volume_process: + """Organizes all volume data and information. + + Attributes: + spatialRef (imref3d): Imaging data orientation (axial, sagittal or coronal). + scan_rot (ndarray): Array of the rotation applied to the XYZ points of the ROI. + data (ndarray): n-dimensional of the imaging data. + + """ + def __init__(self, spatialRef: imref3d = None, + scan_rot: List = None, array: np.ndarray = None, + user_string: str = "") -> None: + """Organizes all volume data and information. + + Args: + spatialRef (imref3d, optional): Imaging data orientation (axial, sagittal or coronal). + scan_rot (ndarray, optional): Array of the rotation applied to the XYZ points of the ROI. + array (ndarray, optional): n-dimensional of the imaging data. + user_string(str, optional): string explaining the processed data in the class. + + Returns: + None. + + """ + self.array = array + self.scan_rot = scan_rot + self.spatialRef = spatialRef + self.user_string = user_string + + def update_processed_data(self, array: np.ndarray, user_string: str = "") -> None: + if user_string: + self.user_string = user_string + self.array = array + + def save(self, name_save: str, path_save: Union[Path, str])-> None: + """Saves the processed data locally. + + Args: + name_save(str): Saving name of the processed data. + path_save(Union[Path, str]): Path to where save the processed data. + + Returns: + None. + """ + path_save = Path(path_save) + if not name_save: + name_save = self.user_string + + if not name_save.endswith('.npy'): + name_save += '.npy' + + with open(path_save / name_save, 'wb') as f: + np.save(f, self.array) + + def load( + self, + file_name: str, + loading_path: Union[Path, str], + update: bool=True + ) -> Union[None, np.ndarray]: + """Saves the processed data locally. + + Args: + file_name(str): Name file of the processed data to load. + loading_path(Union[Path, str]): Path to the processed data to load. + update(bool, optional): If True, updates the class attrtibutes with loaded data. + + Returns: + None. + """ + loading_path = Path(loading_path) + + if not file_name.endswith('.npy'): + file_name += '.npy' + + with open(loading_path / file_name, 'rb') as f: + if update: + self.update_processed_data(np.load(f, allow_pickle=True)) + else: + return np.load(f, allow_pickle=True) + + + class ROI: + """Organizes all ROI data and information. + + Attributes: + indexes (Dict): Dict of the ROI indexes for each ROI name. + roi_names (Dict): Dict of the ROI names. + nameSet (Dict): Dict of the User-defined name for Structure Set for each ROI name. + nameSetInfo (Dict): Dict of the names of the structure sets that define the areas of + significance. Either 'StructureSetName', 'StructureSetDescription', 'SeriesDescription' + or 'SeriesInstanceUID'. + + """ + def __init__(self, indexes: Dict=None, roi_names: Dict=None) -> None: + """Constructor of the ROI class. + + Args: + indexes (Dict, optional): Dict of the ROI indexes for each ROI name. + roi_names (Dict, optional): Dict of the ROI names. + + Returns: + None. + """ + self.indexes = indexes if indexes else {} + self.roi_names = roi_names if roi_names else {} + self.nameSet = roi_names if roi_names else {} + self.nameSetInfo = roi_names if roi_names else {} + + def get_indexes(self, key): + if not self.indexes or key is None: + return {} + else: + return self.indexes[str(key)] + + def get_roi_name(self, key): + if not self.roi_names or key is None: + return {} + else: + return self.roi_names[str(key)] + + def get_name_set(self, key): + if not self.nameSet or key is None: + return {} + else: + return self.nameSet[str(key)] + + def get_name_set_info(self, key): + if not self.nameSetInfo or key is None: + return {} + else: + return self.nameSetInfo[str(key)] + + def update_indexes(self, key, indexes): + try: + self.indexes[str(key)] = indexes + except: + Warning.warn("Wrong key given in update_indexes()") + + def update_roi_name(self, key, roi_name): + try: + self.roi_names[str(key)] = roi_name + except: + Warning.warn("Wrong key given in update_roi_name()") + + def update_name_set(self, key, name_set): + try: + self.nameSet[str(key)] = name_set + except: + Warning.warn("Wrong key given in update_name_set()") + + def update_name_set_info(self, key, nameSetInfo): + try: + self.nameSetInfo[str(key)] = nameSetInfo + except: + Warning.warn("Wrong key given in update_name_set_info()") + + def convert_to_LPS(self, data: np.ndarray) -> np.ndarray: + """Converts the given volume to LPS coordinates system. For + more details please refer here : https://www.slicer.org/wiki/Coordinate_systems + Args: + data(ndarray) : Volume data in RAS to convert to to LPS + + Returns: + ndarray: n-dimensional of `data` in LPS. + """ + # flip x + data = np.flip(data, 0) + # flip y + data = np.flip(data, 1) + + return data + + def get_roi_from_path(self, roi_path: Union[Path, str], id: str): + """Extracts all ROI data from the given path for the given + patient ID and updates all class attributes with the new extracted data. + + Args: + roi_path(Union[Path, str]): Path where the ROI data is stored. + id(str): ID containing patient ID and the modality type, to identify the right file. + + Returns: + None. + """ + self.indexes = {} + self.roi_names = {} + self.nameSet = {} + self.nameSetInfo = {} + roi_index = 0 + list_of_patients = os.listdir(roi_path) + + for file in list_of_patients: + # Load the patient's ROI nifti files : + if file.startswith(id) and file.endswith('nii.gz') and 'ROI' in file.split("."): + roi = nib.load(roi_path + "/" + file) + roi_data = self.convert_to_LPS(data=roi.get_fdata()) + roi_name = file[file.find("(")+1 : file.find(")")] + name_set = file[file.find("_")+2 : file.find("(")] + self.update_indexes(key=roi_index, indexes=np.nonzero(roi_data.flatten())) + self.update_name_set(key=roi_index, name_set=name_set) + self.update_roi_name(key=roi_index, roi_name=roi_name) + roi_index += 1 diff --git a/MEDiml/__init__.py b/MEDiml/__init__.py new file mode 100644 index 0000000..6ed02d7 --- /dev/null +++ b/MEDiml/__init__.py @@ -0,0 +1,21 @@ +import logging + +from . import utils +from . import processing +from . import biomarkers +from . import filters +from . import wrangling +from . import learning +from .MEDscan import MEDscan + + +stream_handler = logging.StreamHandler() +stream_handler.setLevel(logging.WARNING) +logging.getLogger(__name__).addHandler(stream_handler) + +__author__ = "MEDomicsLab consortium" +__version__ = "0.9.8" +__copyright__ = "Copyright (C) MEDomicsLab consortium" +__license__ = "GNU General Public License 3.0" +__maintainer__ = "MAHDI AIT LHAJ LOUTFI" +__email__ = "medomics.info@gmail.com" diff --git a/MEDiml/biomarkers/BatchExtractor.py b/MEDiml/biomarkers/BatchExtractor.py new file mode 100644 index 0000000..c08f699 --- /dev/null +++ b/MEDiml/biomarkers/BatchExtractor.py @@ -0,0 +1,806 @@ +import logging +import math +import os +import pickle +import sys +from copy import deepcopy +from datetime import datetime +from itertools import product +from pathlib import Path +from time import time +from typing import Dict, List, Union + +import numpy as np +import pandas as pd +import ray +from tqdm import trange + +import MEDiml + + +class BatchExtractor(object): + """ + Organizes all the patients/scans in batches to extract all the radiomic features + """ + + def __init__( + self, + path_read: Union[str, Path], + path_csv: Union[str, Path], + path_params: Union[str, Path], + path_save: Union[str, Path], + n_batch: int = 4, + skip_existing: bool = False + ) -> None: + """ + constructor of the BatchExtractor class + """ + self._path_csv = Path(path_csv) + self._path_params = Path(path_params) + self._path_read = Path(path_read) + self._path_save = Path(path_save) + self.roi_types = [] + self.roi_type_labels = [] + self.n_bacth = n_batch + self.skip_existing = skip_existing + + def __load_and_process_params(self) -> Dict: + """Load and process the computing & batch parameters from JSON file""" + # Load json parameters + im_params = MEDiml.utils.json_utils.load_json(self._path_params) + + # Update class attributes + self.roi_types.extend(im_params['roi_types']) + self.roi_type_labels.extend(im_params['roi_type_labels']) + self.n_bacth = im_params['n_batch'] if 'n_batch' in im_params else self.n_bacth + + return im_params + + @ray.remote + def __compute_radiomics_one_patient( + self, + name_patient: str, + roi_name: str, + im_params: Dict, + roi_type: str, + roi_type_label: str, + log_file: Union[Path, str] + ) -> str: + """ + Computes all radiomics features (Texture & Non-texture) for one patient/scan + + Args: + name_patient(str): scan or patient full name. It has to respect the MEDiml naming convention: + PatientID__ImagingScanName.ImagingModality.npy + roi_name(str): name of the ROI that will be used in computation. + im_params(Dict): Dict of parameters/settings that will be used in the processing and computation. + roi_type(str): Type of ROI used in the processing and computation (for identification purposes) + roi_type_label(str): Label of the ROI used, to make it identifiable from other ROIs. + log_file(Union[Path, str]): Path to the logging file. + + Returns: + Union[Path, str]: Path to the updated logging file. + """ + # Setting up logging settings + logging.basicConfig(filename=log_file, level=logging.DEBUG, force=True) + + # Check if features are already computed for the current scan + if self.skip_existing: + modality = name_patient.split('.')[1] + name_save = name_patient.split('.')[0] + f'({roi_type_label})' + f'.{modality}.json' + if Path(self._path_save / f'features({roi_type})' / name_save).exists(): + logging.info("Skipping existing features for scan: {name_patient}") + return log_file + + # start timer + t_start = time() + + # Initialization + message = f"\n***************** COMPUTING FEATURES: {name_patient} *****************" + logging.info(message) + + # Load MEDscan instance + try: + with open(self._path_read / name_patient, 'rb') as f: medscan = pickle.load(f) + medscan = MEDiml.MEDscan(medscan) + except Exception as e: + print(f"\n ERROR LOADING PATIENT {name_patient}:\n {e}") + return None + + # Init processing & computation parameters + medscan.init_params(im_params) + logging.debug('Parameters parsed, json file is valid.') + + # Get ROI (region of interest) + logging.info("\n--> Extraction of ROI mask:") + try: + vol_obj_init, roi_obj_init = MEDiml.processing.get_roi_from_indexes( + medscan, + name_roi=roi_name, + box_string=medscan.params.process.box_string + ) + except: + # if for the current scan ROI is not found, computation is aborted. + return log_file + + start = time() + message = '--> Non-texture features pre-processing (interp + re-seg) for "Scale={}"'.\ + format(str(medscan.params.process.scale_non_text[0])) + logging.info(message) + + # Interpolation + # Intensity Mask + vol_obj = MEDiml.processing.interp_volume( + medscan=medscan, + vol_obj_s=vol_obj_init, + vox_dim=medscan.params.process.scale_non_text, + interp_met=medscan.params.process.vol_interp, + round_val=medscan.params.process.gl_round, + image_type='image', + roi_obj_s=roi_obj_init, + box_string=medscan.params.process.box_string + ) + # Morphological Mask + roi_obj_morph = MEDiml.processing.interp_volume( + medscan=medscan, + vol_obj_s=roi_obj_init, + vox_dim=medscan.params.process.scale_non_text, + interp_met=medscan.params.process.roi_interp, + round_val=medscan.params.process.roi_pv, + image_type='roi', + roi_obj_s=roi_obj_init, + box_string=medscan.params.process.box_string + ) + + # Re-segmentation + # Intensity mask range re-segmentation + roi_obj_int = deepcopy(roi_obj_morph) + roi_obj_int.data = MEDiml.processing.range_re_seg( + vol=vol_obj.data, + roi=roi_obj_int.data, + im_range=medscan.params.process.im_range + ) + # Intensity mask outlier re-segmentation + roi_obj_int.data = np.logical_and( + MEDiml.processing.outlier_re_seg( + vol=vol_obj.data, + roi=roi_obj_int.data, + outliers=medscan.params.process.outliers + ), + roi_obj_int.data + ).astype(int) + logging.info(f"{time() - start}\n") + + # Reset timer + start = time() + + # Preparation of computation : + medscan.init_ntf_calculation(vol_obj) + + # Image filtering: linear + if medscan.params.filter.filter_type: + if medscan.params.filter.filter_type.lower() == 'textural': + raise ValueError('For textural filtering, please use the BatchExtractorTexturalFilters class.') + try: + vol_obj = MEDiml.filters.apply_filter(medscan, vol_obj) + except Exception as e: + logging.error(f'PROBLEM WITH LINEAR FILTERING: {e}') + return log_file + + # ROI Extraction : + try: + vol_int_re = MEDiml.processing.roi_extract( + vol=vol_obj.data, + roi=roi_obj_int.data + ) + except Exception as e: + print(name_patient, e) + return log_file + + # check if ROI is empty + if math.isnan(np.nanmax(vol_int_re)) and math.isnan(np.nanmin(vol_int_re)): + logging.error(f'PROBLEM WITH INTENSITY MASK. ROI {roi_name} IS EMPTY.') + return log_file + + # Computation of non-texture features + logging.info("--> Computation of non-texture features:") + + # Morphological features extraction + try: + if medscan.params.radiomics.extract['Morph']: + morph = MEDiml.biomarkers.morph.extract_all( + vol=vol_obj.data, + mask_int=roi_obj_int.data, + mask_morph=roi_obj_morph.data, + res=medscan.params.process.scale_non_text, + intensity_type=medscan.params.process.intensity_type + ) + else: + morph = None + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF MORPHOLOGICAL FEATURES {e}') + morph = None + + # Local intensity features extraction + try: + if medscan.params.radiomics.extract['LocalIntensity']: + local_intensity = MEDiml.biomarkers.local_intensity.extract_all( + img_obj=vol_obj.data, + roi_obj=roi_obj_int.data, + res=medscan.params.process.scale_non_text, + intensity_type=medscan.params.process.intensity_type + ) + else: + local_intensity = None + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF LOCAL INTENSITY FEATURES {e}') + local_intensity = None + + # statistical features extraction + try: + if medscan.params.radiomics.extract['Stats']: + stats = MEDiml.biomarkers.stats.extract_all( + vol=vol_int_re, + intensity_type=medscan.params.process.intensity_type + ) + else: + stats = None + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF STATISTICAL FEATURES {e}') + stats = None + + # Intensity histogram equalization of the imaging volume + vol_quant_re, _ = MEDiml.processing.discretize( + vol_re=vol_int_re, + discr_type=medscan.params.process.ih['type'], + n_q=medscan.params.process.ih['val'], + user_set_min_val=medscan.params.process.user_set_min_value + ) + + # Intensity histogram features extraction + try: + if medscan.params.radiomics.extract['IntensityHistogram']: + int_hist = MEDiml.biomarkers.intensity_histogram.extract_all( + vol=vol_quant_re + ) + else: + int_hist = None + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF INTENSITY HISTOGRAM FEATURES {e}') + int_hist = None + + # Intensity histogram equalization of the imaging volume + if medscan.params.process.ivh and 'type' in medscan.params.process.ivh and 'val' in medscan.params.process.ivh: + if medscan.params.process.ivh['type'] and medscan.params.process.ivh['val']: + vol_quant_re, wd = MEDiml.processing.discretize( + vol_re=vol_int_re, + discr_type=medscan.params.process.ivh['type'], + n_q=medscan.params.process.ivh['val'], + user_set_min_val=medscan.params.process.user_set_min_value, + ivh=True + ) + else: + vol_quant_re = vol_int_re + wd = 1 + + # Intensity volume histogram features extraction + if medscan.params.radiomics.extract['IntensityVolumeHistogram']: + int_vol_hist = MEDiml.biomarkers.int_vol_hist.extract_all( + medscan=medscan, + vol=vol_quant_re, + vol_int_re=vol_int_re, + wd=wd + ) + else: + int_vol_hist = None + + # End of Non-Texture features extraction + logging.info(f"End of non-texture features extraction: {time() - start}\n") + + # Computation of texture features + logging.info("--> Computation of texture features:") + + # Compute radiomics features for each scale text + count = 0 + for s in range(medscan.params.process.n_scale): + start = time() + message = '--> Texture features: pre-processing (interp + ' \ + f'reSeg) for "Scale={str(medscan.params.process.scale_text[s][0])}": ' + logging.info(message) + + # Interpolation + # Intensity Mask + vol_obj = MEDiml.processing.interp_volume( + medscan=medscan, + vol_obj_s=vol_obj_init, + vox_dim=medscan.params.process.scale_text[s], + interp_met=medscan.params.process.vol_interp, + round_val=medscan.params.process.gl_round, + image_type='image', + roi_obj_s=roi_obj_init, + box_string=medscan.params.process.box_string + ) + # Morphological Mask + roi_obj_morph = MEDiml.processing.interp_volume( + medscan=medscan, + vol_obj_s=roi_obj_init, + vox_dim=medscan.params.process.scale_text[s], + interp_met=medscan.params.process.roi_interp, + round_val=medscan.params.process.roi_pv, + image_type='roi', + roi_obj_s=roi_obj_init, + box_string=medscan.params.process.box_string + ) + + # Re-segmentation + # Intensity mask range re-segmentation + roi_obj_int = deepcopy(roi_obj_morph) + roi_obj_int.data = MEDiml.processing.range_re_seg( + vol=vol_obj.data, + roi=roi_obj_int.data, + im_range=medscan.params.process.im_range + ) + # Intensity mask outlier re-segmentation + roi_obj_int.data = np.logical_and( + MEDiml.processing.outlier_re_seg( + vol=vol_obj.data, + roi=roi_obj_int.data, + outliers=medscan.params.process.outliers + ), + roi_obj_int.data + ).astype(int) + + # Image filtering: linear + if medscan.params.filter.filter_type: + if medscan.params.filter.filter_type.lower() == 'textural': + raise ValueError('For textural filtering, please use the BatchExtractorTexturalFilters class.') + try: + vol_obj = MEDiml.filters.apply_filter(medscan, vol_obj) + except Exception as e: + logging.error(f'PROBLEM WITH LINEAR FILTERING: {e}') + return log_file + + logging.info(f"{time() - start}\n") + + # Compute features for each discretisation algorithm and for each grey-level + for a, n in product(range(medscan.params.process.n_algo), range(medscan.params.process.n_gl)): + count += 1 + start = time() + message = '--> Computation of texture features in image ' \ + 'space for "Scale= {}", "Algo={}", "GL={}" ({}):'.format( + str(medscan.params.process.scale_text[s][1]), + medscan.params.process.algo[a], + str(medscan.params.process.gray_levels[a][n]), + str(count) + '/' + str(medscan.params.process.n_exp) + ) + logging.info(message) + + # Preparation of computation : + medscan.init_tf_calculation(algo=a, gl=n, scale=s) + + # ROI Extraction : + vol_int_re = MEDiml.processing.roi_extract( + vol=vol_obj.data, + roi=roi_obj_int.data) + + # Discretisation : + try: + vol_quant_re, _ = MEDiml.processing.discretize( + vol_re=vol_int_re, + discr_type=medscan.params.process.algo[a], + n_q=medscan.params.process.gray_levels[a][n], + user_set_min_val=medscan.params.process.user_set_min_value + ) + except Exception as e: + logging.error(f'PROBLEM WITH DISCRETIZATION: {e}') + vol_quant_re = None + + # GLCM features extraction + try: + if medscan.params.radiomics.extract['GLCM']: + glcm = MEDiml.biomarkers.glcm.extract_all( + vol=vol_quant_re, + dist_correction=medscan.params.radiomics.glcm.dist_correction) + else: + glcm = None + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF GLCM FEATURES {e}') + glcm = None + + # GLRLM features extraction + try: + if medscan.params.radiomics.extract['GLRLM']: + glrlm = MEDiml.biomarkers.glrlm.extract_all( + vol=vol_quant_re, + dist_correction=medscan.params.radiomics.glrlm.dist_correction) + else: + glrlm = None + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF GLRLM FEATURES {e}') + glrlm = None + + # GLSZM features extraction + try: + if medscan.params.radiomics.extract['GLSZM']: + glszm = MEDiml.biomarkers.glszm.extract_all( + vol=vol_quant_re) + else: + glszm = None + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF GLSZM FEATURES {e}') + glszm = None + + # GLDZM features extraction + try: + if medscan.params.radiomics.extract['GLDZM']: + gldzm = MEDiml.biomarkers.gldzm.extract_all( + vol_int=vol_quant_re, + mask_morph=roi_obj_morph.data) + else: + gldzm = None + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF GLDZM FEATURES {e}') + gldzm = None + + # NGTDM features extraction + try: + if medscan.params.radiomics.extract['NGTDM']: + ngtdm = MEDiml.biomarkers.ngtdm.extract_all( + vol=vol_quant_re, + dist_correction=medscan.params.radiomics.ngtdm.dist_correction) + else: + ngtdm = None + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF NGTDM FEATURES {e}') + ngtdm = None + + # NGLDM features extraction + try: + if medscan.params.radiomics.extract['NGLDM']: + ngldm = MEDiml.biomarkers.ngldm.extract_all( + vol=vol_quant_re) + else: + ngldm = None + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF NGLDM FEATURES {e}') + ngldm = None + + # Update radiomics results class + medscan.update_radiomics( + int_vol_hist_features=int_vol_hist, + morph_features=morph, + loc_int_features=local_intensity, + stats_features=stats, + int_hist_features=int_hist, + glcm_features=glcm, + glrlm_features=glrlm, + glszm_features=glszm, + gldzm_features=gldzm, + ngtdm_features=ngtdm, + ngldm_features=ngldm + ) + + # End of texture features extraction + logging.info(f"End of texture features extraction: {time() - start}\n") + + # Saving radiomics results + medscan.save_radiomics( + scan_file_name=name_patient, + path_save=self._path_save, + roi_type=roi_type, + roi_type_label=roi_type_label, + ) + + logging.info(f"TOTAL TIME:{time() - t_start} seconds\n\n") + + return log_file + + @ray.remote + def __compute_radiomics_tables( + self, + table_tags: List, + log_file: Union[str, Path], + im_params: Dict + ) -> None: + """ + Creates radiomic tables off of the saved dicts with the computed features and save it as CSV files + + Args: + table_tags(List): Lists of information about scans, roi type and imaging space (or filter space) + log_file(Union[str, Path]): Path to logging file. + im_params(Dict): Dictionary of parameters. + + Returns: + None. + """ + n_tables = len(table_tags) + + for t in range(0, n_tables): + scan = table_tags[t][0] + roi_type = table_tags[t][1] + roi_label = table_tags[t][2] + im_space = table_tags[t][3] + modality = table_tags[t][4] + + # extract parameters for the current modality + if modality == 'CTscan' and 'imParamCT' in im_params: + im_params_mod = im_params['imParamCT'] + elif modality== 'MRscan' and 'imParamMR' in im_params: + im_params_mod = im_params['imParamMR'] + elif modality == 'PTscan' and 'imParamPET' in im_params: + im_params_mod = im_params['imParamPET'] + # extract name save of the used filter + if 'filter_type' in im_params_mod: + filter_type = im_params_mod['filter_type'] + if filter_type in im_params['imParamFilter'] and 'name_save' in im_params['imParamFilter'][filter_type]: + name_save = im_params['imParamFilter'][filter_type]['name_save'] + else: + name_save= '' + else: + name_save= '' + + # set up table name + if name_save: + name_table = 'radiomics__' + scan + \ + '(' + roi_type + ')__' + name_save + '.npy' + else: + name_table = 'radiomics__' + scan + \ + '(' + roi_type + ')__' + im_space + '.npy' + + # Start timer + start = time() + logging.info("\n --> Computing radiomics table: {name_table}...") + + # Wildcard used to look only in the parent folder (save path), + # no need to recursively look into sub-folders using '**/'. + wildcard = '*_' + scan + '(' + roi_type + ')*.json' + + # Create radiomics table + radiomics_table_dict = MEDiml.utils.create_radiomics_table( + MEDiml.utils.get_file_paths(self._path_save / f'features({roi_label})', wildcard), + im_space, + log_file + ) + radiomics_table_dict['Properties']['Description'] = name_table + + # Save radiomics table + save_path = self._path_save / f'features({roi_label})' / name_table + np.save(save_path, [radiomics_table_dict]) + + # Create CSV table and Definitions + MEDiml.utils.write_radiomics_csv(save_path) + + logging.info(f"DONE\n {time() - start}\n") + + return log_file + + def __batch_all_patients(self, im_params: Dict) -> None: + """ + Create batches of scans to process and compute radiomics features for every single scan. + + Args: + im_params(Dict): Dict of the processing & computation parameters. + + Returns: + None + """ + # create a batch for each roi type + n_roi_types = len(self.roi_type_labels) + for r in range(0, n_roi_types): + roi_type = self.roi_types[r] + roi_type_label = self.roi_type_labels[r] + print(f'\n --> Computing features for the "{roi_type_label}" roi type ...', end = '') + + # READING CSV EXPERIMENT TABLE + tabel_roi = pd.read_csv(self._path_csv / ('roiNames_' + roi_type_label + '.csv')) + tabel_roi['under'] = '_' + tabel_roi['dot'] = '.' + tabel_roi['npy'] = '.npy' + name_patients = (pd.Series( + tabel_roi[['PatientID', 'under', 'under', + 'ImagingScanName', + 'dot', + 'ImagingModality', + 'npy']].fillna('').values.tolist()).str.join('')).tolist() + tabel_roi = tabel_roi.drop(columns=['under', 'under', 'dot', 'npy']) + roi_names = tabel_roi.ROIname.tolist() + + # INITIALIZATION + os.chdir(self._path_save) + name_bacth_log = 'batchLog_' + roi_type_label + p = Path.cwd().glob('*') + files = [x for x in p if x.is_dir()] + n_files = len(files) + exist_file = name_bacth_log in [x.name for x in files] + if exist_file and (n_files > 0): + for i in range(0, n_files): + if (files[i].name == name_bacth_log): + mod_timestamp = datetime.fromtimestamp( + Path(files[i]).stat().st_mtime) + date = mod_timestamp.strftime("%d-%b-%Y_%HH%MM%SS") + new_name = name_bacth_log+'_'+date + if sys.platform == 'win32': + os.system('move ' + name_bacth_log + ' ' + new_name) + else: + os.system('mv ' + name_bacth_log + ' ' + new_name) + + os.makedirs(name_bacth_log, 0o777, True) + path_batch = Path.cwd() / name_bacth_log + + # PRODUCE BATCH COMPUTATIONS + n_patients = len(name_patients) + n_batch = self.n_bacth + if n_batch is None or n_batch < 0: + n_batch = 1 + elif n_patients < n_batch: + n_batch = n_patients + + # Produce a list log_file path. + log_files = [path_batch / ('log_file_' + str(i) + '.log') for i in range(n_batch)] + + # Distribute the first tasks to all workers + ids = [self.__compute_radiomics_one_patient.remote( + self, + name_patient=name_patients[i], + roi_name=roi_names[i], + im_params=im_params, + roi_type=roi_type, + roi_type_label=roi_type_label, + log_file=log_files[i]) + for i in range(n_batch)] + + # Distribute the remaining tasks + nb_job_left = n_patients - n_batch + for _ in trange(n_patients): + ready, not_ready = ray.wait(ids, num_returns=1) + ids = not_ready + log_file = ray.get(ready)[0] + if nb_job_left > 0: + idx = n_patients - nb_job_left + ids.extend([self.__compute_radiomics_one_patient.remote( + self, + name_patients[idx], + roi_names[idx], + im_params, + roi_type, + roi_type_label, + log_file) + ]) + nb_job_left -= 1 + + print('DONE') + + def __batch_all_tables(self, im_params: Dict): + """ + Create batches of tables of the extracted features for every imaging scan type (CT, PET...). + + Args: + im_params(Dict): Dictionary of parameters. + + Returns: + None + """ + # GETTING COMBINATIONS OF scan, roi_type and imageSpaces + n_roi_types = len(self.roi_type_labels) + table_tags = [] + # Get all scan names present for the given roi_type_label + for r in range(0, n_roi_types): + label = self.roi_type_labels[r] + wildcard = '*' + label + '*.json' + file_paths = MEDiml.utils.get_file_paths(self._path_save / f'features({self.roi_types[r]})', wildcard) + n_files = len(file_paths) + scans = [0] * n_files + modalities = [0] * n_files + for f in range(0, n_files): + rad_file_name = file_paths[f].stem + scans[f] = MEDiml.utils.get_scan_name_from_rad_name(rad_file_name) + modalities[f] = rad_file_name.split('.')[1] + scans = s = (np.unique(np.array(scans))).tolist() + n_scans = len(scans) + # Get all scan names present for the given roi_type_label and scans + for s in range(0, n_scans): + scan = scans[s] + modality = modalities[s] + wildcard = '*' + scan + '(' + label + ')*.json' + file_paths = MEDiml.utils.get_file_paths(self._path_save / f'features({self.roi_types[r]})', wildcard) + n_files = len(file_paths) + + # Finding the images spaces for a test file (assuming that all + # files for a given scan and roi_type_label have the same image spaces + radiomics = MEDiml.utils.json_utils.load_json(file_paths[0]) + im_spaces = [key for key in radiomics.keys()] + im_spaces = im_spaces[:-1] + n_im_spaces = len(im_spaces) + # Constructing the table_tags variable + for i in range(0, n_im_spaces): + im_space = im_spaces[i] + table_tags = table_tags + [[scan, label, self.roi_types[r], im_space, modality]] + + # INITIALIZATION + os.chdir(self._path_save) + name_batch_log = 'batchLog_tables' + p = Path.cwd().glob('*') + files = [x for x in p if x.is_dir()] + n_files = len(files) + exist_file = name_batch_log in [x.name for x in files] + if exist_file and (n_files > 0): + for i in range(0, n_files): + if files[i].name == name_batch_log: + mod_timestamp = datetime.fromtimestamp( + Path(files[i]).stat().st_mtime) + date = mod_timestamp.strftime("%d-%b-%Y_%H:%M:%S") + new_name = name_batch_log+'_'+date + if sys.platform == 'win32': + os.system('move ' + name_batch_log + ' ' + new_name) + else: + os.system('mv ' + name_batch_log + ' ' + new_name) + + os.makedirs(name_batch_log, 0o777, True) + path_batch = Path.cwd() + + # PRODUCE BATCH COMPUTATIONS + n_tables = len(table_tags) + self.n_bacth = self.n_bacth + if self.n_bacth is None or self.n_bacth < 0: + self.n_bacth = 1 + elif n_tables < self.n_bacth: + self.n_bacth = n_tables + + # Produce a list log_file path. + log_files = [path_batch / ('log_file_' + str(i) + '.txt') for i in range(self.n_bacth)] + + # Distribute the first tasks to all workers + ids = [self.__compute_radiomics_tables.remote( + self, + [table_tags[i]], + log_files[i], + im_params) + for i in range(self.n_bacth)] + + nb_job_left = n_tables - self.n_bacth + + for _ in trange(n_tables): + ready, not_ready = ray.wait(ids, num_returns=1) + ids = not_ready + + # We verify if error has occur during the process + log_file = ray.get(ready)[0] + + # Distribute the remaining tasks + if nb_job_left > 0: + idx = n_tables - nb_job_left + ids.extend([self.__compute_radiomics_tables.remote( + self, + [table_tags[idx]], + log_file, + im_params)]) + nb_job_left -= 1 + + print('DONE') + + def compute_radiomics(self, create_tables: bool = True) -> None: + """Compute all radiomic features for all scans in the CSV file (set in initialization) and organize it + in JSON and CSV files + + Args: + create_tables(bool) : True to create CSV tables for the extracted features and not save it in JSON only. + + Returns: + None. + """ + + # Load and process computing parameters + im_params = self.__load_and_process_params() + + # Initialize ray + if ray.is_initialized(): + ray.shutdown() + + ray.init(local_mode=True, include_dashboard=True, num_cpus=self.n_bacth) + + # Batch all scans from CSV file and compute radiomics for each scan + self.__batch_all_patients(im_params) + + # Create a CSV file off of the computed features for all the scans + if create_tables: + self.__batch_all_tables(im_params) diff --git a/MEDiml/biomarkers/BatchExtractorTexturalFilters.py b/MEDiml/biomarkers/BatchExtractorTexturalFilters.py new file mode 100644 index 0000000..6377532 --- /dev/null +++ b/MEDiml/biomarkers/BatchExtractorTexturalFilters.py @@ -0,0 +1,840 @@ +import logging +import math +import os +import pickle +import sys +from copy import deepcopy +from datetime import datetime +from itertools import product +from pathlib import Path +from time import time +from typing import Dict, List, Union + +import numpy as np +import pandas as pd +import ray +from tqdm import trange + +import MEDiml + + +class BatchExtractorTexturalFilters(object): + """ + Organizes all the patients/scans in batches to extract all the radiomic features + """ + + def __init__( + self, + path_read: Union[str, Path], + path_csv: Union[str, Path], + path_params: Union[str, Path], + path_save: Union[str, Path], + n_batch: int = 4 + ) -> None: + """ + constructor of the BatchExtractor class + """ + self._path_csv = Path(path_csv) + self._path_params = Path(path_params) + self._path_read = Path(path_read) + self._path_save = Path(path_save) + self.roi_types = [] + self.roi_type_labels = [] + self.n_bacth = n_batch + self.glcm_features = [ + "Fcm_joint_max", + "Fcm_joint_avg", + "Fcm_joint_var", + "Fcm_joint_entr", + "Fcm_diff_avg", + "Fcm_diff_var", + "Fcm_diff_entr", + "Fcm_sum_avg", + "Fcm_sum_var", + "Fcm_sum_entr", + "Fcm_energy", + "Fcm_contrast", + "Fcm_dissimilarity", + "Fcm_inv_diff", + "Fcm_inv_diff_norm", + "Fcm_inv_diff_mom", + "Fcm_inv_diff_mom_norm", + "Fcm_inv_var", + "Fcm_corr", + "Fcm_auto_corr", + "Fcm_clust_tend", + "Fcm_clust_shade", + "Fcm_clust_prom", + "Fcm_info_corr1", + "Fcm_info_corr2" + ] + + def __load_and_process_params(self) -> Dict: + """Load and process the computing & batch parameters from JSON file""" + # Load json parameters + im_params = MEDiml.utils.json_utils.load_json(self._path_params) + + # Update class attributes + self.roi_types.extend(im_params['roi_types']) + self.roi_type_labels.extend(im_params['roi_type_labels']) + self.n_bacth = im_params['n_batch'] if 'n_batch' in im_params else self.n_bacth + + return im_params + + def __compute_radiomics_one_patient( + self, + name_patient: str, + roi_name: str, + im_params: Dict, + roi_type: str, + roi_type_label: str, + log_file: Union[Path, str], + skip_existing: bool + ) -> str: + """ + Computes all radiomics features (Texture & Non-texture) for one patient/scan + + Args: + name_patient(str): scan or patient full name. It has to respect the MEDiml naming convention: + PatientID__ImagingScanName.ImagingModality.npy + roi_name(str): name of the ROI that will be used in computation. + im_params(Dict): Dict of parameters/settings that will be used in the processing and computation. + roi_type(str): Type of ROI used in the processing and computation (for identification purposes) + roi_type_label(str): Label of the ROI used, to make it identifiable from other ROIs. + log_file(Union[Path, str]): Path to the logging file. + skip_existing(bool): True to skip the computation of the features for the scans that already have been computed. + + Returns: + Union[Path, str]: Path to the updated logging file. + """ + # Check if the features for the current filter have already been computed + if skip_existing: + list_feature = [] + # Find the glcm filters that have not been computed yet + for i in range(len(self.glcm_features)): + index_dot = name_patient.find('.') + ext = name_patient.find('.npy') + name_save = name_patient[:index_dot] + '(' + roi_type_label + ')' + name_patient[index_dot : ext] + ".json" + name_roi_type = roi_type + '_' + self.glcm_features[i] + path_to_check = Path(self._path_save / f'features({name_roi_type})') + if not (path_to_check / name_save).exists(): + list_feature.append(i) + # If all the features have already been computed, skip the computation + if len(list_feature) == 0: + return log_file + + # Setting up logging settings + logging.basicConfig(filename=log_file, level=logging.DEBUG, force=True) + + # start timer + t_start = time() + + # Initialization + message = f"\n***************** COMPUTING FEATURES: {name_patient} *****************" + logging.info(message) + + # Load MEDscan instance + try: + with open(self._path_read / name_patient, 'rb') as f: medscan = pickle.load(f) + medscan = MEDiml.MEDscan(medscan) + except Exception as e: + logging.error(f"\n ERROR LOADING PATIENT {name_patient}:\n {e}") + return None + + # Init processing & computation parameters + medscan.init_params(im_params) + logging.debug('Parameters parsed, json file is valid.') + + # Get ROI (region of interest) + logging.info("\n--> Extraction of ROI mask:") + try: + vol_obj_init, roi_obj_init = MEDiml.processing.get_roi_from_indexes( + medscan, + name_roi=roi_name, + box_string=medscan.params.process.box_string + ) + except: + # if for the current scan ROI is not found, computation is aborted. + return log_file + + start = time() + message = '--> Non-texture features pre-processing (interp + re-seg) for "Scale={}"'.\ + format(str(medscan.params.process.scale_non_text[0])) + logging.info(message) + + # Interpolation + # Intensity Mask + vol_obj = MEDiml.processing.interp_volume( + medscan=medscan, + vol_obj_s=vol_obj_init, + vox_dim=medscan.params.process.scale_non_text, + interp_met=medscan.params.process.vol_interp, + round_val=medscan.params.process.gl_round, + image_type='image', + roi_obj_s=roi_obj_init, + box_string=medscan.params.process.box_string + ) + # Morphological Mask + roi_obj_morph = MEDiml.processing.interp_volume( + medscan=medscan, + vol_obj_s=roi_obj_init, + vox_dim=medscan.params.process.scale_non_text, + interp_met=medscan.params.process.roi_interp, + round_val=medscan.params.process.roi_pv, + image_type='roi', + roi_obj_s=roi_obj_init, + box_string=medscan.params.process.box_string + ) + + # Re-segmentation + # Intensity mask range re-segmentation + roi_obj_int = deepcopy(roi_obj_morph) + roi_obj_int.data = MEDiml.processing.range_re_seg( + vol=vol_obj.data, + roi=roi_obj_int.data, + im_range=medscan.params.process.im_range + ) + # Intensity mask outlier re-segmentation + roi_obj_int.data = np.logical_and( + MEDiml.processing.outlier_re_seg( + vol=vol_obj.data, + roi=roi_obj_int.data, + outliers=medscan.params.process.outliers + ), + roi_obj_int.data + ).astype(int) + logging.info(f"{time() - start}\n") + + # Reset timer + start = time() + + # Image textural filtering + logging.info("--> Image textural filtering:") + + # Preparation of computation : + medscan.init_ntf_calculation(vol_obj) + + # ROI Extraction : + try: + vol_int_re = MEDiml.processing.roi_extract( + vol=vol_obj.data, + roi=roi_obj_int.data + ) + except Exception as e: + print(name_patient, e) + return log_file + + # Apply textural filter + try: + if medscan.params.process.user_set_min_value is None: + medscan.params.process.user_set_min_value = np.nanmin(vol_int_re) + vol_obj_all_features = MEDiml.filters.apply_filter( + medscan, + vol_int_re, + user_set_min_val=medscan.params.process.user_set_min_value + ) + except Exception as e: + print(e) + logging.error(f'PROBLEM WITH TEXTURAL FILTERING: {e}') + return log_file + + # Initialize ray + if ray.is_initialized(): + ray.shutdown() + + ray.init(local_mode=True, include_dashboard=True, num_cpus=self.n_bacth) + + # Loop through all the filters and extract the features for each filter + ids = [] + nb_filters = len(list_feature) + if nb_filters < self.n_bacth: + self.n_bacth = nb_filters + for i in range(self.n_bacth): + # Extract the filtered volume + filter_idx = list_feature[i] + vol_obj.data = deepcopy(vol_obj_all_features[...,filter_idx]) + + # Compute radiomics features + logging.info(f"--> Computation of radiomics features for filter {filter_idx}:") + + ids.append( + self.__compute_radiomics_filtered_volume.remote( + self, + medscan=medscan, + vol_obj=vol_obj, + roi_obj_int=roi_obj_int, + roi_obj_morph=roi_obj_morph, + name_patient=name_patient, + roi_name=roi_name, + roi_type=roi_type + '_' + self.glcm_features[filter_idx], + roi_type_label=roi_type_label, + log_file=log_file + ) + ) + # Distribute the remaining tasks + nb_job_left = nb_filters - self.n_bacth + if nb_job_left > 0: + for i in range(nb_filters - nb_job_left, nb_filters): + ready, not_ready = ray.wait(ids, num_returns=1) + ids = not_ready + try: + log_file = ray.get(ready)[0] + except: + pass + # Extract the filtered volume + filter_idx = list_feature[i] + vol_obj.data = deepcopy(vol_obj_all_features[...,filter_idx]) + + # Compute radiomics features + logging.info(f"--> Computation of radiomics features for filter {filter_idx}:") + + ids.append( + self.__compute_radiomics_filtered_volume.remote( + self, + medscan=medscan, + vol_obj=vol_obj, + roi_obj_int=roi_obj_int, + roi_obj_morph=roi_obj_morph, + name_patient=name_patient, + roi_name=roi_name, + roi_type=roi_type + '_' + self.glcm_features[filter_idx], + roi_type_label=roi_type_label, + log_file=log_file + ) + ) + + logging.info(f"TOTAL TIME:{time() - t_start} seconds\n\n") + + # Empty memory + del medscan + + @ray.remote + def __compute_radiomics_filtered_volume( + self, + medscan: MEDiml.MEDscan, + vol_obj, + roi_obj_int, + roi_obj_morph, + name_patient, + roi_name, + roi_type, + roi_type_label, + log_file + ) -> Union[Path, str]: + + # time + t_start = time() + + # ROI Extraction : + vol_int_re = deepcopy(vol_obj.data) + + # check if ROI is empty + if math.isnan(np.nanmax(vol_int_re)) and math.isnan(np.nanmin(vol_int_re)): + logging.error(f'PROBLEM WITH INTENSITY MASK. ROI {roi_name} IS EMPTY.') + return log_file + + # Computation of non-texture features + logging.info("--> Computation of non-texture features:") + + # Morphological features extraction + try: + morph = MEDiml.biomarkers.morph.extract_all( + vol=vol_obj.data, + mask_int=roi_obj_int.data, + mask_morph=roi_obj_morph.data, + res=medscan.params.process.scale_non_text, + intensity_type=medscan.params.process.intensity_type + ) + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF MORPHOLOGICAL FEATURES {e}') + morph = None + + # Local intensity features extraction + try: + local_intensity = MEDiml.biomarkers.local_intensity.extract_all( + img_obj=vol_obj.data, + roi_obj=roi_obj_int.data, + res=medscan.params.process.scale_non_text, + intensity_type=medscan.params.process.intensity_type + ) + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF LOCAL INTENSITY FEATURES {e}') + local_intensity = None + + # statistical features extraction + try: + stats = MEDiml.biomarkers.stats.extract_all( + vol=vol_int_re, + intensity_type=medscan.params.process.intensity_type + ) + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF STATISTICAL FEATURES {e}') + stats = None + + # Intensity histogram equalization of the imaging volume + vol_quant_re, _ = MEDiml.processing.discretize( + vol_re=vol_int_re, + discr_type=medscan.params.process.ih['type'], + n_q=medscan.params.process.ih['val'], + user_set_min_val=medscan.params.process.user_set_min_value + ) + + # Intensity histogram features extraction + try: + int_hist = MEDiml.biomarkers.intensity_histogram.extract_all( + vol=vol_quant_re + ) + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF INTENSITY HISTOGRAM FEATURES {e}') + int_hist = None + + # Intensity histogram equalization of the imaging volume + if medscan.params.process.ivh and 'type' in medscan.params.process.ivh and 'val' in medscan.params.process.ivh: + if medscan.params.process.ivh['type'] and medscan.params.process.ivh['val']: + vol_quant_re, wd = MEDiml.processing.discretize( + vol_re=vol_int_re, + discr_type=medscan.params.process.ivh['type'], + n_q=medscan.params.process.ivh['val'], + user_set_min_val=medscan.params.process.user_set_min_value, + ivh=True + ) + else: + vol_quant_re = vol_int_re + wd = 1 + + # Intensity volume histogram features extraction + try: + int_vol_hist = MEDiml.biomarkers.int_vol_hist.extract_all( + medscan=medscan, + vol=vol_quant_re, + vol_int_re=vol_int_re, + wd=wd + ) + except: + print("Error ivh:",name_patient) + int_vol_hist = {'Fivh_V10': [], + 'Fivh_V90': [], + 'Fivh_I10': [], + 'Fivh_I90': [], + 'Fivh_V10minusV90': [], + 'Fivh_I10minusI90': [], + 'Fivh_auc': [] + } + + # End of Non-Texture features extraction + logging.info(f"End of non-texture features extraction: {time() - t_start}\n") + + # Computation of texture features + logging.info("--> Computation of texture features:") + + # Compute radiomics features for each scale text + count = 0 + logging.info(f"{time() - t_start}\n") + + # Compute features for each discretisation algorithm and for each grey-level + for a, n in product(range(medscan.params.process.n_algo), range(medscan.params.process.n_gl)): + count += 1 + start = time() + message = '--> Computation of texture features in image ' \ + 'space for "Scale= {}", "Algo={}", "GL={}" ({}):'.format( + str(medscan.params.process.scale_text[0][1]), + medscan.params.process.algo[a], + str(medscan.params.process.gray_levels[a][n]), + str(count) + '/' + str(medscan.params.process.n_exp) + ) + logging.info(message) + + # Preparation of computation : + medscan.init_tf_calculation(algo=a, gl=n, scale=0) + + # Discretisation : + try: + vol_quant_re, _ = MEDiml.processing.discretize( + vol_re=vol_int_re, + discr_type=medscan.params.process.algo[a], + n_q=medscan.params.process.gray_levels[a][n], + user_set_min_val=medscan.params.process.user_set_min_value + ) + except Exception as e: + logging.error(f'PROBLEM WITH DISCRETIZATION: {e}') + vol_quant_re = None + + # GLCM features extraction + try: + glcm = MEDiml.biomarkers.glcm.extract_all( + vol=vol_quant_re, + dist_correction=medscan.params.radiomics.glcm.dist_correction + ) + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF GLCM FEATURES {e}') + glcm = None + + # GLRLM features extraction + try: + glrlm = MEDiml.biomarkers.glrlm.extract_all( + vol=vol_quant_re, + dist_correction=medscan.params.radiomics.glrlm.dist_correction + ) + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF GLRLM FEATURES {e}') + glrlm = None + + # GLSZM features extraction + try: + glszm = MEDiml.biomarkers.glszm.extract_all(vol=vol_quant_re) + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF GLSZM FEATURES {e}') + glszm = None + + # GLDZM features extraction + try: + gldzm = MEDiml.biomarkers.gldzm.extract_all( + vol_int=vol_quant_re, + mask_morph=roi_obj_morph.data + ) + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF GLDZM FEATURES {e}') + gldzm = None + + # NGTDM features extraction + try: + ngtdm = MEDiml.biomarkers.ngtdm.extract_all( + vol=vol_quant_re, + dist_correction=medscan.params.radiomics.ngtdm.dist_correction + ) + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF NGTDM FEATURES {e}') + ngtdm = None + + # NGLDM features extraction + try: + ngldm = MEDiml.biomarkers.ngldm.extract_all(vol=vol_quant_re) + except Exception as e: + logging.error(f'PROBLEM WITH COMPUTATION OF NGLDM FEATURES {e}') + ngldm = None + + # Update radiomics results class + medscan.update_radiomics( + int_vol_hist_features=int_vol_hist, + morph_features=morph, + loc_int_features=local_intensity, + stats_features=stats, + int_hist_features=int_hist, + glcm_features=glcm, + glrlm_features=glrlm, + glszm_features=glszm, + gldzm_features=gldzm, + ngtdm_features=ngtdm, + ngldm_features=ngldm + ) + + # End of texture features extraction + logging.info(f"End of texture features extraction: {time() - start}\n") + + # Saving radiomics results + medscan.save_radiomics( + scan_file_name=name_patient, + path_save=self._path_save, + roi_type=roi_type, + roi_type_label=roi_type_label, + ) + + logging.info(f"TOTAL TIME 1 FILTER:{time() - t_start} seconds\n\n") + + return log_file + + @ray.remote + def __compute_radiomics_tables( + self, + table_tags: List, + log_file: Union[str, Path], + im_params: Dict, + feature_name: str + ) -> None: + """ + Creates radiomic tables off of the saved dicts with the computed features and save it as CSV files + + Args: + table_tags(List): Lists of information about scans, roi type and imaging space (or filter space) + log_file(Union[str, Path]): Path to logging file. + im_params(Dict): Dictionary of parameters. + + Returns: + None. + """ + n_tables = len(table_tags) + + for t in range(0, n_tables): + scan = table_tags[t][0] + roi_type = table_tags[t][1] + roi_label = table_tags[t][2] + im_space = table_tags[t][3] + modality = table_tags[t][4] + + # extract parameters for the current modality + if modality == 'CTscan' and 'imParamCT' in im_params: + im_params_mod = im_params['imParamCT'] + elif modality== 'MRscan' and 'imParamMR' in im_params: + im_params_mod = im_params['imParamMR'] + elif modality == 'PTscan' and 'imParamPET' in im_params: + im_params_mod = im_params['imParamPET'] + # extract name save of the used filter + if 'filter_type' in im_params_mod: + filter_type = im_params_mod['filter_type'] + if filter_type in im_params['imParamFilter'] and 'name_save' in im_params['imParamFilter'][filter_type]: + name_save = im_params['imParamFilter'][filter_type]['name_save'] + '_' + feature_name + else: + name_save= feature_name + else: + name_save= feature_name + + # set up table name + if name_save: + name_table = 'radiomics__' + scan + \ + '(' + roi_type + ')__' + name_save + '.npy' + else: + name_table = 'radiomics__' + scan + \ + '(' + roi_type + ')__' + im_space + '.npy' + + # Start timer + start = time() + logging.info("\n --> Computing radiomics table: {name_table}...") + + # Wildcard used to look only in the parent folder (save path), + # no need to recursively look into sub-folders using '**/'. + wildcard = '*_' + scan + '(' + roi_type + ')*.json' + + # Create radiomics table + radiomics_table_dict = MEDiml.utils.create_radiomics_table( + MEDiml.utils.get_file_paths(self._path_save / f'features({roi_label})', wildcard), + im_space, + log_file + ) + radiomics_table_dict['Properties']['Description'] = name_table + + # Save radiomics table + save_path = self._path_save / f'features({roi_label})' / name_table + np.save(save_path, [radiomics_table_dict]) + + # Create CSV table and Definitions + MEDiml.utils.write_radiomics_csv(save_path) + + logging.info(f"DONE\n {time() - start}\n") + + return log_file + + def __batch_all_patients(self, im_params: Dict, skip_existing) -> None: + """ + Create batches of scans to process and compute radiomics features for every single scan. + + Args: + im_params(Dict): Dict of the processing & computation parameters. + skip_existing(bool) : True to skip the computation of the features for the scans that already have been computed. + + Returns: + None + """ + # create a batch for each roi type + n_roi_types = len(self.roi_type_labels) + for r in range(0, n_roi_types): + roi_type = self.roi_types[r] + roi_type_label = self.roi_type_labels[r] + print(f'\n --> Computing features for the "{roi_type_label}" roi type ...', end = '') + + # READING CSV EXPERIMENT TABLE + tabel_roi = pd.read_csv(self._path_csv / ('roiNames_' + roi_type_label + '.csv')) + tabel_roi['under'] = '_' + tabel_roi['dot'] = '.' + tabel_roi['npy'] = '.npy' + name_patients = (pd.Series( + tabel_roi[['PatientID', 'under', 'under', + 'ImagingScanName', + 'dot', + 'ImagingModality', + 'npy']].fillna('').values.tolist()).str.join('')).tolist() + tabel_roi = tabel_roi.drop(columns=['under', 'under', 'dot', 'npy']) + roi_names = tabel_roi.ROIname.tolist() + + # INITIALIZATION + os.chdir(self._path_save) + name_bacth_log = 'batchLog_' + roi_type_label + p = Path.cwd().glob('*') + files = [x for x in p if x.is_dir()] + n_files = len(files) + exist_file = name_bacth_log in [x.name for x in files] + if exist_file and (n_files > 0): + for i in range(0, n_files): + if (files[i].name == name_bacth_log): + mod_timestamp = datetime.fromtimestamp( + Path(files[i]).stat().st_mtime) + date = mod_timestamp.strftime("%d-%b-%Y_%HH%MM%SS") + new_name = name_bacth_log+'_'+date + if sys.platform == 'win32': + os.system('move ' + name_bacth_log + ' ' + new_name) + else: + os.system('mv ' + name_bacth_log + ' ' + new_name) + + os.makedirs(name_bacth_log, 0o777, True) + path_batch = Path.cwd() / name_bacth_log + + # PRODUCE BATCH COMPUTATIONS + n_patients = len(name_patients) + + # Produce a list log_file path. + log_files = [path_batch / ('log_file_' + str(i) + '.log') for i in range(n_patients)] + + # Features computation for each patient (patients loop) + for i in trange(n_patients): + self.__compute_radiomics_one_patient( + name_patients[i], + roi_names[i], + im_params, + roi_type, + roi_type_label, + log_files[i], + skip_existing + ) + + print('DONE') + + def __batch_all_tables(self, im_params: Dict): + """ + Create batches of tables of the extracted features for every imaging scan type (CT, PET...). + + Args: + im_params(Dict): Dictionary of parameters. + + Returns: + None + """ + # INITIALIZATION + os.chdir(self._path_save) + name_batch_log = 'batchLog_tables' + p = Path.cwd().glob('*') + files = [x for x in p if x.is_dir()] + n_files = len(files) + exist_file = name_batch_log in [x.name for x in files] + if exist_file and (n_files > 0): + for i in range(0, n_files): + if files[i].name == name_batch_log: + mod_timestamp = datetime.fromtimestamp( + Path(files[i]).stat().st_mtime) + date = mod_timestamp.strftime("%d-%b-%Y_%H:%M:%S") + new_name = name_batch_log+'_'+date + if sys.platform == 'win32': + os.system('move ' + name_batch_log + ' ' + new_name) + else: + os.system('mv ' + name_batch_log + ' ' + new_name) + + os.makedirs(name_batch_log, 0o777, True) + path_batch = Path.cwd() + + # GETTING COMBINATIONS OF scan, roi_type and imageSpaces + n_roi_types = len(self.roi_type_labels) + + # Get all scan names present for the given roi_type_label + for f_idx in range(0, len(self.glcm_features)): + # RE-INITIALIZATION + table_tags = [] + for r in range(0, n_roi_types): + label = self.roi_type_labels[r] + wildcard = '*' + label + '*.json' + roi_type = self.roi_types[r] + '_' + self.glcm_features[f_idx] + file_paths = MEDiml.utils.get_file_paths(self._path_save / f'features({roi_type})', wildcard) + n_files = len(file_paths) + scans = [0] * n_files + modalities = [0] * n_files + for f in range(0, n_files): + rad_file_name = file_paths[f].stem + scans[f] = MEDiml.utils.get_scan_name_from_rad_name(rad_file_name) + modalities[f] = rad_file_name.split('.')[1] + scans = s = (np.unique(np.array(scans))).tolist() + n_scans = len(scans) + # Get all scan names present for the given roi_type_label and scans + for s in range(0, n_scans): + scan = scans[s] + modality = modalities[s] + wildcard = '*' + scan + '(' + label + ')*.json' + file_paths = MEDiml.utils.get_file_paths(self._path_save / f'features({roi_type})', wildcard) + n_files = len(file_paths) + + # Finding the images spaces for a test file (assuming that all + # files for a given scan and roi_type_label have the same image spaces + radiomics = MEDiml.utils.json_utils.load_json(file_paths[0]) + im_spaces = [key for key in radiomics.keys()] + im_spaces = im_spaces[:-1] + n_im_spaces = len(im_spaces) + # Constructing the table_tags variable + for i in range(0, n_im_spaces): + im_space = im_spaces[i] + table_tags = table_tags + [[scan, label, roi_type, im_space, modality]] + + # PRODUCE BATCH COMPUTATIONS + n_tables = len(table_tags) + self.n_bacth = self.n_bacth + if self.n_bacth is None or self.n_bacth < 0: + self.n_bacth = 1 + elif n_tables < self.n_bacth: + self.n_bacth = n_tables + + # Produce a list log_file path. + log_files = [path_batch / ('log_file_' + str(i) + '.txt') for i in range(self.n_bacth)] + + # Initialize ray + if ray.is_initialized(): + ray.shutdown() + + ray.init(local_mode=True, include_dashboard=True, num_cpus=self.n_bacth) + + # Distribute the first tasks to all workers + ids = [self.__compute_radiomics_tables.remote( + self, + [table_tags[i]], + log_files[i], + im_params, + self.glcm_features[f_idx]) + for i in range(self.n_bacth)] + + nb_job_left = n_tables - self.n_bacth + + for _ in trange(n_tables): + ready, not_ready = ray.wait(ids, num_returns=1) + ids = not_ready + + # We verify if error has occur during the process + log_file = ray.get(ready)[0] + + # Distribute the remaining tasks + if nb_job_left > 0: + idx = n_tables - nb_job_left + ids.extend([self.__compute_radiomics_tables.remote( + self, + [table_tags[idx]], + log_file, + im_params, + self.glcm_features[f_idx])]) + nb_job_left -= 1 + + print('DONE') + + def compute_radiomics(self, create_tables: bool = True, skip_existing: bool = False) -> None: + """Compute all radiomic features for all scans in the CSV file (set in initialization) and organize it + in JSON and CSV files + + Args: + create_tables(bool) : True to create CSV tables for the extracted features and not save it in JSON only. + skip_existing(bool) : True to skip the computation of the features for the scans that already have been computed. + + Returns: + None. + """ + + # Load and process computing parameters + im_params = self.__load_and_process_params() + + # Batch all scans from CSV file and compute radiomics for each scan + self.__batch_all_patients(im_params, skip_existing) + + # Create a CSV file off of the computed features for all the scans + if create_tables: + self.__batch_all_tables(im_params) diff --git a/MEDiml/biomarkers/__init__.py b/MEDiml/biomarkers/__init__.py new file mode 100644 index 0000000..439f9c0 --- /dev/null +++ b/MEDiml/biomarkers/__init__.py @@ -0,0 +1,16 @@ +from . import * +from .BatchExtractor import * +from .BatchExtractorTexturalFilters import * +from .diagnostics import * +from .get_oriented_bound_box import * +from .glcm import * +from .gldzm import * +from .glrlm import * +from .glszm import * +from .int_vol_hist import * +from .intensity_histogram import * +from .local_intensity import * +from .morph import * +from .ngldm import * +from .ngtdm import * +from .stats import * diff --git a/MEDiml/biomarkers/diagnostics.py b/MEDiml/biomarkers/diagnostics.py new file mode 100644 index 0000000..492ef54 --- /dev/null +++ b/MEDiml/biomarkers/diagnostics.py @@ -0,0 +1,125 @@ +from typing import Dict + +import numpy as np + +from ..processing.segmentation import compute_bounding_box + + +def extract_all(vol_obj: np.ndarray, + roi_obj_int: np.ndarray, + roi_obj_morph: np.ndarray, + im_type: str) -> Dict: + """Computes diagnostic features + + The diagnostic features help identify issues with + the implementation of the image processing sequence. + + Args: + vol_obj (ndarray): Imaging data. + roi_obj_int (ndarray): Intensity mask data. + roi_obj_morph (ndarray): Morphological mask data. + im_type (str): Image processing step. + + - 'reSeg': Computes Diagnostic features right after the re-segmentaion step. + - 'interp' or any other arg: Computes Diagnostic features for any processing step other than re-segmentation. + + Returns: + Dict: Dictionnary containing the computed features. + """ + diag = {} + + # FOR THE IMAGE + + if im_type != 'reSeg': + # Image dimension x + diag.update({'image_' + im_type + '_dimX': + vol_obj.spatialRef.ImageSize[0]}) + + # Image dimension y + diag.update({'image_' + im_type + '_dimY': + vol_obj.spatialRef.ImageSize[1]}) + + # Image dimension z + diag.update({'image_' + im_type + '_dimz': + vol_obj.spatialRef.ImageSize[2]}) + + # Voxel dimension x + diag.update({'image_' + im_type + '_voxDimX': + vol_obj.spatialRef.PixelExtentInWorldX}) + + # Voxel dimension y + diag.update({'image_' + im_type + '_voxDimY': + vol_obj.spatialRef.PixelExtentInWorldY}) + + # Voxel dimension z + diag.update({'image_' + im_type + '_voxDimZ': + vol_obj.spatialRef.PixelExtentInWorldZ}) + + # Mean intensity + diag.update({'image_' + im_type + '_meanInt': np.mean(vol_obj.data)}) + + # Minimum intensity + diag.update({'image_' + im_type + '_minInt': np.min(vol_obj.data)}) + + # Maximum intensity + diag.update({'image_' + im_type + '_maxInt': np.max(vol_obj.data)}) + + # FOR THE ROI + box_bound_int = compute_bounding_box(roi_obj_int.data) + box_bound_morph = compute_bounding_box(roi_obj_morph.data) + + x_gl_int = vol_obj.data[roi_obj_int.data == 1] + x_gl_morph = vol_obj.data[roi_obj_morph.data == 1] + + # Map dimension x + diag.update({'roi_' + im_type + '_Int_dimX': + roi_obj_int.spatialRef.ImageSize[0]}) + + # Map dimension y + diag.update({'roi_' + im_type + '_Int_dimY': + roi_obj_int.spatialRef.ImageSize[1]}) + + # Map dimension z + diag.update({'roi_' + im_type + '_Int_dimZ': + roi_obj_int.spatialRef.ImageSize[2]}) + + # Bounding box dimension x + diag.update({'roi_' + im_type + '_Int_boxBoundDimX': + box_bound_int[0, 1] - box_bound_int[0, 0] + 1}) + + # Bounding box dimension y + diag.update({'roi_' + im_type + '_Int_boxBoundDimY': + box_bound_int[1, 1] - box_bound_int[1, 0] + 1}) + + # Bounding box dimension z + diag.update({'roi_' + im_type + '_Int_boxBoundDimZ': + box_bound_int[2, 1] - box_bound_int[2, 0] + 1}) + + # Bounding box dimension x + diag.update({'roi_' + im_type + '_Morph_boxBoundDimX': + box_bound_morph[0, 1] - box_bound_morph[0, 0] + 1}) + + # Bounding box dimension y + diag.update({'roi_' + im_type + '_Morph_boxBoundDimY': + box_bound_morph[1, 1] - box_bound_morph[1, 0] + 1}) + + # Bounding box dimension z + diag.update({'roi_' + im_type + '_Morph_boxBoundDimZ': + box_bound_morph[2, 1] - box_bound_morph[2, 0] + 1}) + + # Voxel number + diag.update({'roi_' + im_type + '_Int_voxNumb': np.size(x_gl_int)}) + + # Voxel number + diag.update({'roi_' + im_type + '_Morph_voxNumb': np.size(x_gl_morph)}) + + # Mean intensity + diag.update({'roi_' + im_type + '_meanInt': np.mean(x_gl_int)}) + + # Minimum intensity + diag.update({'roi_' + im_type + '_minInt': np.min(x_gl_int)}) + + # Maximum intensity + diag.update({'roi_' + im_type + '_maxInt': np.max(x_gl_int)}) + + return diag diff --git a/MEDiml/biomarkers/get_oriented_bound_box.py b/MEDiml/biomarkers/get_oriented_bound_box.py new file mode 100644 index 0000000..189b30a --- /dev/null +++ b/MEDiml/biomarkers/get_oriented_bound_box.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from typing import List + +import numpy as np +import pandas as pd + + +def rot_matrix(theta: float, + dim: int=2, + rot_axis: int=-1) -> np.ndarray: + """Creates a 2d or 3d rotation matrix + + Args: + theta (float): angle in radian + dim (int, optional): dimension size. Defaults to 2. + rot_axis (int, optional): rotation axis value. Defaults to -1. + + Returns: + ndarray: rotation matrix + """ + + if dim == 2: + rot_mat = np.array([[np.cos(theta), -np.sin(theta)], + [np.sin(theta), np.cos(theta)]]) + + elif dim == 3: + if rot_axis == 0: + rot_mat = np.array([[1.0, 0.0, 0.0], + [0.0, np.cos(theta), -np.sin(theta)], + [0.0, np.sin(theta), np.cos(theta)]]) + elif rot_axis == 1: + rot_mat = np.array([[np.cos(theta), 0.0, np.sin(theta)], + [0.0, 1.0, 0.0], + [-np.sin(theta), 0.0, np.cos(theta)]]) + elif rot_axis == 2: + rot_mat = np.array([[np.cos(theta), -np.sin(theta), 0.0], + [np.sin(theta), np.cos(theta), 0.0], + [0.0, 0.0, 1.0]]) + else: + rot_mat = None + else: + rot_mat = None + + return rot_mat + + +def sig_proc_segmentise(x: List) -> List: + """Produces a list of segments from input x with values (0,1) + + Args: + x (List): list of values + + Returns: + List: list of segments from input x with values (0,1) + """ + + # Create a difference vector + y = np.diff(x) + + # Find start and end indices of sections with value 1 + ind_1_start = np.array(np.where(y == 1)).flatten() + if np.shape(ind_1_start)[0] > 0: + ind_1_start += 1 + ind_1_end = np.array(np.where(y == -1)).flatten() + + # Check for boundary effects + if x[0] == 1: + ind_1_start = np.insert(ind_1_start, 0, 0) + if x[-1] == 1: + ind_1_end = np.append(ind_1_end, np.shape(x)[0]-1) + + # Generate segment df for segments with value 1 + if np.shape(ind_1_start)[0] == 0: + df_one = pd.DataFrame({"i": [], + "j": [], + "val": []}) + else: + df_one = pd.DataFrame({"i": ind_1_start, + "j": ind_1_end, + "val": np.ones(np.shape(ind_1_start)[0])}) + + # Find start and end indices for section with value 0 + if np.shape(ind_1_start)[0] == 0: + ind_0_start = np.array([0]) + ind_0_end = np.array([np.shape(x)[0]-1]) + else: + ind_0_end = ind_1_start - 1 + ind_0_start = ind_1_end + 1 + + # Check for boundary effect + if x[0] == 0: + ind_0_start = np.insert(ind_0_start, 0, 0) + if x[-1] == 0: + ind_0_end = np.append(ind_0_end, np.shape(x)[0]-1) + + # Check for out-of-range boundary effects + if ind_0_end[0] < 0: + ind_0_end = np.delete(ind_0_end, 0) + if ind_0_start[-1] >= np.shape(x)[0]: + ind_0_start = np.delete(ind_0_start, -1) + + # Generate segment df for segments with value 0 + if np.shape(ind_0_start)[0] == 0: + df_zero = pd.DataFrame({"i": [], + "j": [], + "val": []}) + else: + df_zero = pd.DataFrame({"i": ind_0_start, + "j": ind_0_end, + "val": np.zeros(np.shape(ind_0_start)[0])}) + + df_segm = df_one.append(df_zero).sort_values(by="i").reset_index(drop=True) + + return df_segm + + +def sig_proc_find_peaks(x: float, + ddir: str="pos") -> pd.DataFrame: + """Determines peak positions in array of values + + Args: + x (float): value + ddir (str, optional): positive or negative value. Defaults to "pos". + + Returns: + pd.DataFrame: peak positions in array of values + """ + + # Invert when looking for local minima + if ddir == "neg": + x = -x + + # Generate segments where slope is negative + + df_segm = sig_proc_segmentise(x=(np.diff(x) < 0.0)*1) + + # Start of slope coincides with position of peak (due to index shift induced by np.diff) + ind_peak = df_segm.loc[df_segm.val == 1, "i"].values + + # Check right boundary + if x[-1] > x[-2]: + ind_peak = np.append(ind_peak, np.shape(x)[0]-1) + + # Construct dataframe with index and corresponding value + if np.shape(ind_peak)[0] == 0: + df_peak = pd.DataFrame({"ind": [], + "val": []}) + else: + if ddir == "pos": + df_peak = pd.DataFrame({"ind": ind_peak, + "val": x[ind_peak]}) + if ddir == "neg": + df_peak = pd.DataFrame({"ind": ind_peak, + "val": -x[ind_peak]}) + return df_peak diff --git a/MEDiml/biomarkers/glcm.py b/MEDiml/biomarkers/glcm.py new file mode 100644 index 0000000..ce63dc4 --- /dev/null +++ b/MEDiml/biomarkers/glcm.py @@ -0,0 +1,1602 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from copy import deepcopy +from typing import Dict, List, Union, List + +import numpy as np +import pandas as pd + +from ..utils.textureTools import (coord2index, get_neighbour_direction, + get_value, is_list_all_none) + + +def get_matrix(roi_only: np.ndarray, + levels: Union[np.ndarray, List], + dist_correction=True) -> np.ndarray: + r""" + This function computes the Gray-Level Co-occurence Matrix (GLCM) of the + region of interest (ROI) of an input volume. The input volume is assumed + to be isotropically resampled. Only one GLCM is computed per scan, + simultaneously recording (i.e. adding up) the neighboring properties of + the 26-connected neighbors of all voxels in the ROI. To account for + discretization length differences, neighbors at a distance of :math:`\sqrt{3}` + voxels around a center voxel increment the GLCM by a value of :math:`\sqrt{3}`, + neighbors at a distance of :math:`\sqrt{2}` voxels around a center voxel increment + the GLCM by a value of :math:`\sqrt{2}`, and neighbors at a distance of 1 voxels + around a center voxel increment the GLCM by a value of 1. + This matrix refers to "Grey level co-occurrence based features" (ID = LFYI) + in the `IBSI1 reference manual `_. + + Args: + roi_only (ndarray): Smallest box containing the ROI, with the imaging data + ready for texture analysis computations. Voxels outside the ROI are + set to NaNs. + levels (ndarray or List): Vector containing the quantized gray-levels in the tumor region + (or reconstruction ``levels`` of quantization). + dist_correction (bool, optional): Set this variable to true in order to use + discretization length difference corrections as used by the `Institute of Physics and + Engineering in Medicine `_. + Set this variable to false to replicate IBSI results. + + Returns: + ndarray: Gray-Level Co-occurence Matrix of ``roi_only``. + + References: + [1] Haralick, R. M., Shanmugam, K., & Dinstein, I. (1973). Textural \ + features for image classification. IEEE Transactions on Systems, \ + Man and Cybernetics, smc 3(6), 610–621. + """ + # PARSING "dist_correction" ARGUMENT + if type(dist_correction) is not bool: + # The user did not input either "true" or "false", + # so the default behavior is used. + dist_correction = True + + # PRELIMINARY + roi_only = roi_only.copy() + level_temp = np.max(levels)+1 + roi_only[np.isnan(roi_only)] = level_temp + #levels = np.append(levels, level_temp) + dim = np.shape(roi_only) + + if np.ndim(roi_only) == 2: + dim[2] = 1 + + q2 = np.reshape(roi_only, (1, np.prod(dim))) + + # QUANTIZATION EFFECTS CORRECTION (M. Vallieres) + # In case (for example) we initially wanted to have 64 levels, but due to + # quantization, only 60 resulted. + # qs = round(levels*adjust)/adjust; + # q2 = round(q2*adjust)/adjust; + + #qs = levels + qs = levels.tolist() + [level_temp] + lqs = np.size(qs) + + q3 = q2*0 + for k in range(0, lqs): + q3[q2 == qs[k]] = k + + q3 = np.reshape(q3, dim).astype(int) + GLCM = np.zeros((lqs, lqs)) + + for i in range(1, dim[0]+1): + i_min = max(1, i-1) + i_max = min(i+1, dim[0]) + for j in range(1, dim[1]+1): + j_min = max(1, j-1) + j_max = min(j+1, dim[1]) + for k in range(1, dim[2]+1): + k_min = max(1, k-1) + k_max = min(k+1, dim[2]) + val_q3 = q3[i-1, j-1, k-1] + for I2 in range(i_min, i_max+1): + for J2 in range(j_min, j_max+1): + for K2 in range(k_min, k_max+1): + if (I2 == i) & (J2 == j) & (K2 == k): + continue + else: + val_neighbor = q3[I2-1, J2-1, K2-1] + if dist_correction: + # Discretization length correction + GLCM[val_q3, val_neighbor] += \ + np.sqrt(abs(I2-i)+abs(J2-j)+abs(K2-k)) + else: + GLCM[val_q3, val_neighbor] += 1 + + GLCM = GLCM[0:-1, 0:-1] + + return GLCM + +def joint_max(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes joint maximum features. + This feature refers to "Fcm_joint_max" (ID = GYBY) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]:: List or float of the joint maximum feature(s) + """ + temp = [] + joint_max = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + temp.append(np.max(df_pij.pij)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, joint max: {sum(temp) / len(temp)}') + joint_max.append(sum(temp) / len(temp)) + return joint_max + +def joint_avg(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes joint average features. + This feature refers to "Fcm_joint_avg" (ID = 60VM) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]:: List or float of the joint average feature(s) + """ + temp = [] + joint_avg = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + temp.append(np.sum(df_pij.i * df_pij.pij)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, joint avg: {sum(temp) / len(temp)}') + joint_avg.append(sum(temp) / len(temp)) + return joint_avg + +def joint_var(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes joint variance features. + This feature refers to "Fcm_var" (ID = UR99) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: List or float of the joint variance feature(s) + """ + temp = [] + joint_var = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + m_u = np.sum(df_pij.i * df_pij.pij) + temp.append(np.sum((df_pij.i - m_u) ** 2.0 * df_pij.pij)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, joint var: {sum(temp) / len(temp)}') + joint_var.append(sum(temp) / len(temp)) + return joint_var + +def joint_entr(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes joint entropy features. + This feature refers to "Fcm_joint_entr" (ID = TU9B) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the joint entropy features + """ + temp = [] + joint_entr = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + temp.append(-np.sum(df_pij.pij * np.log2(df_pij.pij))) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, joint entr: {sum(temp) / len(temp)}') + joint_entr.append(sum(temp) / len(temp)) + return joint_entr + +def diff_avg(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes difference average features. + This feature refers to "Fcm_diff_avg" (ID = TF7R) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the difference average features + """ + temp = [] + diff_avg = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + _, _, _, df_pimj, _, _ = glcm.get_cm_data([np.nan, np.nan]) + temp.append(np.sum(df_pimj.k * df_pimj.pimj)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, diff avg: {sum(temp) / len(temp)}') + diff_avg.append(sum(temp) / len(temp)) + return diff_avg + +def diff_var(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes difference variance features. + This feature refers to "Fcm_diff_var" (ID = D3YU) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the difference variance features + """ + temp = [] + diff_var = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + _, _, _, df_pimj, _, _ = glcm.get_cm_data([np.nan, np.nan]) + m_u = np.sum(df_pimj.k * df_pimj.pimj) + temp.append(np.sum((df_pimj.k - m_u) ** 2.0 * df_pimj.pimj)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, diff var: {sum(temp) / len(temp)}') + diff_var.append(sum(temp) / len(temp)) + return diff_var + +def diff_entr(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes difference entropy features. + This feature refers to "Fcm_diff_entr" (ID = NTRS) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the difference entropy features + """ + temp = [] + diff_entr = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + _, _, _, df_pimj, _, _ = glcm.get_cm_data([np.nan, np.nan]) + temp.append(-np.sum(df_pimj.pimj * np.log2(df_pimj.pimj))) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, diff entr: {sum(temp) / len(temp)}') + diff_entr.append(sum(temp) / len(temp)) + return diff_entr + +def sum_avg(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes sum average features. + This feature refers to "Fcm_sum_avg" (ID = ZGXS) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the sum average features + """ + temp = [] + sum_avg = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + _, _, _, _, df_pipj, _ = glcm.get_cm_data([np.nan, np.nan]) + temp.append(np.sum(df_pipj.k * df_pipj.pipj)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, sum avg: {sum(temp) / len(temp)}') + sum_avg.append(sum(temp) / len(temp)) + return sum_avg + +def sum_var(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes sum variance features. + This feature refers to "Fcm_sum_var" (ID = OEEB) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the sum variance features + """ + temp = [] + sum_var = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + _, _, _, _, df_pipj, _ = glcm.get_cm_data([np.nan, np.nan]) + m_u = np.sum(df_pipj.k * df_pipj.pipj) + temp.append(np.sum((df_pipj.k - m_u) ** 2.0 * df_pipj.pipj)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, sum var: {sum(temp) / len(temp)}') + sum_var.append(sum(temp) / len(temp)) + return sum_var + +def sum_entr(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes sum entropy features. + This feature refers to "Fcm_sum_entr" (ID = P6QZ) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the sum entropy features + """ + temp = [] + sum_entr = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + _, _, _, _, df_pipj, _ = glcm.get_cm_data([np.nan, np.nan]) + temp.append(-np.sum(df_pipj.pipj * np.log2(df_pipj.pipj))) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, sum entr: {sum(temp) / len(temp)}') + sum_entr.append(sum(temp) / len(temp)) + return sum_entr + +def energy(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes angular second moment features. + This feature refers to "Fcm_energy" (ID = 8ZQL) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the angular second moment features + """ + temp = [] + energy = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + temp.append(np.sum(df_pij.pij ** 2.0)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, energy: {sum(temp) / len(temp)}') + energy.append(sum(temp) / len(temp)) + return energy + +def contrast(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes constrast features. + This feature refers to "Fcm_contrast" (ID = ACUI) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the contrast features + """ + temp = [] + contrast = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + temp.append(np.sum((df_pij.i - df_pij.j) ** 2.0 * df_pij.pij)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, contrast: {sum(temp) / len(temp)}') + contrast.append(sum(temp) / len(temp)) + return contrast + +def dissimilarity(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes dissimilarity features. + This feature refers to "Fcm_dissimilarity" (ID = 8S9J) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the dissimilarity features + """ + temp = [] + dissimilarity = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + temp.append(np.sum(np.abs(df_pij.i - df_pij.j) * df_pij.pij)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, dissimilarity: {sum(temp) / len(temp)}') + dissimilarity.append(sum(temp) / len(temp)) + return dissimilarity + +def inv_diff(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes inverse difference features. + This feature refers to "Fcm_inv_diff" (ID = IB1Z) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the inverse difference features + """ + temp = [] + inv_diff = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + temp.append(np.sum(df_pij.pij / (1.0 + np.abs(df_pij.i - df_pij.j)))) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, inv diff: {sum(temp) / len(temp)}') + inv_diff.append(sum(temp) / len(temp)) + return inv_diff + +def inv_diff_norm(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes inverse difference normalized features. + This feature refers to "Fcm_inv_diff_norm" (ID = NDRX) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the inverse difference normalized features + """ + temp = [] + inv_diff_norm = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, _, _, _, _, n_g = glcm.get_cm_data([np.nan, np.nan]) + temp.append(np.sum(df_pij.pij / (1.0 + np.abs(df_pij.i - df_pij.j) / n_g))) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, inv diff norm: {sum(temp) / len(temp)}') + inv_diff_norm.append(sum(temp) / len(temp)) + return inv_diff_norm + +def inv_diff_mom(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes inverse difference moment features. + This feature refers to "Fcm_inv_diff_mom" (ID = WF0Z) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the inverse difference moment features + """ + temp = [] + inv_diff_mom = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + temp.append(np.sum(df_pij.pij / (1.0 + (df_pij.i - df_pij.j) ** 2.0))) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, inv diff mom: {sum(temp) / len(temp)}') + inv_diff_mom.append(sum(temp) / len(temp)) + return inv_diff_mom + +def inv_diff_mom_norm(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes inverse difference moment normalized features. + This feature refers to "Fcm_inv_diff_mom_norm" (ID = 1QCO) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the inverse difference moment normalized features + """ + temp = [] + inv_diff_mom_norm = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, _, _, _, _, n_g = glcm.get_cm_data([np.nan, np.nan]) + temp.append(np.sum(df_pij.pij / (1.0 + (df_pij.i - df_pij.j)** 2.0 / n_g ** 2.0))) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, inv diff mom norm: {sum(temp) / len(temp)}') + inv_diff_mom_norm.append(sum(temp) / len(temp)) + return inv_diff_mom_norm + +def inv_var(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes inverse variance features. + This feature refers to "Fcm_inv_var" (ID = E8JP) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the inverse variance features + """ + temp = [] + inv_var = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, df_pi, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + mu_marg = np.sum(df_pi.i * df_pi.pi) + var_marg = np.sum((df_pi.i - mu_marg) ** 2.0 * df_pi.pi) + if var_marg == 0.0: + temp.append(1.0) + else: + temp.append(1.0 / var_marg * (np.sum(df_pij.i * df_pij.j * df_pij.pij) - mu_marg ** 2.0)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, inv var: {sum(temp) / len(temp)}') + inv_var.append(sum(temp) / len(temp)) + return inv_var + +def corr(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes correlation features. + This feature refers to "Fcm_corr" (ID = NI2N) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the correlation features + """ + temp = [] + corr = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, df_pi, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + mu_marg = np.sum(df_pi.i * df_pi.pi) + var_marg = np.sum((df_pi.i - mu_marg) ** 2.0 * df_pi.pi) + if var_marg == 0.0: + temp.append(1.0) + else: + temp.append(1.0 / var_marg * (np.sum(df_pij.i * df_pij.j * df_pij.pij) - mu_marg ** 2.0)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, corr: {sum(temp) / len(temp)}') + corr.append(sum(temp) / len(temp)) + return corr + + +def auto_corr(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes autocorrelation features. + This feature refers to "Fcm_auto_corr" (ID = QWB0) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the autocorrelation features + """ + temp = [] + auto_corr = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + temp.append(np.sum(df_pij.i * df_pij.j * df_pij.pij)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, auto corr: {sum(temp) / len(temp)}') + auto_corr.append(sum(temp) / len(temp)) + return auto_corr + +def info_corr1(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes information correlation 1 features. + This feature refers to "Fcm_info_corr1" (ID = R8DG) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the information correlation 1 features + """ + temp = [] + info_corr1 = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, df_pi, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + hxy = -np.sum(df_pij.pij * np.log2(df_pij.pij)) + hxy_1 = -np.sum(df_pij.pij * np.log2(df_pij.pi * df_pij.pj)) + hx = -np.sum(df_pi.pi * np.log2(df_pi.pi)) + if len(df_pij) == 1 or hx == 0.0: + temp.append(1.0) + else: + temp.append((hxy - hxy_1) / hx) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, info corr 1: {sum(temp) / len(temp)}') + info_corr1.append(sum(temp) / len(temp)) + return info_corr1 + +def info_corr2(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes information correlation 2 features - Note: iteration over combinations of i and j + This feature refers to "Fcm_info_corr2" (ID = JN9H) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the information correlation 2 features + """ + temp = [] + info_corr2 = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, df_pi, df_pj, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + hxy = - np.sum(df_pij.pij * np.log2(df_pij.pij)) + hxy_2 = - np.sum( + np.tile(df_pi.pi, len(df_pj)) * np.repeat(df_pj.pj, len(df_pi)) * \ + np.log2(np.tile(df_pi.pi, len(df_pj)) * np.repeat(df_pj.pj, len(df_pi))) + ) + if hxy_2 < hxy: + temp.append(0) + else: + temp.append(np.sqrt(1 - np.exp(-2.0 * (hxy_2 - hxy)))) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, info corr 2: {sum(temp) / len(temp)}') + info_corr2.append(sum(temp) / len(temp)) + return info_corr2 + +def clust_tend(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes cluster tendency features. + This feature refers to "Fcm_clust_tend" (ID = DG8W) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the cluster tendency features + """ + temp = [] + clust_tend = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, df_pi, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + m_u = np.sum(df_pi.i * df_pi.pi) + temp.append(np.sum((df_pij.i + df_pij.j - 2 * m_u) ** 2.0 * df_pij.pij)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, clust tend: {sum(temp) / len(temp)}') + clust_tend.append(sum(temp) / len(temp)) + return clust_tend + +def clust_shade(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes cluster shade features. + This feature refers to "Fcm_clust_shade" (ID = 7NFM) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the cluster shade features + """ + temp = [] + clust_shade = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, df_pi, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + m_u = np.sum(df_pi.i * df_pi.pi) + temp.append(np.sum((df_pij.i + df_pij.j - 2 * m_u) ** 3.0 * df_pij.pij)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, clust shade: {sum(temp) / len(temp)}') + clust_shade.append(sum(temp) / len(temp)) + return clust_shade + +def clust_prom(glcm_dict: Dict) -> Union[float, List[float]]: + """Computes cluster prominence features. + This feature refers to "Fcm_clust_prom" (ID = AE86) in the `IBSI1 reference \ + manual `__. + + Args: + glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` + + Returns: + Union[float, List[float]]: the cluster prominence features + """ + temp = [] + clust_prom = [] + for key in glcm_dict.keys(): + for glcm in glcm_dict[key]: + df_pij, df_pi, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) + m_u = np.sum(df_pi.i * df_pi.pi) + temp.append(np.sum((df_pij.i + df_pij.j - 2 * m_u) ** 4.0 * df_pij.pij)) + if len(glcm_dict) <= 1: + return sum(temp) / len(temp) + else: + print(f'Merge method: {key}, clust prom: {sum(temp) / len(temp)}') + clust_prom.append(sum(temp) / len(temp)) + return clust_prom + +def extract_all(vol, dist_correction=None, merge_method="vol_merge") -> Dict: + """Computes glcm features. + This features refer to Glcm family in the `IBSI1 reference \ + manual `__. + + Args: + vol (ndarray): 3D volume, isotropically resampled, quantized + (e.g. n_g = 32, levels = [1, ..., n_g]), with NaNs outside the region + of interest. + dist_correction (Union[bool, str], optional): Set this variable to true in order to use + discretization length difference corrections as used by the `Institute of Physics and + Engineering in Medicine `__. + Set this variable to false to replicate IBSI results. + Or use string and specify the norm for distance weighting. Weighting is + only performed if this argument is "manhattan", "euclidean" or "chebyshev". + merge_method (str, optional): merging ``method`` which determines how features are + calculated. One of "average", "slice_merge", "dir_merge" and "vol_merge". + Note that not all combinations of spatial and merge ``method`` are valid. + method (str, optional): Either 'old' (deprecated) or 'new' (faster) ``method``. + + Returns: + Dict: Dict of the glcm features. + + Raises: + ValueError: If `method` is not 'old' or 'new'. + + Todo: + + - Enable calculation of CM features using different spatial methods (2d, 2.5d, 3d) + - Enable calculation of CM features using different CM distance settings + - Enable calculation of CM features for different merge methods ("average", "slice_merge", "dir_merge" and "vol_merge") + - Provide the range of discretised intensities from a calling function and pass to :func:`get_cm_features`. + - Test if dist_correction works as expected. + + """ + glcm = get_cm_features( + vol=vol, + intensity_range=[np.nan, np.nan], + merge_method=merge_method, + dist_weight_norm=dist_correction + ) + + return glcm + +def get_glcm_matrices(vol, + glcm_spatial_method="3d", + glcm_dist=1.0, + merge_method="vol_merge", + dist_weight_norm=None) -> Dict: + """Extracts co-occurrence matrices from the intensity roi mask prior to features extraction. + + Note: + This code was adapted from the in-house radiomics software created at + OncoRay, Dresden, Germany. + + Args: + vol (ndarray): volume with discretised intensities as 3D numpy array (x, y, z). + intensity_range (ndarray): range of potential discretised intensities,provided as a list: + [minimal discretised intensity, maximal discretised intensity]. + If one or both values are unknown, replace the respective values with np.nan. + glcm_spatial_method (str, optional): spatial method which determines the way + co-occurrence matrices are calculated and how features are determined. + Must be "2d", "2.5d" or "3d". + glcm_dist (float, optional): Chebyshev distance for comparison between neighboring voxels. + merge_method (str, optional): merging method which determines how features are + calculated. One of "average", "slice_merge", "dir_merge" and "vol_merge". + Note that not all combinations of spatial and merge method are valid. + dist_weight_norm (Union[bool, str], optional): norm for distance weighting. Weighting is only + performed if this argument is either "manhattan","euclidean", "chebyshev" or bool. + + Returns: + Dict: Dict of co-occurrence matrices. + + Raises: + ValueError: If `glcm_spatial_method` is not "2d", "2.5d" or "3d". + """ + if type(glcm_spatial_method) is not list: + glcm_spatial_method = [glcm_spatial_method] + + if type(glcm_dist) is not list: + glcm_dist = [glcm_dist] + + if type(merge_method) is not list: + merge_method = [merge_method] + + if type(dist_weight_norm) is bool: + if dist_weight_norm: + dist_weight_norm = "euclidean" + + # Get the roi in tabular format + img_dims = vol.shape + index_id = np.arange(start=0, stop=vol.size) + coords = np.unravel_index(indices=index_id, shape=img_dims) + df_img = pd.DataFrame({"index_id": index_id, + "g": np.ravel(vol), + "x": coords[0], + "y": coords[1], + "z": coords[2], + "roi_int_mask": np.ravel(np.isfinite(vol))}) + + # Iterate over spatial arrangements + for ii_spatial in glcm_spatial_method: + # Iterate over distances + for ii_dist in glcm_dist: + # Initiate list of glcm objects + glcm_list = [] + # Perform 2D analysis + if ii_spatial.lower() in ["2d", "2.5d"]: + # Iterate over slices + for ii_slice in np.arange(0, img_dims[2]): + # Get neighbour direction and iterate over neighbours + nbrs = get_neighbour_direction( + d=1, + distance="chebyshev", + centre=False, + complete=False, + dim3=False) * int(ii_dist) + for ii_direction in np.arange(0, np.shape(nbrs)[1]): + # Add glcm matrices to list + glcm_list += [CooccurrenceMatrix(distance=int(ii_dist), + direction=nbrs[:, ii_direction], + direction_id=ii_direction, + spatial_method=ii_spatial.lower(), + img_slice=ii_slice)] + + # Perform 3D analysis + elif ii_spatial.lower() == "3d": + # Get neighbour direction and iterate over neighbours + nbrs = get_neighbour_direction(d=1, + distance="chebyshev", + centre=False, + complete=False, + dim3=True) * int(ii_dist) + + for ii_direction in np.arange(0, np.shape(nbrs)[1]): + # Add glcm matrices to list + glcm_list += [CooccurrenceMatrix(distance=int(ii_dist), + direction=nbrs[:, ii_direction], + direction_id=ii_direction, + spatial_method=ii_spatial.lower())] + + else: + raise ValueError( + "GCLM matrices can be determined in \"2d\", \"2.5d\" and \"3d\". \ + The requested method (%s) is not implemented.", ii_spatial) + + # Calculate glcm matrices + for glcm in glcm_list: + glcm.calculate_cm_matrix( + df_img=df_img, img_dims=img_dims, dist_weight_norm=dist_weight_norm) + + # Merge matrices according to the given method + upd_list = {} + for merge_method in merge_method: + upd_list[merge_method] = combine_matrices( + glcm_list=glcm_list, merge_method=merge_method, spatial_method=ii_spatial.lower()) + + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is None: + continue + return upd_list + +def get_cm_features(vol, + intensity_range, + glcm_spatial_method="3d", + glcm_dist=1.0, + merge_method="vol_merge", + dist_weight_norm=None) -> Dict: + """Extracts co-occurrence matrix-based features from the intensity roi mask. + + Note: + This code was adapted from the in-house radiomics software created at + OncoRay, Dresden, Germany. + + Args: + vol (ndarray): volume with discretised intensities as 3D numpy array (x, y, z). + intensity_range (ndarray): range of potential discretised intensities, + provided as a list: [minimal discretised intensity, maximal discretised + intensity]. If one or both values are unknown, replace the respective values + with np.nan. + glcm_spatial_method (str, optional): spatial method which determines the way + co-occurrence matrices are calculated and how features are determined. + MUST BE "2d", "2.5d" or "3d". + glcm_dist (float, optional): chebyshev distance for comparison between neighbouring + voxels. + merge_method (str, optional): merging method which determines how features are + calculated. One of "average", "slice_merge", "dir_merge" and "vol_merge". + Note that not all combinations of spatial and merge method are valid. + dist_weight_norm (Union[bool, str], optional): norm for distance weighting. Weighting is only + performed if this argument is either "manhattan", + "euclidean", "chebyshev" or bool. + + Returns: + Dict: Dict of the glcm features. + + Raises: + ValueError: If `glcm_spatial_method` is not "2d", "2.5d" or "3d". + """ + if type(glcm_spatial_method) is not list: + glcm_spatial_method = [glcm_spatial_method] + + if type(glcm_dist) is not list: + glcm_dist = [glcm_dist] + + if type(merge_method) is not list: + merge_method = [merge_method] + + if type(dist_weight_norm) is bool: + if dist_weight_norm: + dist_weight_norm = "euclidean" + + # Get the roi in tabular format + img_dims = vol.shape + index_id = np.arange(start=0, stop=vol.size) + coords = np.unravel_index(indices=index_id, shape=img_dims) + df_img = pd.DataFrame({"index_id": index_id, + "g": np.ravel(vol), + "x": coords[0], + "y": coords[1], + "z": coords[2], + "roi_int_mask": np.ravel(np.isfinite(vol))}) + + # Generate an empty feature list + feat_list = [] + + # Iterate over spatial arrangements + for ii_spatial in glcm_spatial_method: + # Iterate over distances + for ii_dist in glcm_dist: + # Initiate list of glcm objects + glcm_list = [] + # Perform 2D analysis + if ii_spatial.lower() in ["2d", "2.5d"]: + # Iterate over slices + for ii_slice in np.arange(0, img_dims[2]): + # Get neighbour direction and iterate over neighbours + nbrs = get_neighbour_direction( + d=1, + distance="chebyshev", + centre=False, + complete=False, + dim3=False) * int(ii_dist) + for ii_direction in np.arange(0, np.shape(nbrs)[1]): + # Add glcm matrices to list + glcm_list += [CooccurrenceMatrix(distance=int(ii_dist), + direction=nbrs[:, ii_direction], + direction_id=ii_direction, + spatial_method=ii_spatial.lower(), + img_slice=ii_slice)] + + # Perform 3D analysis + elif ii_spatial.lower() == "3d": + # Get neighbour direction and iterate over neighbours + nbrs = get_neighbour_direction(d=1, + distance="chebyshev", + centre=False, + complete=False, + dim3=True) * int(ii_dist) + + for ii_direction in np.arange(0, np.shape(nbrs)[1]): + # Add glcm matrices to list + glcm_list += [CooccurrenceMatrix(distance=int(ii_dist), + direction=nbrs[:, ii_direction], + direction_id=ii_direction, + spatial_method=ii_spatial.lower())] + + else: + raise ValueError( + "GCLM matrices can be determined in \"2d\", \"2.5d\" and \"3d\". \ + The requested method (%s) is not implemented.", ii_spatial) + + # Calculate glcm matrices + for glcm in glcm_list: + glcm.calculate_cm_matrix( + df_img=df_img, img_dims=img_dims, dist_weight_norm=dist_weight_norm) + + # Merge matrices according to the given method + for merge_method in merge_method: + upd_list = combine_matrices( + glcm_list=glcm_list, merge_method=merge_method, spatial_method=ii_spatial.lower()) + + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is None: + continue + + # Calculate features + feat_run_list = [] + for glcm in upd_list: + feat_run_list += [glcm.calculate_cm_features( + intensity_range=intensity_range)] + + # Average feature values + feat_list += [pd.concat(feat_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single table and return as a dictionary + df_feat = pd.concat(feat_list, axis=1).to_dict(orient="records")[0] + + return df_feat + +def combine_matrices(glcm_list: List, merge_method: str, spatial_method: str) -> List: + """Merges co-occurrence matrices prior to feature calculation. + + Note: + This code was adapted from the in-house radiomics software created at + OncoRay, Dresden, Germany. + + Args: + glcm_list (List): List of CooccurrenceMatrix objects. + merge_method (str): Merging method which determines how features are calculated. + One of "average", "slice_merge", "dir_merge" and "vol_merge". Note that not all + combinations of spatial and merge method are valid. + spatial_method (str): spatial method which determines the way co-occurrence + matrices are calculated and how features are determined. One of "2d", "2.5d" + or "3d". + + Returns: + List[CooccurrenceMatrix]: list of one or more merged CooccurrenceMatrix objects. + """ + # Initiate empty list + use_list = [] + + # For average features over direction, maintain original glcms + if merge_method == "average" and spatial_method in ["2d", "3d"]: + # Make copy of glcm_list + for glcm in glcm_list: + use_list += [glcm._copy()] + + # Set merge method to average + for glcm in use_list: + glcm.merge_method = "average" + + # Merge glcms by slice + elif merge_method == "slice_merge" and spatial_method == "2d": + # Find slice_ids + slice_id = [] + for glcm in glcm_list: + slice_id += [glcm.slice] + + # Iterate over unique slice_ids + for ii_slice in np.unique(slice_id): + slice_glcm_id = np.squeeze(np.where(slice_id == ii_slice)) + + # Select all matrices within the slice + sel_matrix_list = [] + for glcm_id in slice_glcm_id: + sel_matrix_list += [glcm_list[glcm_id].matrix] + + # Check if any matrix has been created for the currently selected slice + if is_list_all_none(sel_matrix_list): + # No matrix was created + use_list += [CooccurrenceMatrix(distance=glcm_list[slice_glcm_id[0]].distance, + direction=None, + direction_id=None, + spatial_method=spatial_method, + img_slice=ii_slice, + merge_method=merge_method, + matrix=None, + n_v=0.0)] + else: + # Merge matrices within the slice + merge_cm = pd.concat(sel_matrix_list, axis=0) + merge_cm = merge_cm.groupby(by=["i", "j"]).sum().reset_index() + + # Update the number of voxels within the merged slice + merge_n_v = 0.0 + for glcm_id in slice_glcm_id: + merge_n_v += glcm_list[glcm_id].n_v + + # Create new cooccurrence matrix + use_list += [CooccurrenceMatrix(distance=glcm_list[slice_glcm_id[0]].distance, + direction=None, + direction_id=None, + spatial_method=spatial_method, + img_slice=ii_slice, + merge_method=merge_method, + matrix=merge_cm, + n_v=merge_n_v)] + + # Merge glcms by direction + elif merge_method == "dir_merge" and spatial_method == "2.5d": + # Find slice_ids + dir_id = [] + for glcm in glcm_list: + dir_id += [glcm.direction_id] + + # Iterate over unique directions + for ii_dir in np.unique(dir_id): + dir_glcm_id = np.squeeze(np.where(dir_id == ii_dir)) + + # Select all matrices with the same direction + sel_matrix_list = [] + for glcm_id in dir_glcm_id: + sel_matrix_list += [glcm_list[glcm_id].matrix] + + # Check if any matrix has been created for the currently selected direction + if is_list_all_none(sel_matrix_list): + # No matrix was created + use_list += [CooccurrenceMatrix(distance=glcm_list[dir_glcm_id[0]].distance, + direction=glcm_list[dir_glcm_id[0]].direction, + direction_id=ii_dir, + spatial_method=spatial_method, + img_slice=None, + merge_method=merge_method, + matrix=None, n_v=0.0)] + else: + # Merge matrices with the same direction + merge_cm = pd.concat(sel_matrix_list, axis=0) + merge_cm = merge_cm.groupby(by=["i", "j"]).sum().reset_index() + + # Update the number of voxels for the merged matrices with the same direction + merge_n_v = 0.0 + for glcm_id in dir_glcm_id: + merge_n_v += glcm_list[glcm_id].n_v + + # Create new co-occurrence matrix + use_list += [CooccurrenceMatrix(distance=glcm_list[dir_glcm_id[0]].distance, + direction=glcm_list[dir_glcm_id[0]].direction, + direction_id=ii_dir, + spatial_method=spatial_method, + img_slice=None, + merge_method=merge_method, + matrix=merge_cm, + n_v=merge_n_v)] + + # Merge all glcms into a single representation + elif merge_method == "vol_merge" and spatial_method in ["2.5d", "3d"]: + # Select all matrices within the slice + sel_matrix_list = [] + for glcm_id in np.arange(len(glcm_list)): + sel_matrix_list += [glcm_list[glcm_id].matrix] + + # Check if any matrix was created + if is_list_all_none(sel_matrix_list): + # In case no matrix was created + use_list += [CooccurrenceMatrix(distance=glcm_list[0].distance, + direction=None, + direction_id=None, + spatial_method=spatial_method, + img_slice=None, + merge_method=merge_method, + matrix=None, + n_v=0.0)] + else: + # Merge co-occurrence matrices + merge_cm = pd.concat(sel_matrix_list, axis=0) + merge_cm = merge_cm.groupby(by=["i", "j"]).sum().reset_index() + + # Update the number of voxels + merge_n_v = 0.0 + for glcm_id in np.arange(len(glcm_list)): + merge_n_v += glcm_list[glcm_id].n_v + + # Create new co-occurrence matrix + use_list += [CooccurrenceMatrix(distance=glcm_list[0].distance, + direction=None, + direction_id=None, + spatial_method=spatial_method, + img_slice=None, + merge_method=merge_method, + matrix=merge_cm, + n_v=merge_n_v)] + else: + use_list = None + + return use_list + + +class CooccurrenceMatrix: + """ Class that contains a single co-occurrence ``matrix``. + + Note: + Code was adapted from the in-house radiomics software created at + OncoRay, Dresden, Germany. + + Attributes: + distance (int): Chebyshev ``distance``. + direction (ndarray): Direction along which neighbouring voxels are found. + direction_id (int): Direction index to identify unique ``direction`` vectors. + spatial_method (str): Spatial method used to calculate the co-occurrence + ``matrix``: "2d", "2.5d" or "3d". + img_slice (ndarray): Corresponding slice index (only if the co-occurrence + ``matrix`` corresponds to a 2d image slice). + merge_method (str): Method for merging the co-occurrence ``matrix`` with other + co-occurrence matrices. + matrix (pandas.DataFrame): The actual co-occurrence ``matrix`` in sparse format + (row, column, count). + n_v (int): The number of voxels in the volume. + """ + + def __init__(self, + distance: int, + direction: np.ndarray, + direction_id: int, + spatial_method: str, + img_slice: np.ndarray=None, + merge_method: str=None, + matrix: pd.DataFrame=None, + n_v: int=None) -> None: + """Constructor of the CooccurrenceMatrix class + + Args: + distance (int): Chebyshev ``distance``. + direction (ndarray): Direction along which neighbouring voxels are found. + direction_id (int): Direction index to identify unique ``direction`` vectors. + spatial_method (str): Spatial method used to calculate the co-occurrence + ``matrix``: "2d", "2.5d" or "3d". + img_slice (ndarray, optional): Corresponding slice index (only if the + co-occurrence ``matrix`` corresponds to a 2d image slice). + merge_method (str, optional): Method for merging the co-occurrence ``matrix`` + with other co-occurrence matrices. + matrix (pandas.DataFrame, optional): The actual co-occurrence ``matrix`` in + sparse format (row, column, count). + n_v (int, optional): The number of voxels in the volume. + + Returns: + None. + """ + # Distance used + self.distance = distance + + # Direction and slice for which the current matrix is extracted + self.direction = direction + self.direction_id = direction_id + self.img_slice = img_slice + + # Spatial analysis method (2d, 2.5d, 3d) and merge method (average, slice_merge, dir_merge, vol_merge) + self.spatial_method = spatial_method + self.merge_method = merge_method + + # Place holders + self.matrix = matrix + self.n_v = n_v + + def _copy(self): + """ + Returns a copy of the co-occurrence matrix object. + """ + return deepcopy(self) + + def calculate_cm_matrix(self, df_img: pd.DataFrame, img_dims: np.ndarray, dist_weight_norm: str) -> None: + """Function that calculates a co-occurrence matrix for the settings provided during + initialisation and the input image. + + Args: + df_img (pandas.DataFrame): Data table containing image intensities, x, y and z coordinates, + and mask labels corresponding to voxels in the volume. + img_dims (ndarray, List[float]): Dimensions of the image volume. + dist_weight_norm (str): Norm for distance weighting. Weighting is only + performed if this parameter is either "manhattan", "euclidean" or "chebyshev". + + Returns: + None. Assigns the created image table (cm matrix) to the `matrix` attribute. + + Raises: + ValueError: + If `self.spatial_method` is not "2d", "2.5d" or "3d". + Also, if ``dist_weight_norm`` is not "manhattan", "euclidean" or "chebyshev". + + """ + # Check if the roi contains any masked voxels. If this is not the case, don't construct the glcm. + if not np.any(df_img.roi_int_mask): + self.n_v = 0 + self.matrix = None + + return None + + # Create local copies of the image table + if self.spatial_method == "3d": + df_cm = deepcopy(df_img) + elif self.spatial_method in ["2d", "2.5d"]: + df_cm = deepcopy(df_img[df_img.z == self.img_slice]) + df_cm["index_id"] = np.arange(0, len(df_cm)) + df_cm["z"] = 0 + df_cm = df_cm.reset_index(drop=True) + else: + raise ValueError( + "The spatial method for grey level co-occurrence matrices should be one of \"2d\", \"2.5d\" or \"3d\".") + + # Set grey level of voxels outside ROI to NaN + df_cm.loc[df_cm.roi_int_mask == False, "g"] = np.nan + + # Determine potential transitions + df_cm["to_index"] = coord2index(x=df_cm.x.values + self.direction[0], + y=df_cm.y.values + self.direction[1], + z=df_cm.z.values + self.direction[2], + dims=img_dims) + + # Get grey levels from transitions + df_cm["to_g"] = get_value(x=df_cm.g.values, index=df_cm.to_index.values) + + # Check if any transitions exist. + if np.all(np.isnan(df_cm[["to_g"]])): + self.n_v = 0 + self.matrix = None + + return None + + # Count occurrences of grey level transitions + df_cm = df_cm.groupby(by=["g", "to_g"]).size().reset_index(name="n") + + # Append grey level transitions in opposite direction + df_cm_inv = pd.DataFrame({"g": df_cm.to_g, "to_g": df_cm.g, "n": df_cm.n}) + df_cm = df_cm.append(df_cm_inv, ignore_index=True) + + # Sum occurrences of grey level transitions + df_cm = df_cm.groupby(by=["g", "to_g"]).sum().reset_index() + + # Rename columns + df_cm.columns = ["i", "j", "n"] + + if dist_weight_norm in ["manhattan", "euclidean", "chebyshev"]: + if dist_weight_norm == "manhattan": + weight = sum(abs(self.direction)) + elif dist_weight_norm == "euclidean": + weight = np.sqrt(sum(np.power(self.direction, 2.0))) + elif dist_weight_norm == "chebyshev": + weight = np.max(abs(self.direction)) + df_cm.n /= weight + + # Set the number of voxels + self.n_v = np.sum(df_cm.n) + + # Add matrix and number of voxels to object + self.matrix = df_cm + + def get_cm_data(self, intensity_range: np.ndarray): + """Computes the probability distribution for the elements of the GLCM + (diagonal probability, cross-diagonal probability...) and number of gray-levels. + + Args: + intensity_range (ndarray): Range of potential discretised intensities, provided as a list: + [minimal discretised intensity, maximal discretised intensity]. + If one or both values are unknown,replace the respective values with np.nan. + + Returns: + Typle[pd.DataFrame, pd.DataFrame, pd.DataFrame, float]: + - Occurence data frame + - Diagonal probabilty + - Cross-diagonal probabilty + - Number of gray levels + """ + # Occurrence data frames + df_pij = deepcopy(self.matrix) + df_pij["pij"] = df_pij.n / sum(df_pij.n) + df_pi = df_pij.groupby(by="i")["pij"].agg(np.sum).reset_index().rename(columns={"pij": "pi"}) + df_pj = df_pij.groupby(by="j")["pij"].agg(np.sum).reset_index().rename(columns={"pij": "pj"}) + + # Diagonal probilities p(i-j) + df_pimj = deepcopy(df_pij) + df_pimj["k"] = np.abs(df_pimj.i - df_pimj.j) + df_pimj = df_pimj.groupby(by="k")["pij"].agg(np.sum).reset_index().rename(columns={"pij": "pimj"}) + + # Cross-diagonal probabilities p(i+j) + df_pipj = deepcopy(df_pij) + df_pipj["k"] = df_pipj.i + df_pipj.j + df_pipj = df_pipj.groupby(by="k")["pij"].agg(np.sum).reset_index().rename(columns={"pij": "pipj"}) + + # Merger of df.p_ij, df.p_i and df.p_j + df_pij = pd.merge(df_pij, df_pi, on="i") + df_pij = pd.merge(df_pij, df_pj, on="j") + + # Constant definitions + intensity_range_loc = deepcopy(intensity_range) + if np.isnan(intensity_range[0]): + intensity_range_loc[0] = np.min(df_pi.i) * 1.0 + if np.isnan(intensity_range[1]): + intensity_range_loc[1] = np.max(df_pi.i) * 1.0 + # Number of grey levels + n_g = intensity_range_loc[1] - intensity_range_loc[0] + 1.0 + + return df_pij, df_pi, df_pj, df_pimj, df_pipj, n_g + + def calculate_cm_features(self, intensity_range: np.ndarray) -> pd.DataFrame: + """Wrapper to json.dump function. + + Args: + intensity_range (np.ndarray): Range of potential discretised intensities, + provided as a list: [minimal discretised intensity, maximal discretised intensity]. + If one or both values are unknown,replace the respective values with np.nan. + + Returns: + pandas.DataFrame: Data frame with values for each feature. + """ + # Create feature table + feat_names = ["Fcm_joint_max", "Fcm_joint_avg", "Fcm_joint_var", "Fcm_joint_entr", + "Fcm_diff_avg", "Fcm_diff_var", "Fcm_diff_entr", + "Fcm_sum_avg", "Fcm_sum_var", "Fcm_sum_entr", + "Fcm_energy", "Fcm_contrast", "Fcm_dissimilarity", + "Fcm_inv_diff", "Fcm_inv_diff_norm", "Fcm_inv_diff_mom", + "Fcm_inv_diff_mom_norm", "Fcm_inv_var", "Fcm_corr", + "Fcm_auto_corr", "Fcm_clust_tend", "Fcm_clust_shade", + "Fcm_clust_prom", "Fcm_info_corr1", "Fcm_info_corr2"] + + df_feat = pd.DataFrame(np.full(shape=(1, len(feat_names)), fill_value=np.nan)) + df_feat.columns = feat_names + + # Don't return data for empty slices or slices without a good matrix + if self.matrix is None: + # Update names + #df_feat.columns += self._parse_names() + return df_feat + elif len(self.matrix) == 0: + # Update names + #df_feat.columns += self._parse_names() + return df_feat + + df_pij, df_pi, df_pj, df_pimj, df_pipj, n_g = self.get_cm_data(intensity_range) + + ############################################### + ###### glcm features ###### + ############################################### + # Joint maximum + df_feat.loc[0, "Fcm_joint_max"] = np.max(df_pij.pij) + + # Joint average + df_feat.loc[0, "Fcm_joint_avg"] = np.sum(df_pij.i * df_pij.pij) + + # Joint variance + m_u = np.sum(df_pij.i * df_pij.pij) + df_feat.loc[0, "Fcm_joint_var"] = np.sum((df_pij.i - m_u) ** 2.0 * df_pij.pij) + + # Joint entropy + df_feat.loc[0, "Fcm_joint_entr"] = -np.sum(df_pij.pij * np.log2(df_pij.pij)) + + # Difference average + df_feat.loc[0, "Fcm_diff_avg"] = np.sum(df_pimj.k * df_pimj.pimj) + + # Difference variance + m_u = np.sum(df_pimj.k * df_pimj.pimj) + df_feat.loc[0, "Fcm_diff_var"] = np.sum((df_pimj.k - m_u) ** 2.0 * df_pimj.pimj) + + # Difference entropy + df_feat.loc[0, "Fcm_diff_entr"] = -np.sum(df_pimj.pimj * np.log2(df_pimj.pimj)) + + # Sum average + df_feat.loc[0, "Fcm_sum_avg"] = np.sum(df_pipj.k * df_pipj.pipj) + + # Sum variance + m_u = np.sum(df_pipj.k * df_pipj.pipj) + df_feat.loc[0, "Fcm_sum_var"] = np.sum((df_pipj.k - m_u) ** 2.0 * df_pipj.pipj) + + # Sum entropy + df_feat.loc[0, "Fcm_sum_entr"] = -np.sum(df_pipj.pipj * np.log2(df_pipj.pipj)) + + # Angular second moment + df_feat.loc[0, "Fcm_energy"] = np.sum(df_pij.pij ** 2.0) + + # Contrast + df_feat.loc[0, "Fcm_contrast"] = np.sum((df_pij.i - df_pij.j) ** 2.0 * df_pij.pij) + + # Dissimilarity + df_feat.loc[0, "Fcm_dissimilarity"] = np.sum(np.abs(df_pij.i - df_pij.j) * df_pij.pij) + + # Inverse difference + df_feat.loc[0, "Fcm_inv_diff"] = np.sum(df_pij.pij / (1.0 + np.abs(df_pij.i - df_pij.j))) + + # Inverse difference normalised + df_feat.loc[0, "Fcm_inv_diff_norm"] = np.sum(df_pij.pij / (1.0 + np.abs(df_pij.i - df_pij.j) / n_g)) + + # Inverse difference moment + df_feat.loc[0, "Fcm_inv_diff_mom"] = np.sum(df_pij.pij / (1.0 + (df_pij.i - df_pij.j) ** 2.0)) + + # Inverse difference moment normalised + df_feat.loc[0, "Fcm_inv_diff_mom_norm"] = np.sum(df_pij.pij / (1.0 + (df_pij.i - df_pij.j) + ** 2.0 / n_g ** 2.0)) + + # Inverse variance + df_sel = df_pij[df_pij.i != df_pij.j] + df_feat.loc[0, "Fcm_inv_var"] = np.sum(df_sel.pij / (df_sel.i - df_sel.j) ** 2.0) + del df_sel + + # Correlation + mu_marg = np.sum(df_pi.i * df_pi.pi) + var_marg = np.sum((df_pi.i - mu_marg) ** 2.0 * df_pi.pi) + + if var_marg == 0.0: + df_feat.loc[0, "Fcm_corr"] = 1.0 + else: + df_feat.loc[0, "Fcm_corr"] = 1.0 / var_marg * (np.sum(df_pij.i * df_pij.j * df_pij.pij) - mu_marg ** 2.0) + + del mu_marg, var_marg + + # Autocorrelation + df_feat.loc[0, "Fcm_auto_corr"] = np.sum(df_pij.i * df_pij.j * df_pij.pij) + + # Information correlation 1 + hxy = -np.sum(df_pij.pij * np.log2(df_pij.pij)) + hxy_1 = -np.sum(df_pij.pij * np.log2(df_pij.pi * df_pij.pj)) + hx = -np.sum(df_pi.pi * np.log2(df_pi.pi)) + if len(df_pij) == 1 or hx == 0.0: + df_feat.loc[0, "Fcm_info_corr1"] = 1.0 + else: + df_feat.loc[0, "Fcm_info_corr1"] = (hxy - hxy_1) / hx + del hxy, hxy_1, hx + + # Information correlation 2 - Note: iteration over combinations of i and j + hxy = - np.sum(df_pij.pij * np.log2(df_pij.pij)) + hxy_2 = - np.sum( + np.tile(df_pi.pi, len(df_pj)) * np.repeat(df_pj.pj, len(df_pi)) * \ + np.log2(np.tile(df_pi.pi, len(df_pj)) * np.repeat(df_pj.pj, len(df_pi))) + ) + + if hxy_2 < hxy: + df_feat.loc[0, "Fcm_info_corr2"] = 0 + else: + df_feat.loc[0, "Fcm_info_corr2"] = np.sqrt(1 - np.exp(-2.0 * (hxy_2 - hxy))) + del hxy, hxy_2 + + # Cluster tendency + m_u = np.sum(df_pi.i * df_pi.pi) + df_feat.loc[0, "Fcm_clust_tend"] = np.sum((df_pij.i + df_pij.j - 2 * m_u) ** 2.0 * df_pij.pij) + del m_u + + # Cluster shade + m_u = np.sum(df_pi.i * df_pi.pi) + df_feat.loc[0, "Fcm_clust_shade"] = np.sum((df_pij.i + df_pij.j - 2 * m_u) ** 3.0 * df_pij.pij) + del m_u + + # Cluster prominence + m_u = np.sum(df_pi.i * df_pi.pi) + df_feat.loc[0, "Fcm_clust_prom"] = np.sum((df_pij.i + df_pij.j - 2 * m_u) ** 4.0 * df_pij.pij) + + del df_pi, df_pj, df_pij, df_pimj, df_pipj, n_g + + # Update names + # df_feat.columns += self._parse_names() + + return df_feat + + def _parse_names(self) -> str: + """"Adds additional settings-related identifiers to each feature. + Not used currently, as the use of different settings for the + co-occurrence matrix is not supported. + + Returns: + str: String of the features indetifier. + """ + parse_str = "" + + # Add distance + parse_str += "_d" + str(np.round(self.distance, 1)) + + # Add spatial method + if self.spatial_method is not None: + parse_str += "_" + self.spatial_method + + # Add merge method + if self.merge_method is not None: + if self.merge_method == "average": + parse_str += "_avg" + if self.merge_method == "slice_merge": + parse_str += "_s_mrg" + if self.merge_method == "dir_merge": + parse_str += "_d_mrg" + if self.merge_method == "vol_merge": + parse_str += "_v_mrg" + + return parse_str diff --git a/MEDiml/biomarkers/gldzm.py b/MEDiml/biomarkers/gldzm.py new file mode 100644 index 0000000..0277ecb --- /dev/null +++ b/MEDiml/biomarkers/gldzm.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from typing import Dict, List, Union + +import numpy as np +import scipy.ndimage as sc +import skimage.measure as skim + + +def get_matrix(roi_only_int: np.ndarray, + mask: np.ndarray, + levels: Union[np.ndarray, List]) -> np.ndarray: + r""" + Computes Grey level distance zone matrix. + This matrix refers to "Grey level distance zone based features" (ID = VMDZ) + in the `IBSI1 reference manual `_. + + Args: + roi_only_int (ndarray): 3D volume, isotropically resampled, + quantized (e.g. n_g = 32, levels = [1, ..., n_g]), + with NaNs outside the region of interest. + mask (ndarray): Morphological ROI ``mask``. + levels (ndarray or List): Vector containing the quantized gray-levels + in the tumor region (or reconstruction ``levels`` of quantization). + + Returns: + ndarray: Grey level distance zone Matrix. + + Todo: + ``levels`` should be removed at some point, no longer needed if we always + quantize our volume such that ``levels = 1,2,3,4,...,max(quantized Volume)``. + So simply calculate ``levels = 1:max(roi_only(~isnan(roi_only(:))))`` + directly in this function. + + """ + + roi_only_int = roi_only_int.copy() + levels = levels.copy().astype("int") + morph_voxel_grid = mask.copy().astype(np.uint8) + + # COMPUTATION OF DISTANCE MAP + morph_voxel_grid = np.pad(morph_voxel_grid, + [1,1], + 'constant', + constant_values=0) + + # Computing the smallest ROI edge possible. + # Distances are determined in 3D + binary_struct = sc.generate_binary_structure(rank=3, connectivity=1) + perimeter = morph_voxel_grid - sc.binary_erosion(morph_voxel_grid, structure=binary_struct) + perimeter = perimeter[1:-1,1:-1,1:-1] # Removing the padding. + morph_voxel_grid = morph_voxel_grid[1:-1,1:-1,1:-1] # Removing the padding + + # +1 according to the definition of the IBSI + dist_map = sc.distance_transform_cdt(np.logical_not(perimeter), metric='cityblock') + 1 + + # INITIALIZATION + # Since levels is always defined as 1,2,3,4,...,max(quantized Volume) + n_g = np.size(levels) + level_temp = np.max(levels) + 1 + roi_only_int[np.isnan(roi_only_int)] = level_temp + # Since the ROI morph always encompasses ROI int, + # using the mask as defined from ROI morph does not matter since + # we want to find the maximal possible distance. + dist_init = np.max(dist_map[morph_voxel_grid == 1]) + gldzm = np.zeros((n_g,dist_init)) + + # COMPUTATION OF gldzm + temp = roi_only_int.copy().astype('int') + for i in range(1,n_g+1): + temp[roi_only_int!=levels[i-1]] = 0 + temp[roi_only_int==levels[i-1]] = 1 + conn_objects, n_zone = skim.label(temp,return_num = True) + for j in range(1,n_zone+1): + col = np.min(dist_map[conn_objects==j]).astype("int") + gldzm[i-1,col-1] = gldzm[i-1,col-1] + 1 + + # REMOVE UNECESSARY COLUMNS + stop = np.nonzero(np.sum(gldzm,0))[0][-1] + gldzm = np.delete(gldzm, range(stop+1, np.shape(gldzm)[1]), 1) + + return gldzm + +def extract_all(vol_int: np.ndarray, + mask_morph: np.ndarray, + gldzm: np.ndarray = None) -> Dict: + """Computes gldzm features. + This feature refers to "Grey level distance zone based features" (ID = VMDZ) + in the `IBSI1 reference manual `__. + + Args: + vol_int (np.ndarray): 3D volume, isotropically resampled, quantized (e.g. n_g = 32, levels = [1, ..., n_g]), + with NaNs outside the region of interest. + mask_morph (np.ndarray): Morphological ROI mask. + gldzm (np.ndarray, optional): array of the gray level distance zone matrix. Defaults to None. + + Returns: + Dict: dict of ``gldzm`` features + """ + gldzm_features = {'Fdzm_sde': [], + 'Fdzm_lde': [], + 'Fdzm_lgze': [], + 'Fdzm_hgze': [], + 'Fdzm_sdlge': [], + 'Fdzm_sdhge': [], + 'Fdzm_ldlge': [], + 'Fdzm_ldhge': [], + 'Fdzm_glnu': [], + 'Fdzm_glnu_norm': [], + 'Fdzm_zdnu': [], + 'Fdzm_zdnu_norm': [], + 'Fdzm_z_perc': [], + 'Fdzm_gl_var': [], + 'Fdzm_zd_var': [], + 'Fdzm_zd_entr': []} + + # Correct definition, without any assumption + levels = np.arange(1, np.max(vol_int[~np.isnan(vol_int[:])])+1) + + # GET THE gldzm MATRIX + if gldzm is None: + gldzm = get_matrix(vol_int, mask_morph, levels) + n_s = np.sum(gldzm) + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + s_z = np.shape(gldzm) # Size of gldzm + c_vect = range(1, s_z[1]+1) # Row vectors + r_vect = range(1, s_z[0]+1) # Column vectors + # Column and row indicators for each entry of the gldzm + c_mat, r_mat = np.meshgrid(c_vect, r_vect) + p_g = np.transpose(np.sum(gldzm, 1)) # Gray-Level Vector + p_d = np.sum(gldzm, 0) # Distance Zone Vector + + # COMPUTING TEXTURES + + # Small distance emphasis + gldzm_features['Fdzm_sde'] = (np.matmul(p_d, np.transpose(np.power(1.0/np.array(c_vect), 2)))) + + # Large distance emphasis + gldzm_features['Fdzm_lde'] = (np.matmul(p_d, np.transpose(np.power(np.array(c_vect), 2)))) + + # Low grey level zone emphasis + gldzm_features['Fdzm_lgze'] = np.matmul(p_g, np.transpose(np.power(1.0/np.array(r_vect), 2))) + + # High grey level zone emphasis + gldzm_features['Fdzm_hgze'] = np.matmul(p_g, np.transpose(np.power(np.array(r_vect), 2))) + + # Small distance low grey level emphasis + gldzm_features['Fdzm_sdlge'] = np.sum(np.sum(gldzm*(np.power(1.0/r_mat, 2))*(np.power(1.0/c_mat, 2)))) + + # Small distance high grey level emphasis + gldzm_features['Fdzm_sdhge'] = np.sum(np.sum(gldzm*(np.power(r_mat, 2))*(np.power(1.0/c_mat, 2)))) + + # Large distance low grey level emphasis + gldzm_features['Fdzm_ldlge'] = np.sum(np.sum(gldzm*(np.power(1.0/r_mat, 2))*(np.power(c_mat, 2)))) + + # Large distance high grey level emphasis + gldzm_features['Fdzm_ldhge'] = np.sum(np.sum(gldzm*(np.power(r_mat, 2))*(np.power(c_mat, 2)))) + + # Gray level non-uniformity + gldzm_features['Fdzm_glnu'] = np.sum(np.power(p_g, 2)) * n_s + + # Gray level non-uniformity normalised + gldzm_features['Fdzm_glnu_norm'] = np.sum(np.power(p_g, 2)) + + # Zone distance non-uniformity + gldzm_features['Fdzm_zdnu'] = np.sum(np.power(p_d, 2)) * n_s + + # Zone distance non-uniformity normalised + gldzm_features['Fdzm_zdnu_norm'] = np.sum(np.power(p_d, 2)) + + # Zone percentage + # Must change the original definition here. + gldzm_features['Fdzm_z_perc'] = n_s / np.sum(~np.isnan(vol_int[:])) + + # Grey level variance + temp = r_mat * gldzm + u = np.sum(temp) + temp = (np.power(r_mat-u, 2)) * gldzm + gldzm_features['Fdzm_gl_var'] = np.sum(temp) + + # Zone distance variance + temp = c_mat * gldzm + u = np.sum(temp) + temp = (np.power(c_mat-u, 2)) * gldzm + temp = (np.power(c_mat - u, 2)) * gldzm + gldzm_features['Fdzm_zd_var'] = np.sum(temp) + + # Zone distance entropy + val_pos = gldzm[np.nonzero(gldzm)] + temp = val_pos * np.log2(val_pos) + gldzm_features['Fdzm_zd_entr'] = -np.sum(temp) + + return gldzm_features + +def get_single_matrix(vol_int: np.ndarray, mask_morph: np.ndarray) -> np.ndarray: + """Computes gray level distance zone matrix in order to compute the single features. + + Args: + vol_int (ndarray): 3D volume, isotropically resampled, + quantized (e.g. n_g = 32, levels = [1, ..., n_g]), + with NaNs outside the region of interest. + mask_morph (ndarray): Morphological ROI mask. + + Returns: + ndarray: gldzm features. + """ + # Correct definition, without any assumption + levels = np.arange(1, np.max(vol_int[~np.isnan(vol_int[:])])+1) + + # GET THE gldzm MATRIX + gldzm = get_matrix(vol_int, mask_morph, levels) + + return gldzm + +def sde(gldzm: np.ndarray) -> float: + """Computes small distance emphasis feature. + This feature refers to "Fdzm_sde" (ID = 0GBI) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the small distance emphasis feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + s_z = np.shape(gldzm) # Size of gldzm + c_vect = range(1, s_z[1]+1) # Row vectors + p_d = np.sum(gldzm, 0) # Distance Zone Vector + + # Small distance emphasis + return (np.matmul(p_d, np.transpose(np.power(1.0 / np.array(c_vect), 2)))) + +def lde(gldzm: np.ndarray) -> float: + """Computes large distance emphasis feature. + This feature refers to "Fdzm_lde" (ID = MB4I) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the large distance emphasis feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + s_z = np.shape(gldzm) # Size of gldzm + c_vect = range(1, s_z[1]+1) # Row vectors + p_d = np.sum(gldzm, 0) # Distance Zone Vector + + #Large distance emphasis + return (np.matmul(p_d, np.transpose(np.power(np.array(c_vect), 2)))) + +def lgze(gldzm: np.ndarray) -> float: + """Computes distance matrix low grey level zone emphasis feature. + This feature refers to "Fdzm_lgze" (ID = S1RA) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the low grey level zone emphasis feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + s_z = np.shape(gldzm) # Size of gldzm + r_vect = range(1, s_z[0]+1) # Column vectors + p_g = np.transpose(np.sum(gldzm, 1)) # Gray-Level Vector + + #Low grey level zone emphasisphasis + return np.matmul(p_g, np.transpose(np.power(1.0/np.array(r_vect), 2))) + +def hgze(gldzm: np.ndarray) -> float: + """Computes distance matrix high grey level zone emphasis feature. + This feature refers to "Fdzm_hgze" (ID = K26C) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the high grey level zone emphasis feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + s_z = np.shape(gldzm) # Size of gldzm + r_vect = range(1, s_z[0]+1) # Column vectors + p_g = np.transpose(np.sum(gldzm, 1)) # Gray-Level Vector + + #Low grey level zone emphasisphasis + return np.matmul(p_g, np.transpose(np.power(np.array(r_vect), 2))) + +def sdlge(gldzm: np.ndarray) -> float: + """Computes small distance low grey level emphasis feature. + This feature refers to "Fdzm_sdlge" (ID = RUVG) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the low grey level emphasis feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + s_z = np.shape(gldzm) # Size of gldzm + c_vect = range(1, s_z[1]+1) # Row vectors + r_vect = range(1, s_z[0]+1) # Column vectors + c_mat, r_mat = np.meshgrid(c_vect, r_vect) # Column and row indicators for each entry of the gldzm + + #Low grey level zone emphasisphasis + return np.sum(np.sum(gldzm*(np.power(1.0/r_mat, 2))*(np.power(1.0/c_mat, 2)))) + +def sdhge(gldzm: np.ndarray) -> float: + """Computes small distance high grey level emphasis feature. + This feature refers to "Fdzm_sdhge" (ID = DKNJ) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the distance high grey level emphasis feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + s_z = np.shape(gldzm) # Size of gldzm + c_vect = range(1, s_z[1]+1) # Row vectors + r_vect = range(1, s_z[0]+1) # Column vectors + c_mat, r_mat = np.meshgrid(c_vect, r_vect) # Column and row indicators for each entry of the gldzm + + #High grey level zone emphasisphasis + return np.sum(np.sum(gldzm*(np.power(r_mat, 2))*(np.power(1.0/c_mat, 2)))) + +def ldlge(gldzm: np.ndarray) -> float: + """Computes large distance low grey level emphasis feature. + This feature refers to "Fdzm_ldlge" (ID = A7WM) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the low grey level emphasis feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + s_z = np.shape(gldzm) # Size of gldzm + c_vect = range(1, s_z[1]+1) # Row vectors + r_vect = range(1, s_z[0]+1) # Column vectors + c_mat, r_mat = np.meshgrid(c_vect, r_vect) # Column and row indicators for each entry of the gldzm + + #Large distance low grey levels emphasis + return np.sum(np.sum(gldzm*(np.power(1.0/r_mat, 2))*(np.power(c_mat, 2)))) + +def ldhge(gldzm: np.ndarray) -> float: + """Computes large distance high grey level emphasis feature. + This feature refers to "Fdzm_ldhge" (ID = KLTH) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the high grey level emphasis feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + s_z = np.shape(gldzm) # Size of gldzm + c_vect = range(1, s_z[1]+1) # Row vectors + r_vect = range(1, s_z[0]+1) # Column vectors + c_mat, r_mat = np.meshgrid(c_vect, r_vect) # Column and row indicators for each entry of the gldzm + + #Large distance high grey levels emphasis + return np.sum(np.sum(gldzm*(np.power( + r_mat, 2))*(np.power(c_mat, 2)))) + +def glnu(gldzm: np.ndarray) -> float: + """Computes distance zone matrix gray level non-uniformity + This feature refers to "Fdzm_glnu" (ID = VFT7) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the gray level non-uniformity feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + p_g = np.transpose(np.sum(gldzm, 1)) # Gray-Level Vector + n_s = np.sum(gldzm) + + #Gray level non-uniformity + return np.sum(np.power(p_g, 2)) * n_s + +def glnu_norm(gldzm: np.ndarray) -> float: + """Computes distance zone matrix gray level non-uniformity normalised + This feature refers to "Fdzm_glnu_norm" (ID = 7HP3) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the gray level non-uniformity normalised feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + p_g = np.transpose(np.sum(gldzm, 1)) # Gray-Level Vector + + #Gray level non-uniformity normalised + return np.sum(np.power(p_g, 2)) + +def zdnu(gldzm: np.ndarray) -> float: + """Computes zone distance non-uniformity + This feature refers to "Fdzm_zdnu" (ID = V294) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the zone distance non-uniformity feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + p_d = np.sum(gldzm, 0) # Distance Zone Vector + n_s = np.sum(gldzm) + + #Zone distance non-uniformity + return np.sum(np.power(p_d, 2)) * n_s + +def zdnu_norm(gldzm: np.ndarray) -> float: + """Computes zone distance non-uniformity normalised + This feature refers to "Fdzm_zdnu_norm" (ID = IATH) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the zone distance non-uniformity normalised feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + p_d = np.sum(gldzm, 0) # Distance Zone Vector + + #Zone distance non-uniformity normalised + return np.sum(np.power(p_d, 2)) + +def z_perc(gldzm, vol_int): + """Computes zone percentage + This feature refers to "Fdzm_z_perc" (ID = VIWW) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the zone percentage feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + n_s = np.sum(gldzm) + + #Zone percentage + return n_s/np.sum(~np.isnan(vol_int[:])) + +def gl_var(gldzm: np.ndarray) -> float: + """Computes grey level variance + This feature refers to "Fdzm_gl_var" (ID = QK93) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the grey level variance feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + s_z = np.shape(gldzm) # Size of gldzm + c_vect = range(1, s_z[1]+1) # Row vectors + r_vect = range(1, s_z[0]+1) # Column vectors + _, r_mat = np.meshgrid(c_vect, r_vect) # Column and row indicators for each entry of the gldzm + temp = r_mat * gldzm + u = np.sum(temp) + temp = (np.power(r_mat-u, 2)) * gldzm + + #Grey level variance + return np.sum(temp) + +def zd_var(gldzm: np.ndarray) -> float: + """Computes zone distance variance + This feature refers to "Fdzm_zd_var" (ID = 7WT1) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the zone distance variance feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + s_z = np.shape(gldzm) # Size of gldzm + c_vect = range(1, s_z[1]+1) # Row vectors + r_vect = range(1, s_z[0]+1) # Column vectors + c_mat, _ = np.meshgrid(c_vect, r_vect) # Column and row indicators for each entry of the gldzm + temp = c_mat * gldzm + u = np.sum(temp) + temp = (np.power(c_mat-u, 2)) * gldzm + + #Zone distance variance + return np.sum(temp) + +def zd_entr(gldzm: np.ndarray) -> float: + """Computes zone distance entropy + This feature refers to "Fdzm_zd_entr" (ID = GBDU) in + the `IBSI1 reference manual `__. + + Args: + gldzm (ndarray): array of the gray level distance zone matrix + + Returns: + float: the zone distance entropy feature + """ + gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm + val_pos = gldzm[np.nonzero(gldzm)] + temp = val_pos * np.log2(val_pos) + + #Zone distance entropy + return -np.sum(temp) diff --git a/MEDiml/biomarkers/glrlm.py b/MEDiml/biomarkers/glrlm.py new file mode 100644 index 0000000..5b2edbb --- /dev/null +++ b/MEDiml/biomarkers/glrlm.py @@ -0,0 +1,1315 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from copy import deepcopy +from typing import Dict, List, Union + +import numpy as np +import pandas as pd + +from ..utils.textureTools import (coord2index, get_neighbour_direction, + is_list_all_none) + + +def extract_all(vol: np.ndarray, + dist_correction: Union[bool, str]=None, + merge_method: str="vol_merge") -> Dict: + """Computes glrlm features. + This features refer to Grey Level Run Length Matrix family in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, isotropically resampled, quantized + (e.g. n_g = 32, levels = [1, ..., n_g]), with NaNs outside the region + of interest. + dist_correction (Union[bool, str], optional): Set this variable to true in order to use + discretization length difference corrections as used + by the `Institute of Physics and Engineering in + Medicine `__. + Set this variable to false to replicate IBSI results. + Or use string and specify the norm for distance weighting. + Weighting is only performed if this argument is + "manhattan", "euclidean" or "chebyshev". + merge_method (str, optional): merging method which determines how features are + calculated. One of "average", "slice_merge", "dir_merge" and "vol_merge". + Note that not all combinations of spatial and merge method are valid. + method (str, optional): Either 'old' (deprecated) or 'new' (faster) method. + + Returns: + Dict: Dict of the glrlm features. + + Raises: + ValueError: + If `method` is not 'old' or 'new'. + + Todo: + * Enable calculation of RLM features using different spatial methods (2d, 2.5d, 3d) + * Enable calculation of RLM features using different RLM distance settings + * Enable calculation of RLM features for different merge methods (average, slice_merge, dir_merge, vol_merge) + * Provide the range of discretised intensities from a calling function and pass to get_rlm_features. + * Test if dist_correction works as expected. + """ + + rlm_features = get_rlm_features(vol=vol, + merge_method=merge_method, + dist_weight_norm=dist_correction) + + return rlm_features + +def get_rlm_features(vol: np.ndarray, + glrlm_spatial_method: str="3d", + merge_method: str="vol_merge", + dist_weight_norm: Union[bool, str]=None) -> Dict: + """Extract run length matrix-based features from the intensity roi mask. + + Note: + This code was adapted from the in-house radiomics software created at + OncoRay, Dresden, Germany. + + Args: + vol (ndarray): volume with discretised intensities as 3D numpy array (x, y, z). + glrlm_spatial_method (str, optional): spatial method which determines the way + co-occurrence matrices are calculated and how features are determined. + must be "2d", "2.5d" or "3d". + merge_method (str, optional): merging method which determines how features are + calculated. One of "average", "slice_merge", "dir_merge" and "vol_merge". + Note that not all combinations of spatial and merge method are valid. + dist_weight_norm (Union[bool, str], optional): norm for distance weighting. Weighting is only + performed if this argument is either "manhattan", + "euclidean", "chebyshev" or bool. + + Returns: + Dict: Dict of the length matrix features. + """ + if type(glrlm_spatial_method) is not list: + glrlm_spatial_method = [glrlm_spatial_method] + + if type(merge_method) is not list: + merge_method = [merge_method] + + if type(dist_weight_norm) is bool: + if dist_weight_norm: + dist_weight_norm = "euclidean" + + # Get the roi in tabular format + img_dims = vol.shape + index_id = np.arange(start=0, stop=vol.size) + coords = np.unravel_index(indices=index_id, shape=img_dims) # Convert flat index into coordinate + df_img = pd.DataFrame({"index_id": index_id, + "g": np.ravel(vol), + "x": coords[0], + "y": coords[1], + "z": coords[2], + "roi_int_mask": np.ravel(np.isfinite(vol))}) + + # Generate an empty feature list + feat_list = [] + + # Iterate over spatial arrangements + for ii_spatial in glrlm_spatial_method: + # Initiate list of rlm objects + rlm_list = [] + + # Perform 2D analysis + if ii_spatial.lower() in ["2d", "2.5d"]: + # Iterate over slices + for ii_slice in np.arange(0, img_dims[2]): + # Get neighbour direction and iterate over neighbours + nbrs = get_neighbour_direction(d=1, + distance="chebyshev", + centre=False, + complete=False, + dim3=False) + + for ii_direction in np.arange(0, np.shape(nbrs)[1]): + # Add rlm matrices to list + rlm_list += [RunLengthMatrix(direction=nbrs[:, ii_direction], + direction_id=ii_direction, + spatial_method=ii_spatial.lower(), + img_slice=ii_slice)] + + # Perform 3D analysis + if ii_spatial.lower() == "3d": + # Get neighbour direction and iterate over neighbours + nbrs = get_neighbour_direction(d=1, + distance="chebyshev", + centre=False, + complete=False, + dim3=True) + + for ii_direction in np.arange(0, np.shape(nbrs)[1]): + # Add rlm matrices to list + rlm_list += [RunLengthMatrix(direction=nbrs[:, ii_direction], + direction_id=ii_direction, + spatial_method=ii_spatial.lower())] + + # Calculate run length matrices + for rlm in rlm_list: + rlm.calculate_rlm_matrix(df_img=df_img, + img_dims=img_dims, + dist_weight_norm=dist_weight_norm) + + # Merge matrices according to the given method + for merge_method in merge_method: + upd_list = combine_rlm_matrices(rlm_list=rlm_list, + merge_method=merge_method, + spatial_method=ii_spatial.lower()) + + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is None: + continue + + # Calculate features + feat_run_list = [] + for rlm in upd_list: + feat_run_list += [rlm.calculate_rlm_features()] + + # Average feature values + feat_list += [pd.concat(feat_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary + df_feat = pd.concat(feat_list, axis=1).to_dict(orient="records")[0] + + return df_feat + +def get_matrix(vol: np.ndarray, + glrlm_spatial_method: str="3d", + merge_method: str="vol_merge", + dist_weight_norm: Union[bool, str]=None) -> np.ndarray: + """Extract run length matrix-based features from the intensity roi mask. + + Note: + This code was adapted from the in-house radiomics software created at + OncoRay, Dresden, Germany. + + Args: + vol (ndarray): volume with discretised intensities as 3D numpy array (x, y, z). + glrlm_spatial_method (str, optional): spatial method which determines the way + co-occurrence matrices are calculated and how features are determined. + must be "2d", "2.5d" or "3d". + merge_method (str, optional): merging method which determines how features are + calculated. One of "average", "slice_merge", "dir_merge" and "vol_merge". + Note that not all combinations of spatial and merge method are valid. + dist_weight_norm (Union[bool, str], optional): norm for distance weighting. Weighting is only + performed if this argument is either "manhattan", + "euclidean", "chebyshev" or bool. + + Returns: + ndarray: Dict of the length matrix features. + """ + if type(glrlm_spatial_method) is not list: + glrlm_spatial_method = [glrlm_spatial_method] + + if type(merge_method) is not list: + merge_method = [merge_method] + + if type(dist_weight_norm) is bool: + if dist_weight_norm: + dist_weight_norm = "euclidean" + + # Get the roi in tabular format + img_dims = vol.shape + index_id = np.arange(start=0, stop=vol.size) + coords = np.unravel_index(indices=index_id, shape=img_dims) # Convert flat index into coordinate + df_img = pd.DataFrame({"index_id": index_id, + "g": np.ravel(vol), + "x": coords[0], + "y": coords[1], + "z": coords[2], + "roi_int_mask": np.ravel(np.isfinite(vol))}) + + # Iterate over spatial arrangements + for ii_spatial in glrlm_spatial_method: + # Initiate list of rlm objects + rlm_list = [] + + # Perform 2D analysis + if ii_spatial.lower() in ["2d", "2.5d"]: + # Iterate over slices + for ii_slice in np.arange(0, img_dims[2]): + # Get neighbour direction and iterate over neighbours + nbrs = get_neighbour_direction(d=1, + distance="chebyshev", + centre=False, + complete=False, + dim3=False) + + for ii_direction in np.arange(0, np.shape(nbrs)[1]): + # Add rlm matrices to list + rlm_list += [RunLengthMatrix(direction=nbrs[:, ii_direction], + direction_id=ii_direction, + spatial_method=ii_spatial.lower(), + img_slice=ii_slice)] + + # Perform 3D analysis + if ii_spatial.lower() == "3d": + # Get neighbour direction and iterate over neighbours + nbrs = get_neighbour_direction(d=1, + distance="chebyshev", + centre=False, + complete=False, + dim3=True) + + for ii_direction in np.arange(0, np.shape(nbrs)[1]): + # Add rlm matrices to list + rlm_list += [RunLengthMatrix(direction=nbrs[:, ii_direction], + direction_id=ii_direction, + spatial_method=ii_spatial.lower())] + + # Calculate run length matrices + for rlm in rlm_list: + rlm.calculate_rlm_matrix(df_img=df_img, + img_dims=img_dims, + dist_weight_norm=dist_weight_norm) + + # Merge matrices according to the given method + for merge_method in merge_method: + upd_list = combine_rlm_matrices(rlm_list=rlm_list, + merge_method=merge_method, + spatial_method=ii_spatial.lower()) + + return upd_list + +def combine_rlm_matrices(rlm_list: list, + merge_method: str, + spatial_method: str)-> List: + """Merges run length matrices prior to feature calculation. + + Note: + This code was adapted from the in-house radiomics software created at + OncoRay, Dresden, Germany. + + Args: + rlm_list (List): List of RunLengthMatrix objects. + merge_method (str): Merging method which determines how features are calculated. + One of "average", "slice_merge", "dir_merge" and "vol_merge". Note that not all + combinations of spatial and merge method are valid. + spatial_method (str): Spatial method which determines the way co-occurrence + matrices are calculated and how features are determined. One of "2d", "2.5d" + or "3d". + + Returns: + List[CooccurrenceMatrix]: List of one or more merged RunLengthMatrix objects. + """ + # Initiate empty list + use_list = [] + + # For average features over direction, maintain original run length matrices + if merge_method == "average" and spatial_method in ["2d", "3d"]: + # Make copy of rlm_list + for rlm in rlm_list: + use_list += [rlm._copy()] + + # Set merge method to average + for rlm in use_list: + rlm.merge_method = "average" + + # Merge rlms within each slice + elif merge_method == "slice_merge" and spatial_method == "2d": + # Find slice_ids + slice_id = [] + for rlm in rlm_list: + slice_id += [rlm.slice] + + # Iterate over unique slice_ids + for ii_slice in np.unique(slice_id): + slice_rlm_id = np.squeeze(np.where(slice_id == ii_slice)) + + # Select all matrices within the slice + sel_matrix_list = [] + for rlm_id in slice_rlm_id: + sel_matrix_list += [rlm_list[rlm_id].matrix] + + # Check if any matrix has been created for the currently selected slice + if is_list_all_none(sel_matrix_list): + # No matrix was created + use_list += [RunLengthMatrix(direction=None, + direction_id=None, + spatial_method=spatial_method, + img_slice=ii_slice, + merge_method=merge_method, + matrix=None, + n_v=0.0)] + else: + # Merge matrices within the slice + merge_rlm = pd.concat(sel_matrix_list, axis=0) + merge_rlm = merge_rlm.groupby(by=["i", "r"]).sum().reset_index() + + # Update the number of voxels within the merged slice + merge_n_v = 0.0 + for rlm_id in slice_rlm_id: + merge_n_v += rlm_list[rlm_id].n_v + + # Create new run length matrix + use_list += [RunLengthMatrix(direction=None, + direction_id=None, + spatial_method=spatial_method, + img_slice=ii_slice, + merge_method=merge_method, + matrix=merge_rlm, + n_v=merge_n_v)] + + # Merge rlms within each slice + elif merge_method == "dir_merge" and spatial_method == "2.5d": + # Find direction ids + dir_id = [] + for rlm in rlm_list: + dir_id += [rlm.direction_id] + + # Iterate over unique dir_ids + for ii_dir in np.unique(dir_id): + dir_rlm_id = np.squeeze(np.where(dir_id == ii_dir)) + + # Select all matrices with the same direction + sel_matrix_list = [] + for rlm_id in dir_rlm_id: + sel_matrix_list += [rlm_list[rlm_id].matrix] + + # Check if any matrix has been created for the currently selected direction + if is_list_all_none(sel_matrix_list): + # No matrix was created + use_list += [RunLengthMatrix(direction=rlm_list[dir_rlm_id[0]].direction, + direction_id=ii_dir, + spatial_method=spatial_method, + img_slice=None, + merge_method=merge_method, + matrix=None, + n_v=0.0)] + else: + # Merge matrices with the same direction + merge_rlm = pd.concat(sel_matrix_list, axis=0) + merge_rlm = merge_rlm.groupby(by=["i", "r"]).sum().reset_index() + + # Update the number of voxels within the merged slice + merge_n_v = 0.0 + for rlm_id in dir_rlm_id: + merge_n_v += rlm_list[rlm_id].n_v + + # Create new run length matrix + use_list += [RunLengthMatrix(direction=rlm_list[dir_rlm_id[0]].direction, + direction_id=ii_dir, + spatial_method=spatial_method, + img_slice=None, + merge_method=merge_method, + matrix=merge_rlm, + n_v=merge_n_v)] + + # Merge all rlms into a single representation + elif merge_method == "vol_merge" and spatial_method in ["2.5d", "3d"]: + # Select all matrices within the slice + sel_matrix_list = [] + for rlm_id in np.arange(len(rlm_list)): + sel_matrix_list += [rlm_list[rlm_id].matrix] + + # Check if any matrix has been created + if is_list_all_none(sel_matrix_list): + # No matrix was created + use_list += [RunLengthMatrix(direction=None, + direction_id=None, + spatial_method=spatial_method, + img_slice=None, + merge_method=merge_method, + matrix=None, + n_v=0.0)] + else: + # Merge run length matrices + merge_rlm = pd.concat(sel_matrix_list, axis=0) + merge_rlm = merge_rlm.groupby(by=["i", "r"]).sum().reset_index() + + # Update the number of voxels + merge_n_v = 0.0 + for rlm_id in np.arange(len(rlm_list)): + merge_n_v += rlm_list[rlm_id].n_v + + # Create new run length matrix + use_list += [RunLengthMatrix(direction=None, + direction_id=None, + spatial_method=spatial_method, + img_slice=None, + merge_method=merge_method, + matrix=merge_rlm, + n_v=merge_n_v)] + + else: + use_list = None + + # Return to new rlm list to calling function + return use_list + +class RunLengthMatrix: + """Class that contains a single run length matrix. + + Note: + This code was adapted from the in-house radiomics software created at + OncoRay, Dresden, Germany. + + Args: + direction (ndarray): Direction along which neighbouring voxels are found. + direction_id (int): Direction index to identify unique direction vectors. + spatial_method (str): Spatial method used to calculate the co-occurrence + matrix: "2d", "2.5d" or "3d". + img_slice (ndarray, optional): Corresponding slice index (only if the + co-occurrence matrix corresponds to a 2d image slice). + merge_method (str, optional): Method for merging the co-occurrence matrix + with other co-occurrence matrices. + matrix (pandas.DataFrame, optional): The actual co-occurrence matrix in + sparse format (row, column, count). + n_v (int, optional): The number of voxels in the volume. + + Attributes: + direction (ndarray): Direction along which neighbouring voxels are found. + direction_id (int): Direction index to identify unique direction vectors. + spatial_method (str): Spatial method used to calculate the co-occurrence + matrix: "2d", "2.5d" or "3d". + img_slice (ndarray): Corresponding slice index (only if the co-occurrence + matrix corresponds to a 2d image slice). + merge_method (str): Method for merging the co-occurrence matrix with other + co-occurrence matrices. + matrix (pandas.DataFrame): The actual co-occurrence matrix in sparse format + (row, column, count). + n_v (int): The number of voxels in the volume. + """ + + def __init__(self, + direction: np.ndarray, + direction_id: int, + spatial_method: str, + img_slice: np.ndarray=None, + merge_method: str=None, + matrix: pd.DataFrame=None, + n_v: int=None) -> None: + """ + Initialising function for a new run length matrix + """ + + # Direction and slice for which the current matrix is extracted + self.direction = direction + self.direction_id = direction_id + self.img_slice = img_slice + + # Spatial analysis method (2d, 2.5d, 3d) and merge method (average, slice_merge, dir_merge, vol_merge) + self.spatial_method = spatial_method + + # Place holders + self.merge_method = merge_method + self.matrix = matrix + self.n_v = n_v + + def _copy(self): + """Returns a copy of the RunLengthMatrix object.""" + + return deepcopy(self) + + def _set_empty(self): + """Creates an empty RunLengthMatrix""" + self.n_v = 0 + self.matrix = None + + def calculate_rlm_matrix(self, + df_img: pd.DataFrame, + img_dims: np.ndarray, + dist_weight_norm: str) -> None: + """Function that calculates a run length matrix for the settings provided + during initialisation and the input image. + + Args: + df_img (pandas.DataFrame): Data table containing image intensities, x, y and z coordinates, + and mask labels corresponding to voxels in the volume. + img_dims (ndarray, List[float]): Dimensions of the image volume. + dist_weight_norm (str): Norm for distance weighting. Weighting is only + performed if this parameter is either "manhattan", "euclidean" or "chebyshev". + + Returns: + None. Assigns the created image table (rlm matrix) to the `matrix` attribute. + + Raises: + ValueError: + If `self.spatial_method` is not "2d", "2.5d" or "3d". + Also, if ``dist_weight_norm`` is not "manhattan", "euclidean" or "chebyshev". + """ + # Check if the df_img actually exists + if df_img is None: + self._set_empty() + return + + # Check if the roi contains any masked voxels. If this is not the case, don't construct the glrlm. + if not np.any(df_img.roi_int_mask): + self._set_empty() + return + + # Create local copies of the image table + if self.spatial_method == "3d": + df_rlm = deepcopy(df_img) + elif self.spatial_method in ["2d", "2.5d"]: + df_rlm = deepcopy(df_img[df_img.z == self.img_slice]) + df_rlm["index_id"] = np.arange(0, len(df_rlm)) + df_rlm["z"] = 0 + df_rlm = df_rlm.reset_index(drop=True) + else: + raise ValueError("The spatial method for grey level run length matrices \ + should be one of \"2d\", \"2.5d\" or \"3d\".") + + # Set grey level of voxels outside ROI to NaN + df_rlm.loc[df_rlm.roi_int_mask == False, "g"] = np.nan + + # Set the number of voxels + self.n_v = np.sum(df_rlm.roi_int_mask.values) + + # Determine update index number for direction + if (self.direction[2] + self.direction[1] * img_dims[2] + self.direction[0] * img_dims[2] * img_dims[1]) >= 0: + curr_dir = self.direction + else: + curr_dir = - self.direction + + # Step size + ind_update = curr_dir[2] + curr_dir[1] * img_dims[2] + curr_dir[0] * img_dims[2] * img_dims[1] + + # Generate information concerning segments + n_seg = ind_update # Number of segments + + # Check if the number of segments is greater than one + if n_seg == 0: + self._set_empty() + return + + seg_len = (len(df_rlm) - 1) // ind_update + 1 # Nominal segment length + trans_seg_len = np.tile([seg_len - 1], reps=n_seg) # Initial segment length for transitions (nominal length-1) + full_len_trans = n_seg - n_seg*seg_len + len(df_rlm) # Number of full segments + trans_seg_len[0:full_len_trans] += 1 # Update full segments + + # Create transition vector + trans_vec = np.tile(np.arange(start=0, stop=len(df_rlm), step=ind_update), reps=ind_update) + trans_vec += np.repeat(np.arange(start=0, stop=n_seg), repeats=seg_len) + trans_vec = trans_vec[trans_vec < len(df_rlm)] + + # Determine valid transitions + to_index = coord2index(x=df_rlm.x.values + curr_dir[0], + y=df_rlm.y.values + curr_dir[1], + z=df_rlm.z.values + curr_dir[2], + dims=img_dims) + + # Determine which transitions are valid + end_ind = np.nonzero(to_index[trans_vec] < 0)[0] # Find transitions that form an endpoints + + # Get an interspersed array of intensities. Runs are broken up by np.nan + intensities = np.insert(df_rlm.g.values[trans_vec], end_ind + 1, np.nan) + + # Determine run length start and end indices + rle_end = np.array(np.append(np.where(intensities[1:] != intensities[:-1]), len(intensities) - 1)) + rle_start = np.cumsum(np.append(0, np.diff(np.append(-1, rle_end))))[:-1] + + # Generate dataframe + df_rltable = pd.DataFrame({"i": intensities[rle_start], + "r": rle_end - rle_start + 1}) + df_rltable = df_rltable.loc[~np.isnan(df_rltable.i), :] + df_rltable = df_rltable.groupby(by=["i", "r"]).size().reset_index(name="n") + + if dist_weight_norm in ["manhattan", "euclidean", "chebyshev"]: + if dist_weight_norm == "manhattan": + weight = sum(abs(self.direction)) + elif dist_weight_norm == "euclidean": + weight = np.sqrt(sum(np.power(self.direction, 2.0))) + elif dist_weight_norm == "chebyshev": + weight = np.max(abs(self.direction)) + df_rltable.n /= weight + + # Add matrix to object + self.matrix = df_rltable + + def calculate_rlm_features(self) -> pd.DataFrame: + """Computes run length matrix features for the current run length matrix. + + Returns: + pandas.DataFrame: Data frame with values for each feature. + """ + # Create feature table + feat_names = ["Frlm_sre", + "Frlm_lre", + "Frlm_lgre", + "Frlm_hgre", + "Frlm_srlge", + "Frlm_srhge", + "Frlm_lrlge", + "Frlm_lrhge", + "Frlm_glnu", + "Frlm_glnu_norm", + "Frlm_rlnu", + "Frlm_rlnu_norm", + "Frlm_r_perc", + "Frlm_gl_var", + "Frlm_rl_var", + "Frlm_rl_entr"] + + df_feat = pd.DataFrame(np.full(shape=(1, len(feat_names)), fill_value=np.nan)) + df_feat.columns = feat_names + + # Don't return data for empty slices or slices without a good matrix + if self.matrix is None: + # Update names + # df_feat.columns += self._parse_feature_names() + return df_feat + elif len(self.matrix) == 0: + # Update names + # df_feat.columns += self._parse_feature_names() + return df_feat + + # Create local copy of the run length matrix and set column names + df_rij = deepcopy(self.matrix) + df_rij.columns = ["i", "j", "rij"] + + # Sum over grey levels + df_ri = df_rij.groupby(by="i")["rij"].agg(np.sum).reset_index().rename(columns={"rij": "ri"}) + + # Sum over run lengths + df_rj = df_rij.groupby(by="j")["rij"].agg(np.sum).reset_index().rename(columns={"rij": "rj"}) + + # Constant definitions + n_s = np.sum(df_rij.rij) * 1.0 # Number of runs + n_v = self.n_v * 1.0 # Number of voxels + + ############################################## + ###### glrlm features ###### + ############################################## + # Short runs emphasis + df_feat.loc[0, "Frlm_sre"] = np.sum(df_rj.rj / df_rj.j ** 2.0) / n_s + + # Long runs emphasis + df_feat.loc[0, "Frlm_lre"] = np.sum(df_rj.rj * df_rj.j ** 2.0) / n_s + + # Grey level non-uniformity + df_feat.loc[0, "Frlm_glnu"] = np.sum(df_ri.ri ** 2.0) / n_s + + # Grey level non-uniformity, normalised + df_feat.loc[0, "Frlm_glnu_norm"] = np.sum(df_ri.ri ** 2.0) / n_s ** 2.0 + + # Run length non-uniformity + df_feat.loc[0, "Frlm_rlnu"] = np.sum(df_rj.rj ** 2.0) / n_s + + # Run length non-uniformity, normalised + df_feat.loc[0, "Frlm_rlnu_norm"] = np.sum(df_rj.rj ** 2.0) / n_s ** 2.0 + + # Run percentage + df_feat.loc[0, "Frlm_r_perc"] = n_s / n_v + + # Low grey level run emphasis + df_feat.loc[0, "Frlm_lgre"] = np.sum(df_ri.ri / df_ri.i ** 2.0) / n_s + + # High grey level run emphasis + df_feat.loc[0, "Frlm_hgre"] = np.sum(df_ri.ri * df_ri.i ** 2.0) / n_s + + # Short run low grey level emphasis + df_feat.loc[0, "Frlm_srlge"] = np.sum(df_rij.rij / (df_rij.i * df_rij.j) ** 2.0) / n_s + + # Short run high grey level emphasis + df_feat.loc[0, "Frlm_srhge"] = np.sum(df_rij.rij * df_rij.i ** 2.0 / df_rij.j ** 2.0) / n_s + + # Long run low grey level emphasis + df_feat.loc[0, "Frlm_lrlge"] = np.sum(df_rij.rij * df_rij.j ** 2.0 / df_rij.i ** 2.0) / n_s + + # Long run high grey level emphasis + df_feat.loc[0, "Frlm_lrhge"] = np.sum(df_rij.rij * df_rij.i ** 2.0 * df_rij.j ** 2.0) / n_s + + # Grey level variance + mu = np.sum(df_rij.rij * df_rij.i) / n_s + df_feat.loc[0, "Frlm_gl_var"] = np.sum((df_rij.i - mu) ** 2.0 * df_rij.rij) / n_s + + # Run length variance + mu = np.sum(df_rij.rij * df_rij.j) / n_s + df_feat.loc[0, "Frlm_rl_var"] = np.sum((df_rij.j - mu) ** 2.0 * df_rij.rij) / n_s + + # Zone size entropy + df_feat.loc[0, "Frlm_rl_entr"] = - np.sum(df_rij.rij * np.log2(df_rij.rij / n_s)) / n_s + + return df_feat + + def calculate_feature(self, + name: str) -> pd.DataFrame: + """Computes run length matrix features for the current run length matrix. + + Returns: + ndarray: Value of feature given as parameter + """ + df_feat = pd.DataFrame(np.full(shape=(0, 0), fill_value=np.nan)) + + # Don't return data for empty slices or slices without a good matrix + if self.matrix is None: + # Update names + # df_feat.columns += self._parse_feature_names() + return df_feat + elif len(self.matrix) == 0: + # Update names + # df_feat.columns += self._parse_feature_names() + return df_feat + + # Create local copy of the run length matrix and set column names + df_rij = deepcopy(self.matrix) + df_rij.columns = ["i", "j", "rij"] + + # Sum over grey levels + df_ri = df_rij.groupby(by="i")["rij"].agg(np.sum).reset_index().rename(columns={"rij": "ri"}) + + # Sum over run lengths + df_rj = df_rij.groupby(by="j")["rij"].agg(np.sum).reset_index().rename(columns={"rij": "rj"}) + + # Constant definitions + n_s = np.sum(df_rij.rij) * 1.0 # Number of runs + n_v = self.n_v * 1.0 # Number of voxels + + # Calculation glrlm feature + # Short runs emphasis + if name == "sre": + df_feat.loc["value", "sre"] = np.sum(df_rj.rj / df_rj.j ** 2.0) / n_s + # Long runs emphasis + elif name == "lre": + df_feat.loc["value", "lre"] = np.sum(df_rj.rj * df_rj.j ** 2.0) / n_s + # Grey level non-uniformity + elif name == "glnu": + df_feat.loc["value", "glnu"] = np.sum(df_ri.ri ** 2.0) / n_s + # Grey level non-uniformity, normalised + elif name == "glnu_norm": + df_feat.loc["value", "glnu_norm"] = np.sum(df_ri.ri ** 2.0) / n_s ** 2.0 + # Run length non-uniformity + elif name == "rlnu": + df_feat.loc["value", "rlnu"] = np.sum(df_rj.rj ** 2.0) / n_s + # Run length non-uniformity, normalised + elif name == "rlnu_norm": + df_feat.loc["value", "rlnu_norm"] = np.sum(df_rj.rj ** 2.0) / n_s ** 2.0 + # Run percentage + elif name == "r_perc": + df_feat.loc["value", "r_perc"] = n_s / n_v + # Low grey level run emphasis + elif name == "lgre": + df_feat.loc["value", "lgre"] = np.sum(df_ri.ri / df_ri.i ** 2.0) / n_s + # High grey level run emphasis + elif name == "hgre": + df_feat.loc["value", "hgre"] = np.sum(df_ri.ri * df_ri.i ** 2.0) / n_s + # Short run low grey level emphasis + elif name == "srlge": + df_feat.loc["value", "srlge"] = np.sum(df_rij.rij / (df_rij.i * df_rij.j) ** 2.0) / n_s + # Short run high grey level emphasis + elif name == "srhge": + df_feat.loc["value", "srhge"] = np.sum(df_rij.rij * df_rij.i ** 2.0 / df_rij.j ** 2.0) / n_s + # Long run low grey level emphasis + elif name == "lrlge": + df_feat.loc["value", "lrlge"] = np.sum(df_rij.rij * df_rij.j ** 2.0 / df_rij.i ** 2.0) / n_s + # Long run high grey level emphasis + elif name == "lrhge": + df_feat.loc["value", "lrhge"] = np.sum(df_rij.rij * df_rij.i ** 2.0 * df_rij.j ** 2.0) / n_s + # Grey level variance + elif name == "gl_var": + mu = np.sum(df_rij.rij * df_rij.i) / n_s + df_feat.loc["value", "gl_var"] = np.sum((df_rij.i - mu) ** 2.0 * df_rij.rij) / n_s + # Run length variance + elif name == "rl_var": + mu = np.sum(df_rij.rij * df_rij.j) / n_s + df_feat.loc["value", "rl_var"] = np.sum((df_rij.j - mu) ** 2.0 * df_rij.rij) / n_s + # Zone size entropy + elif name == "rl_entr": + df_feat.loc["value", "rl_entr"] = - np.sum(df_rij.rij * np.log2(df_rij.rij / n_s)) / n_s + else: + print("ERROR: Wrong arg. Use ones from list : (sre, lre, glnu, glnu_normn, rlnu \ + rlnu_norm, r_perc, lgre, hgre, srlge, srhge, lrlge, lrhge, gl_var, rl_var, rl_entr)") + + return df_feat + + def _parse_feature_names(self) -> str: + """"Adds additional settings-related identifiers to each feature. + Not used currently, as the use of different settings for the + run length matrix is not supported. + """ + parse_str = "" + + # Add spatial method + if self.spatial_method is not None: + parse_str += "_" + self.spatial_method + + # Add merge method + if self.merge_method is not None: + if self.merge_method == "average": + parse_str += "_avg" + if self.merge_method == "slice_merge": + parse_str += "_s_mrg" + if self.merge_method == "dir_merge": + parse_str += "_d_mrg" + if self.merge_method == "vol_merge": + parse_str += "_v_mrg" + + return parse_str + +def sre(upd_list: np.ndarray) -> float: + """Compute Short runs emphasis feature from the run length matrices list. + This feature refers to "Frlm_sre" (ID = 22OV) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Short runs emphasis feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Short runs emphasis feature + sre_list = [] + sre_run_list = [] + for rlm in upd_list: + sre_run_list += [rlm.calculate_feature("sre")] + + # Average feature values + sre_list += [pd.concat(sre_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_sre = pd.concat(sre_list, axis=1).to_dict(orient="records")[0] + sre = list(df_sre.values())[0] + + return sre + +def lre(upd_list: np.ndarray) -> float: + """Compute Long runs emphasis feature from the run length matrices list. + This feature refers to "Frlm_lre" (ID = W4KF) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Long runs emphasis feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Long runs emphasis feature + lre_list = [] + lre_run_list = [] + for rlm in upd_list: + lre_run_list += [rlm.calculate_feature("lre")] + + # Average feature values + lre_list += [pd.concat(lre_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_lre = pd.concat(lre_list, axis=1).to_dict(orient="records")[0] + lre = list(df_lre.values())[0] + + return lre + +def glnu(upd_list: np.ndarray) -> float: + """Compute Grey level non-uniformity feature from the run length matrices list. + This feature refers to "Frlm_glnu" (ID = R5YN) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Grey level non-uniformity feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Grey level non-uniformity feature + glnu_list = [] + glnu_run_list = [] + for rlm in upd_list: + glnu_run_list += [rlm.calculate_feature("glnu")] + + # Average feature values + glnu_list += [pd.concat(glnu_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_glnu = pd.concat(glnu_list, axis=1).to_dict(orient="records")[0] + glnu = list(df_glnu.values())[0] + + return glnu + +def glnu_norm(upd_list: np.ndarray) -> float: + """Compute Grey level non-uniformity normalised feature from the run length matrices list. + This feature refers to "Frlm_glnu_norm" (ID = OVBL) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Grey level non-uniformity normalised feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Grey level non-uniformity normalised feature + glnu_norm_list = [] + glnu_norm_run_list = [] + for rlm in upd_list: + glnu_norm_run_list += [rlm.calculate_feature("glnu_norm")] + + # Average feature values + glnu_norm_list += [pd.concat(glnu_norm_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_glnu_norm = pd.concat(glnu_norm_list, axis=1).to_dict(orient="records")[0] + glnu_norm = list(df_glnu_norm.values())[0] + + return glnu_norm + +def rlnu(upd_list: np.ndarray) -> float: + """Compute Run length non-uniformity feature from the run length matrices list. + This feature refers to "Frlm_rlnu" (ID = W92Y) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Run length non-uniformity feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Run length non-uniformity feature + rlnu_list = [] + rlnu_run_list = [] + for rlm in upd_list: + rlnu_run_list += [rlm.calculate_feature("rlnu")] + + # Average feature values + rlnu_list += [pd.concat(rlnu_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_rlnu = pd.concat(rlnu_list, axis=1).to_dict(orient="records")[0] + rlnu = list(df_rlnu.values())[0] + + return rlnu + +def rlnu_norm(upd_list: np.ndarray) -> float: + """Compute Run length non-uniformity normalised feature from the run length matrices list. + This feature refers to "Frlm_rlnu_norm" (ID = IC23) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Run length non-uniformity normalised feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Run length non-uniformity normalised feature + rlnu_norm_list = [] + rlnu_norm_run_list = [] + for rlm in upd_list: + rlnu_norm_run_list += [rlm.calculate_feature("rlnu_norm")] + + # Average feature values + rlnu_norm_list += [pd.concat(rlnu_norm_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_rlnu_norm = pd.concat(rlnu_norm_list, axis=1).to_dict(orient="records")[0] + rlnu_norm = list(df_rlnu_norm.values())[0] + + return rlnu_norm + +def r_perc(upd_list: np.ndarray) -> float: + """Compute Run percentage feature from the run length matrices list. + This feature refers to "Frlm_r_perc" (ID = 9ZK5) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Run percentage feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Run percentage feature + r_perc_list = [] + r_perc_run_list = [] + for rlm in upd_list: + r_perc_run_list += [rlm.calculate_feature("r_perc")] + + # Average feature values + r_perc_list += [pd.concat(r_perc_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_r_perc = pd.concat(r_perc_list, axis=1).to_dict(orient="records")[0] + r_perc = list(df_r_perc.values())[0] + + return r_perc + +def lgre(upd_list: np.ndarray) -> float: + """Compute Low grey level run emphasis feature from the run length matrices list. + This feature refers to "Frlm_lgre" (ID = V3SW) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Low grey level run emphasis feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Low grey level run emphasis feature + lgre_list = [] + lgre_run_list = [] + for rlm in upd_list: + lgre_run_list += [rlm.calculate_feature("lgre")] + + # Average feature values + lgre_list += [pd.concat(lgre_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_lgre = pd.concat(lgre_list, axis=1).to_dict(orient="records")[0] + lgre = list(df_lgre.values())[0] + + return lgre + +def hgre(upd_list: np.ndarray) -> float: + """Compute High grey level run emphasis feature from the run length matrices list. + This feature refers to "Frlm_hgre" (ID = G3QZ) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the High grey level run emphasis feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate High grey level run emphasis feature + hgre_list = [] + hgre_run_list = [] + for rlm in upd_list: + hgre_run_list += [rlm.calculate_feature("hgre")] + + # Average feature values + hgre_list += [pd.concat(hgre_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_hgre = pd.concat(hgre_list, axis=1).to_dict(orient="records")[0] + hgre = list(df_hgre.values())[0] + + return hgre + +def srlge(upd_list: np.ndarray) -> float: + """Compute Short run low grey level emphasis feature from the run length matrices list. + This feature refers to "Frlm_srlge" (ID = HTZT) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Short run low grey level emphasis feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Short run low grey level emphasis feature + srlge_list = [] + srlge_run_list = [] + for rlm in upd_list: + srlge_run_list += [rlm.calculate_feature("srlge")] + + # Average feature values + srlge_list += [pd.concat(srlge_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_srlge = pd.concat(srlge_list, axis=1).to_dict(orient="records")[0] + srlge = list(df_srlge.values())[0] + + return srlge + +def srhge(upd_list: np.ndarray) -> float: + """Compute Short run high grey level emphasis feature from the run length matrices list. + This feature refers to "Frlm_srhge" (ID = GD3A) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Short run high grey level emphasis feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Short run high grey level emphasis feature + srhge_list = [] + srhge_run_list = [] + for rlm in upd_list: + srhge_run_list += [rlm.calculate_feature("srhge")] + + # Average feature values + srhge_list += [pd.concat(srhge_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_srhge = pd.concat(srhge_list, axis=1).to_dict(orient="records")[0] + srhge = list(df_srhge.values())[0] + + return srhge + +def lrlge(upd_list: np.ndarray) -> float: + """Compute Long run low grey level emphasis feature from the run length matrices list. + This feature refers to "Frlm_lrlge" (ID = IVPO) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Long run low grey level emphasis feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Long run low grey level emphasis feature + lrlge_list = [] + lrlge_run_list = [] + for rlm in upd_list: + lrlge_run_list += [rlm.calculate_feature("lrlge")] + + # Average feature values + lrlge_list += [pd.concat(lrlge_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_lrlge = pd.concat(lrlge_list, axis=1).to_dict(orient="records")[0] + lrlge = list(df_lrlge.values())[0] + + return lrlge + +def lrhge(upd_list: np.ndarray) -> float: + """Compute Long run high grey level emphasisfeature from the run length matrices list. + This feature refers to "Frlm_lrhge" (ID = 3KUM) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Long run high grey level emphasis feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Long run high grey level emphasis feature + lrhge_list = [] + lrhge_run_list = [] + for rlm in upd_list: + lrhge_run_list += [rlm.calculate_feature("lrhge")] + + # Average feature values + lrhge_list += [pd.concat(lrhge_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_lrhge = pd.concat(lrhge_list, axis=1).to_dict(orient="records")[0] + lrhge = list(df_lrhge.values())[0] + + return lrhge + +def gl_var(upd_list: np.ndarray) -> float: + """Compute Grey level variance feature from the run length matrices list. + This feature refers to "Frlm_gl_var" (ID = 8CE5) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Grey level variance feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Grey level variance feature + gl_var_list = [] + gl_var_run_list = [] + for rlm in upd_list: + gl_var_run_list += [rlm.calculate_feature("gl_var")] + + # Average feature values + gl_var_list += [pd.concat(gl_var_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_gl_var = pd.concat(gl_var_list, axis=1).to_dict(orient="records")[0] + gl_var = list(df_gl_var.values())[0] + + return gl_var + +def rl_var(upd_list: np.ndarray) -> float: + """Compute Run length variancefeature from the run length matrices list. + This feature refers to "Frlm_rl_var" (ID = SXLW) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Run length variance feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Run length variance feature + rl_var_list = [] + rl_var_run_list = [] + for rlm in upd_list: + rl_var_run_list += [rlm.calculate_feature("rl_var")] + + # Average feature values + rl_var_list += [pd.concat(rl_var_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_rl_var = pd.concat(rl_var_list, axis=1).to_dict(orient="records")[0] + rl_var = list(df_rl_var.values())[0] + + return rl_var + +def rl_entr(upd_list: np.ndarray) -> float: + """Compute Zone size entropy feature from the run length matrices list. + This feature refers to "Frlm_rl_entr" (ID = HJ9O) in + the `IBSI1 reference manual `__. + + Args: + upd_list (ndarray): Run length matrices computed and merged according given method. + + Returns: + float: Dict of the Zone size entropy feature. + """ + # Skip if no matrices are available (due to illegal combinations of merge and spatial methods + if upd_list is not None: + + # Calculate Zone size entropyfeature + rl_entr_list = [] + rl_entr_run_list = [] + for rlm in upd_list: + rl_entr_run_list += [rlm.calculate_feature("rl_entr")] + + # Average feature values + rl_entr_list += [pd.concat(rl_entr_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary. + df_rl_entr = pd.concat(rl_entr_list, axis=1).to_dict(orient="records")[0] + rl_entr = list(df_rl_entr.values())[0] + + return rl_entr + +def merge_feature(feat_list: np.ndarray) -> float: + """Merge feature tables into a single dictionary. + + Args: + feat_list (ndarray): volume with discretised intensities as 3D numpy array (x, y, z). + + Returns: + float: Dict of the length matrix feature. + """ + df_feat = pd.concat(feat_list, axis=1).to_dict(orient="records")[0] + + return df_feat diff --git a/MEDiml/biomarkers/glszm.py b/MEDiml/biomarkers/glszm.py new file mode 100644 index 0000000..727dc0f --- /dev/null +++ b/MEDiml/biomarkers/glszm.py @@ -0,0 +1,555 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from typing import Dict, List, Union + +import numpy as np +import skimage.measure as skim + + +def get_matrix(roi_only: np.ndarray, + levels: Union[np.ndarray, List]) -> Dict: + r""" + This function computes the Gray-Level Size Zone Matrix (GLSZM) of the + region of interest (ROI) of an input volume. The input volume is assumed + to be isotropically resampled. The zones of different sizes are computed + using 26-voxel connectivity. + This matrix refers to "Grey level size zone based features" (ID = 9SAK) + in the `IBSI1 reference manual `_. + + Note: + This function is compatible with 2D analysis (language not adapted in the text). + + Args: + roi_only_int (ndarray): Smallest box containing the ROI, with the imaging data ready + for texture analysis computations. Voxels outside the ROI are + set to NaNs. + levels (ndarray or List): Vector containing the quantized gray-levels + in the tumor region (or reconstruction ``levels`` of quantization). + + Returns: + ndarray: Array of Gray-Level Size Zone Matrix of ``roi_only``. + + REFERENCES: + [1] Thibault, G., Fertil, B., Navarro, C., Pereira, S., Cau, P., Levy, + N., Mari, J.-L. (2009). Texture Indexes and Gray Level Size Zone + Matrix. Application to Cell Nuclei Classification. In Pattern + Recognition and Information Processing (PRIP) (pp. 140–145). + """ + + # PRELIMINARY + roi_only = roi_only.copy() + n_max = np.sum(~np.isnan(roi_only)) + level_temp = np.max(levels) + 1 + roi_only[np.isnan(roi_only)] = level_temp + levels = np.append(levels, level_temp) + + # QUANTIZATION EFFECTS CORRECTION + # In case (for example) we initially wanted to have 64 levels, but due to + # quantization, only 60 resulted. + unique_vect = levels + n_l = np.size(levels) - 1 + + # INITIALIZATION + # THIS NEEDS TO BE CHANGED. THE ARRAY INITIALIZED COULD BE TOO BIG! + glszm = np.zeros((n_l, n_max)) + + # COMPUTATION OF glszm + temp = roi_only.copy().astype('int') + for i in range(1, n_l+1): + temp[roi_only != unique_vect[i-1]] = 0 + temp[roi_only == unique_vect[i-1]] = 1 + conn_objects, n_zone = skim.label(temp, return_num=True) + for j in range(1, n_zone+1): + col = np.sum(conn_objects == j) + glszm[i-1, col-1] = glszm[i-1, col-1] + 1 + + # REMOVE UNECESSARY COLUMNS + stop = np.nonzero(np.sum(glszm, 0))[0][-1] + glszm = np.delete(glszm, range(stop+1, np.shape(glszm)[1]), 1) + + return glszm + +def extract_all(vol: np.ndarray, + glszm: np.ndarray = None) -> Dict: + """Computes glszm features. + These features refer to "Grey level size zone based features" (ID = 9SAK) + in the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, isotropically resampled, quantized + (e.g. n_g = 32, levels = [1, ..., n_g]), + with NaNs outside the region of interest. + + Returns: + Dict: Dict of glszm features. + + """ + glszm_features = {'Fszm_sze': [], + 'Fszm_lze': [], + 'Fszm_lgze': [], + 'Fszm_hgze': [], + 'Fszm_szlge': [], + 'Fszm_szhge': [], + 'Fszm_lzlge': [], + 'Fszm_lzhge': [], + 'Fszm_glnu': [], + 'Fszm_glnu_norm': [], + 'Fszm_zsnu': [], + 'Fszm_zsnu_norm': [], + 'Fszm_z_perc': [], + 'Fszm_gl_var': [], + 'Fszm_zs_var': [], + 'Fszm_zs_entr': []} + + # GET THE GLSZM MATRIX + # Correct definition, without any assumption + vol = vol.copy() + levels = np.arange(1, np.max(vol[~np.isnan(vol[:])])+1) + if glszm is None: + glszm = get_matrix(vol, levels) + n_s = np.sum(glszm) + glszm = glszm/np.sum(glszm) # Normalization of glszm + sz = np.shape(glszm) # Size of glszm + + c_vect = range(1, sz[1]+1) # Row vectors + r_vect = range(1, sz[0]+1) # Column vectors + # Column and row indicators for each entry of the glszm + c_mat, r_mat = np.meshgrid(c_vect, r_vect) + pg = np.transpose(np.sum(glszm, 1)) # Gray-Level Vector + pz = np.sum(glszm, 0) # Zone Size Vector + + # COMPUTING TEXTURES + + # Small zone emphasis + glszm_features['Fszm_sze'] = (np.matmul(pz, np.transpose(np.power(1.0/np.array(c_vect), 2)))) + + # Large zone emphasis + glszm_features['Fszm_lze'] = (np.matmul(pz, np.transpose(np.power(np.array(c_vect), 2)))) + + # Low grey level zone emphasis + glszm_features['Fszm_lgze'] = np.matmul(pg, np.transpose(np.power( + 1.0/np.array(r_vect), 2))) + + # High grey level zone emphasis + glszm_features['Fszm_hgze'] = np.matmul(pg, np.transpose(np.power(np.array(r_vect), 2))) + + # Small zone low grey level emphasis + glszm_features['Fszm_szlge'] = np.sum(np.sum(glszm*(np.power(1.0/r_mat, 2))*(np.power(1.0/c_mat, 2)))) + + # Small zone high grey level emphasis + glszm_features['Fszm_szhge'] = np.sum(np.sum(glszm*(np.power(r_mat, 2))*(np.power(1.0/c_mat, 2)))) + + # Large zone low grey levels emphasis + glszm_features['Fszm_lzlge'] = np.sum(np.sum(glszm*(np.power(1.0/r_mat, 2))*(np.power(c_mat, 2)))) + + # Large zone high grey level emphasis + glszm_features['Fszm_lzhge'] = np.sum(np.sum(glszm*(np.power(r_mat, 2))*(np.power(c_mat, 2)))) + + # Gray level non-uniformity + glszm_features['Fszm_glnu'] = np.sum(np.power(pg, 2)) * n_s + + # Gray level non-uniformity normalised + glszm_features['Fszm_glnu_norm'] = np.sum(np.power(pg, 2)) + + # Zone size non-uniformity + glszm_features['Fszm_zsnu'] = np.sum(np.power(pz, 2)) * n_s + + # Zone size non-uniformity normalised + glszm_features['Fszm_zsnu_norm'] = np.sum(np.power(pz, 2)) + + # Zone percentage + glszm_features['Fszm_z_perc'] = np.sum(pg)/(np.matmul(pz, np.transpose(c_vect))) + + # Grey level variance + temp = r_mat * glszm + u = np.sum(temp) + temp = (np.power(r_mat - u, 2)) * glszm + glszm_features['Fszm_gl_var'] = np.sum(temp) + + # Zone size variance + temp = c_mat * glszm + u = np.sum(temp) + temp = (np.power(c_mat - u, 2)) * glszm + glszm_features['Fszm_zs_var'] = np.sum(temp) + + # Zone size entropy + val_pos = glszm[np.nonzero(glszm)] + temp = val_pos * np.log2(val_pos) + glszm_features['Fszm_zs_entr'] = -np.sum(temp) + + return glszm_features + +def get_single_matrix(vol: np.ndarray) -> np.ndarray: + """Computes gray level size zone matrix in order to compute the single features. + + Args: + vol_int: 3D volume, isotropically resampled, + quantized (e.g. n_g = 32, levels = [1, ..., n_g]), + with NaNs outside the region of interest. + levels: Vector containing the quantized gray-levels + in the tumor region (or reconstruction ``levels`` of quantization). + + Returns: + ndarray: Array of Gray-Level Size Zone Matrix of 'vol'. + + """ + # Correct definition, without any assumption + vol = vol.copy() + levels = np.arange(1, np.max(vol[~np.isnan(vol[:])])+1) + + # GET THE gldzm MATRIX + glszm = get_matrix(vol, levels) + + return glszm + +def sze(glszm: np.ndarray) -> float: + """Computes small zone emphasis feature. + This feature refers to "Fszm_sze" (ID = 5QRC) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the small zone emphasis + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + sz = np.shape(glszm) # Size of glszm + + c_vect = range(1, sz[1]+1) # Row vectors + pz = np.sum(glszm, 0) # Zone Size Vector + + # Small zone emphasis + return (np.matmul(pz, np.transpose(np.power(1.0/np.array(c_vect), 2)))) + +def lze(glszm: np.ndarray) -> float: + """Computes large zone emphasis feature. + This feature refers to "Fszm_lze" (ID = 48P8) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the large zone emphasis + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + sz = np.shape(glszm) # Size of glszm + + c_vect = range(1, sz[1]+1) # Row vectors + pz = np.sum(glszm, 0) # Zone Size Vector + + # Large zone emphasis + return (np.matmul(pz, np.transpose(np.power(np.array(c_vect), 2)))) + +def lgze(glszm: np.ndarray) -> float: + """Computes low grey zone emphasis feature. + This feature refers to "Fszm_lgze" (ID = XMSY) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the low grey zone emphasis + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + sz = np.shape(glszm) # Size of glszm + + r_vect = range(1, sz[0]+1) # Column vectors + pg = np.transpose(np.sum(glszm, 1)) # Gray-Level Vector + + # Low grey zone emphasis + return np.matmul(pg, np.transpose(np.power( + 1.0/np.array(r_vect), 2))) + +def hgze(glszm: np.ndarray) -> float: + """Computes high grey zone emphasis feature. + This feature refers to "Fszm_hgze" (ID = 5GN9) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the high grey zone emphasis + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + sz = np.shape(glszm) # Size of glszm + + r_vect = range(1, sz[0]+1) # Column vectors + pg = np.transpose(np.sum(glszm, 1)) # Gray-Level Vector + + # High grey zone emphasis + return np.matmul(pg, np.transpose(np.power(np.array(r_vect), 2))) + +def szlge(glszm: np.ndarray) -> float: + """Computes small zone low grey level emphasis feature. + This feature refers to "Fszm_szlge" (ID = 5RAI) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the small zone low grey level emphasis + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + sz = np.shape(glszm) # Size of glszm + + c_vect = range(1, sz[1]+1) # Row vectors + r_vect = range(1, sz[0]+1) # Column vectors + # Column and row indicators for each entry of the glszm + c_mat, r_mat = np.meshgrid(c_vect, r_vect) + + # Small zone low grey level emphasis + return np.sum(np.sum(glszm*(np.power(1.0/r_mat, 2))*(np.power(1.0/c_mat, 2)))) + +def szhge(glszm: np.ndarray) -> float: + """Computes small zone high grey level emphasis feature. + This feature refers to "Fszm_szhge" (ID = HW1V) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the small zone high grey level emphasis + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + sz = np.shape(glszm) # Size of glszm + + c_vect = range(1, sz[1]+1) # Row vectors + r_vect = range(1, sz[0]+1) # Column vectors + # Column and row indicators for each entry of the glszm + c_mat, r_mat = np.meshgrid(c_vect, r_vect) + + # Small zone high grey level emphasis + return np.sum(np.sum(glszm*(np.power(r_mat, 2))*(np.power(1.0/c_mat, 2)))) + +def lzlge(glszm: np.ndarray) -> float: + """Computes large zone low grey level emphasis feature. + This feature refers to "Fszm_lzlge" (ID = YH51) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the large zone low grey level emphasis + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + sz = np.shape(glszm) # Size of glszm + + c_vect = range(1, sz[1]+1) # Row vectors + r_vect = range(1, sz[0]+1) # Column vectors + # Column and row indicators for each entry of the glszm + c_mat, r_mat = np.meshgrid(c_vect, r_vect) + + # Lage zone low grey level emphasis + return np.sum(np.sum(glszm*(np.power(1.0/r_mat, 2))*(np.power(c_mat, 2)))) + +def lzhge(glszm: np.ndarray) -> float: + """Computes large zone high grey level emphasis feature. + This feature refers to "Fszm_lzhge" (ID = J17V) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the large zone high grey level emphasis + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + sz = np.shape(glszm) # Size of glszm + + c_vect = range(1, sz[1]+1) # Row vectors + r_vect = range(1, sz[0]+1) # Column vectors + # Column and row indicators for each entry of the glszm + c_mat, r_mat = np.meshgrid(c_vect, r_vect) + + # Large zone high grey level emphasis + return np.sum(np.sum(glszm*(np.power(r_mat, 2))*(np.power(c_mat, 2)))) + +def glnu(glszm: np.ndarray) -> float: + """Computes grey level non-uniformity feature. + This feature refers to "Fszm_glnu" (ID = JNSA) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the grey level non-uniformity feature + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + n_s = np.sum(glszm) + + pg = np.transpose(np.sum(glszm, 1)) # Gray-Level Vector + + # Grey level non-uniformity feature + return np.sum(np.power(pg, 2)) * n_s + +def glnu_norm(glszm: np.ndarray) -> float: + """Computes grey level non-uniformity normalised + This feature refers to "Fszm_glnu_norm" (ID = Y1RO) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the grey level non-uniformity normalised feature + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + + pg = np.transpose(np.sum(glszm, 1)) # Gray-Level Vector + + # Grey level non-uniformity normalised feature + return np.sum(np.power(pg, 2)) + +def zsnu(glszm: np.ndarray) -> float: + """Computes zone size non-uniformity + This feature refers to "Fszm_zsnu" (ID = 4JP3) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the zone size non-uniformity feature + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + n_s = np.sum(glszm) + + pz = np.sum(glszm, 0) # Zone Size Vector + + # Zone size non-uniformity feature + return np.sum(np.power(pz, 2)) * n_s + +def zsnu_norm(glszm: np.ndarray) -> float: + """Computes zone size non-uniformity normalised + This feature refers to "Fszm_zsnu_norm" (ID = VB3A) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the zone size non-uniformity normalised feature + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + + pz = np.sum(glszm, 0) # Zone Size Vector + + # Zone size non-uniformity normalised feature + return np.sum(np.power(pz, 2)) + +def z_perc(glszm: np.ndarray) -> float: + """Computes zone percentage + This feature refers to "Fszm_z_perc" (ID = P30P) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the zone percentage feature + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + sz = np.shape(glszm) # Size of glszm + + c_vect = range(1, sz[1]+1) # Row vectors + pg = np.transpose(np.sum(glszm, 1)) # Gray-Level Vector + pz = np.sum(glszm, 0) # Zone Size Vector + + # Zone percentage feature + return np.sum(pg)/(np.matmul(pz, np.transpose(c_vect))) + +def gl_var(glszm: np.ndarray) -> float: + """Computes grey level variance + This feature refers to "Fszm_gl_var" (ID = BYLV) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the grey level variance feature + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + sz = np.shape(glszm) # Size of glszm + + c_vect = range(1, sz[1]+1) # Row vectors + r_vect = range(1, sz[0]+1) # Column vectors + # Column and row indicators for each entry of the glszm + _, r_mat = np.meshgrid(c_vect, r_vect) + + temp = r_mat * glszm + u = np.sum(temp) + temp = (np.power(r_mat - u, 2)) * glszm + + # Grey level variance feature + return np.sum(temp) + +def zs_var(glszm: np.ndarray) -> float: + """Computes zone size variance + This feature refers to "Fszm_zs_var" (ID = 3NSA) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the zone size variance feature + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + sz = np.shape(glszm) # Size of glszm + + c_vect = range(1, sz[1]+1) # Row vectors + r_vect = range(1, sz[0]+1) # Column vectors + # Column and row indicators for each entry of the glszm + c_mat, _ = np.meshgrid(c_vect, r_vect) + + temp = c_mat * glszm + u = np.sum(temp) + temp = (np.power(c_mat - u, 2)) * glszm + + # Zone size variance feature + return np.sum(temp) + +def zs_entr(glszm: np.ndarray) -> float: + """Computes zone size entropy + This feature refers to "Fszm_zs_entr" (ID = GU8N) in + the `IBSI1 reference manual `__. + + Args: + glszm (ndarray): array of the gray level size zone matrix + + Returns: + float: the zone size entropy feature + + """ + glszm = glszm/np.sum(glszm) # Normalization of glszm + + val_pos = glszm[np.nonzero(glszm)] + temp = val_pos * np.log2(val_pos) + + # Zone size entropy feature + return -np.sum(temp) diff --git a/MEDiml/biomarkers/int_vol_hist.py b/MEDiml/biomarkers/int_vol_hist.py new file mode 100644 index 0000000..a91c1bf --- /dev/null +++ b/MEDiml/biomarkers/int_vol_hist.py @@ -0,0 +1,527 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +from typing import Dict, Tuple + +import numpy as np + +from ..biomarkers.utils import find_i_x, find_v_x +from ..MEDscan import MEDscan + + +def init_ivh( + vol: np.ndarray, + vol_int_re: np.ndarray, + wd: int, + ivh: Dict = None, + im_range: np.ndarray = None, + medscan: MEDscan = None + ) -> Tuple[np.ndarray, np.ndarray, int, int]: + """Computes Intensity-volume Histogram Features. + + Note: + For the input volume: + + - Naturally discretised volume can be kept as it is (e.g. HU values of CT scans) + - All other volumes with continuous intensity distribution should be \ + quantized (e.g., nBins = 100), with levels = [min, ..., max] + + Args: + vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest + vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest + wd (int): Discretisation width. + ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). + im_range (ndarray, optional): The intensity range. + medscan (MEDscan, optional): MEDscan instance containing processing parameters. + + Returns: + Dict: Dict of the Intensity Histogram Features. + """ + try: + # Retrieve relevant parameters from MEDscan instance. + if medscan is not None: + ivh = medscan.params.process.ivh + im_range = medscan.params.process.im_range + elif ivh is None or im_range is None: + raise ValueError('MEDscan instance or ivh and im_range must be provided.') + + # Initialize relevant parameters. + user_set_range = [] + if ivh and 'type' in ivh: + # PET example case (definite intensity units -- continuous case) + if ivh['type'] == 'FBS' or ivh['type'] == 'FBSequal': + range_fbs = [0, 0] + if not im_range: + range_fbs[0] = np.nanmin(vol_int_re) + range_fbs[1] = np.nanmax(vol_int_re) + else: + if im_range[0] == -np.inf: + range_fbs[0] = np.nanmin(vol_int_re) + else: + range_fbs[0] = im_range[0] + if im_range[1] == np.inf: + range_fbs[1] = np.nanmax(vol_int_re) + else: + range_fbs[1] = im_range[1] + # In this case, wd = wb (see discretisation.m) + range_fbs[0] = range_fbs[0] + 0.5*wd + # In this case, wd = wb (see discretisation.m) + range_fbs[1] = range_fbs[1] - 0.5*wd + user_set_range = range_fbs + + else: # MRI example case (arbitrary intensity units) + user_set_range = None + + else: # CT example case (definite intensity units -- discrete case) + user_set_range = im_range + + # INITIALIZATION + X = vol[~np.isnan(vol[:])] + + if (vol is not None) & (wd is not None) & (user_set_range is not None): + if user_set_range: + min_val = user_set_range[0] + max_val = user_set_range[1] + else: + min_val = np.min(X) + max_val = np.max(X) + else: + min_val = np.min(X) + max_val = np.max(X) + + if max_val == np.inf: + max_val = np.max(X) + + if min_val == -np.inf: + min_val = np.min(X) + + # Vector of grey-levels. + # Values are generated within the half-open interval [min_val,max_val+wd) + levels = np.arange(min_val, max_val + wd, wd) + n_g = levels.size + n_v = X.size + + except Exception as e: + print('PROBLEM WITH INITIALIZATION OF INTENSITY-VOLUME HISTOGRAM PARAMETERS \n {e}') + + return X, levels, n_g, n_v + +def extract_all( + vol: np.ndarray, + vol_int_re: np.ndarray, + wd: int, + ivh: Dict = None, + im_range: np.ndarray = None, + medscan: MEDscan = None + ) -> Dict: + """Computes Intensity-volume Histogram Features. + This features refer to Intensity-volume histogram family in + the `IBSI1 reference manual `__. + + Note: + For the input volume, naturally discretised volume can be kept as it is (e.g. HU values of CT scans). + All other volumes with continuous intensity distribution should be + quantized (e.g., nBins = 100), with levels = [min, ..., max] + + Args: + vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest + vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest + wd (int): Discretisation width. + ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). + im_range (ndarray, optional): The intensity range. + medscan (MEDscan, optional): MEDscan instance containing processing parameters. + + Returns: + Dict: Dict of the Intensity Histogram Features. + """ + try: + # Initialization of final structure (Dictionary) containing all features. + int_vol_hist = { + 'Fivh_V10': [], + 'Fivh_V90': [], + 'Fivh_I10': [], + 'Fivh_I90': [], + 'Fivh_V10minusV90': [], + 'Fivh_I10minusI90': [], + 'Fivh_auc': [] + } + + # Retrieve relevant parameters + X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) + + # Calculating fractional volume + fract_vol = np.zeros(n_g) + for i in range(0, n_g): + fract_vol[i] = 1 - np.sum(X < levels[i])/n_v + + # Calculating intensity fraction + fract_int = (levels - np.min(levels)) / (np.max(levels) - np.min(levels)) + + # Volume at intensity fraction 10 + v10 = find_v_x(fract_int, fract_vol, 10) + int_vol_hist['Fivh_V10'] = v10 + + # Volume at intensity fraction 90 + v90 = find_v_x(fract_int, fract_vol, 90) + int_vol_hist['Fivh_V90'] = v90 + + # Intensity at volume fraction 10 + # For initial arbitrary intensities, + # we will always be discretising (1000 bins). + # So intensities are definite here. + i10 = find_i_x(levels, fract_vol, 10) + int_vol_hist['Fivh_I10'] = i10 + + # Intensity at volume fraction 90 + # For initial arbitrary intensities, + # we will always be discretising (1000 bins). + # So intensities are definite here. + i90 = find_i_x(levels, fract_vol, 90) + int_vol_hist['Fivh_I90'] = i90 + + # Volume at intensity fraction difference v10-v90 + int_vol_hist['Fivh_V10minusV90'] = v10 - v90 + + # Intensity at volume fraction difference i10-i90 + # For initial arbitrary intensities, + # we will always be discretising (1000 bins). + # So intensities are definite here. + int_vol_hist['Fivh_I10minusI90'] = i10 - i90 + + # Area under IVH curve + int_vol_hist['Fivh_auc'] = np.trapz(fract_vol) / (n_g - 1) + + except Exception as e: + message = f'PROBLEM WITH COMPUTATION OF INTENSITY-VOLUME HISTOGRAM FEATURES \n {e}' + if medscan is not None: + medscan.radiomics.image['intVolHist_3D'][medscan.params.radiomics.ivh_name].update( + {'error': 'ERROR_COMPUTATION'}) + logging.error(message) + print(message) + + return int_vol_hist + + return int_vol_hist + +def v10( + vol: np.ndarray, + vol_int_re: np.ndarray, + wd: int, + ivh: Dict = None, + im_range: np.ndarray = None, + medscan: MEDscan = None + ) -> float: + """Computes Volume at intensity fraction 10 feature. + This feature refers to "Fivh_V10" (ID = BC2M) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest + vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest + wd (int): Discretisation width. + ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). + im_range (ndarray, optional): The intensity range. + medscan (MEDscan, optional): MEDscan instance containing processing parameters. + + Returns: + float: Volume at intensity fraction 10 feature. + """ + try: + # Retrieve relevant parameters from init_ivh() method. + X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) + + # Calculating fractional volume + fract_vol = np.zeros(n_g) + for i in range(0, n_g): + fract_vol[i] = 1 - np.sum(X < levels[i]) / n_v + + # Calculating intensity fraction + fract_int = (levels - np.min(levels))/(np.max(levels) - np.min(levels)) + + # Volume at intensity fraction 10 + v10 = find_v_x(fract_int, fract_vol, 10) + + except Exception as e: + print(f'PROBLEM WITH COMPUTATION OF V10 FEATURE \n {e}') + return None + + return v10 + +def v90( + vol: np.ndarray, + vol_int_re: np.ndarray, + wd: int, + ivh: Dict = None, + im_range: np.ndarray = None, + medscan: MEDscan = None + ) -> float: + """Computes Volume at intensity fraction 90 feature. + This feature refers to "Fivh_V90" (ID = BC2M) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest + vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest + wd (int): Discretisation width. + ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). + im_range (ndarray, optional): The intensity range. + medscan (MEDscan, optional): MEDscan instance containing processing parameters. + + Returns: + float: Volume at intensity fraction 90 feature. + """ + try: + # Retrieve relevant parameters from init_ivh() method. + X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) + + # Calculating fractional volume + fract_vol = np.zeros(n_g) + for i in range(0, n_g): + fract_vol[i] = 1 - np.sum(X < levels[i]) / n_v + + # Calculating intensity fraction + fract_int = (levels - np.min(levels)) / (np.max(levels) - np.min(levels)) + + # Volume at intensity fraction 90 + v90 = find_v_x(fract_int, fract_vol, 90) + + except Exception as e: + print(f'PROBLEM WITH COMPUTATION OF V90 FEATURE \n {e}') + return None + + return v90 + +def i10( + vol: np.ndarray, + vol_int_re: np.ndarray, + wd: int, + ivh: Dict = None, + im_range: np.ndarray = None, + medscan: MEDscan = None + ) -> float: + """Computes Intensity at volume fraction 10 feature. + This feature refers to "Fivh_I10" (ID = GBPN) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest + vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest + wd (int): Discretisation width. + ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). + im_range (ndarray, optional): The intensity range. + medscan (MEDscan, optional): MEDscan instance containing processing parameters. + + Returns: + float: Intensity at volume fraction 10 feature. + """ + try: + # Retrieve relevant parameters from init_ivh() method. + X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) + + # Calculating fractional volume + fract_vol = np.zeros(n_g) + for i in range(0, n_g): + fract_vol[i] = 1 - np.sum(X < levels[i]) / n_v + + # Intensity at volume fraction 10 + # For initial arbitrary intensities, + # we will always be discretising (1000 bins). + # So intensities are definite here. + i10 = find_i_x(levels, fract_vol, 10) + + except Exception as e: + print(f'PROBLEM WITH COMPUTATION OF I10 FEATURE \n {e}') + return None + + return i10 + +def i90( + vol: np.ndarray, + vol_int_re: np.ndarray, + wd: int, + ivh: Dict = None, + im_range: np.ndarray = None, + medscan: MEDscan = None + ) -> float: + """Computes Intensity at volume fraction 90 feature. + This feature refers to "Fivh_I90" (ID = GBPN) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest + vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest + wd (int): Discretisation width. + ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). + im_range (ndarray, optional): The intensity range. + medscan (MEDscan, optional): MEDscan instance containing processing parameters. + + Returns: + float: Intensity at volume fraction 90 feature. + """ + try: + # Retrieve relevant parameters from init_ivh() method. + X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) + + # Calculating fractional volume + fract_vol = np.zeros(n_g) + for i in range(0, n_g): + fract_vol[i] = 1 - np.sum(X < levels[i]) / n_v + + # Intensity at volume fraction 90 + # For initial arbitrary intensities, + # we will always be discretising (1000 bins). + # So intensities are definite here. + i90 = find_i_x(levels, fract_vol, 90) + + except Exception as e: + print(f'PROBLEM WITH COMPUTATION OF I90 FEATURE \n {e}') + return None + + return i90 + +def v10_minus_v90( + vol: np.ndarray, + vol_int_re: np.ndarray, + wd: int, + ivh: Dict = None, + im_range: np.ndarray = None, + medscan: MEDscan = None + ) -> float: + """Computes Volume at intensity fraction difference v10-v90 + This feature refers to "Fivh_V10minusV90" (ID = DDTU) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest + vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest + wd (int): Discretisation width. + ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). + im_range (ndarray, optional): The intensity range. + medscan (MEDscan, optional): MEDscan instance containing processing parameters. + + Returns: + float: Volume at intensity fraction difference v10-v90 feature. + """ + try: + # Retrieve relevant parameters from init_ivh() method. + X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) + + # Calculating fractional volume + fract_vol = np.zeros(n_g) + for i in range(0, n_g): + fract_vol[i] = 1 - np.sum(X < levels[i]) / n_v + + # Calculating intensity fraction + fract_int = (levels - np.min(levels)) / (np.max(levels) - np.min(levels)) + + # Volume at intensity fraction 10 + v10 = find_v_x(fract_int, fract_vol, 10) + + # Volume at intensity fraction 90 + v90 = find_v_x(fract_int, fract_vol, 90) + + except Exception as e: + print(f'PROBLEM WITH COMPUTATION OF V10minusV90 FEATURE \n {e}') + return None + + return v10 - v90 + +def i10_minus_i90( + vol: np.ndarray, + vol_int_re: np.ndarray, + wd: int, + ivh: Dict = None, + im_range: np.ndarray = None, + medscan: MEDscan = None + ) -> float: + """Computes Intensity at volume fraction difference i10-i90 + This feature refers to "Fivh_I10minusI90" (ID = CNV2) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest + vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest + wd (int): Discretisation width. + ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). + im_range (ndarray, optional): The intensity range. + medscan (MEDscan, optional): MEDscan instance containing processing parameters. + + Returns: + float: Intensity at volume fraction difference i10-i90 feature. + """ + try: + # Retrieve relevant parameters from init_ivh() method. + X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) + + # Calculating fractional volume + fract_vol = np.zeros(n_g) + for i in range(0, n_g): + fract_vol[i] = 1 - np.sum(X < levels[i]) / n_v + + # Intensity at volume fraction 10 + # For initial arbitrary intensities, + # we will always be discretising (1000 bins). + # So intensities are definite here. + i10 = find_i_x(levels, fract_vol, 10) + + # Intensity at volume fraction 90 + # For initial arbitrary intensities, + # we will always be discretising (1000 bins). + # So intensities are definite here. + i90 = find_i_x(levels, fract_vol, 90) + + except Exception as e: + print(f'PROBLEM WITH COMPUTATION OF I10minusI90 FEATURE \n {e}') + return None + + return i10 - i90 + +def auc( + vol: np.ndarray, + vol_int_re: np.ndarray, + wd: int, + ivh: Dict = None, + im_range: np.ndarray = None, + medscan: MEDscan = None + ) -> float: + """ + Computes Area under IVH curve. + This feature refers to "Fivh_auc" (ID = 9CMM) in + the `IBSI1 reference manual `__. + + Note: + For the input volume: + + * Naturally discretised volume can be kept as it is (e.g. HU values of CT scans) + * All other volumes with continuous intensity distribution should be \ + quantized (e.g., nBins = 100), with levels = [min, ..., max] + + Args: + vol(ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest + vol_int_re(ndarray): 3D volume, with NaNs outside the region of interest + wd(int): Discretisation width. + ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). + im_range (ndarray, optional): The intensity range. + medscan (MEDscan, optional): MEDscan instance containing processing parameters. + + Returns: + float: Area under IVH curve feature. + """ + try: + # Retrieve relevant parameters from init_ivh() method. + X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) + + # Calculating fractional volume + fract_vol = np.zeros(n_g) + for i in range(0, n_g): + fract_vol[i] = 1 - np.sum(X < levels[i]) / n_v + + # Area under IVH curve + auc = np.trapz(fract_vol) / (n_g - 1) + + except Exception as e: + print(f'PROBLEM WITH COMPUTATION OF AUC FEATURE \n {e}') + return None + + return auc diff --git a/MEDiml/biomarkers/intensity_histogram.py b/MEDiml/biomarkers/intensity_histogram.py new file mode 100644 index 0000000..2c8da64 --- /dev/null +++ b/MEDiml/biomarkers/intensity_histogram.py @@ -0,0 +1,615 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import warnings +from typing import Dict, Tuple + +import numpy as np +from scipy.stats import scoreatpercentile, variation + + +def init_IH(vol: np.ndarray) -> Tuple[np.ndarray, np.ndarray, int, np.ndarray, np.ndarray]: + """Initialize Intensity Histogram Features. + + Args: + vol (ndarray): 3D volume, QUANTIZED (e.g. nBins = 100, + levels = [1, ..., max]), with NaNs outside the region of interest. + + Returns: + Dict: Dict of the Intensity Histogram Features. + """ + warnings.simplefilter("ignore") + + # INITIALIZATION + + x = vol[~np.isnan(vol[:])] + n_v = x.size + + # CONSTRUCTION OF HISTOGRAM AND ASSOCIATED NUMBER OF GRAY-LEVELS + + # Always defined from 1 to the maximum value of + # the volume to remove any ambiguity + levels = np.arange(1, np.max(x) + 100*np.finfo(float).eps) + n_g = levels.size # Number of gray-levels + h = np.zeros(n_g) # The histogram of x + + for i in np.arange(0, n_g): + # == i or == levels(i) is equivalent since levels = 1:max(x), + # and n_g = numel(levels) + h[i] = np.sum(x == i + 1) # h[i] = sum(x == i+1) + + p = (h / n_v) # Occurence probability for each grey level bin i + pt = p.transpose() + + return x, levels, n_g, h, p, pt + +def extract_all(vol: np.ndarray) -> Dict: + """Computes Intensity Histogram Features. + These features refer to Intensity histogram family in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, QUANTIZED (e.g. nBins = 100, + levels = [1, ..., max]), with NaNs outside the region of interest. + + Returns: + Dict: Dict of the Intensity Histogram Features. + """ + warnings.simplefilter("ignore") + + # INITIALIZATION + x, levels, n_g, h, p, pt = init_IH(vol) + + # Initialization of final structure (Dictionary) containing all features. + int_hist = {'Fih_mean': [], + 'Fih_var': [], + 'Fih_skew': [], + 'Fih_kurt': [], + 'Fih_median': [], + 'Fih_min': [], + 'Fih_P10': [], + 'Fih_P90': [], + 'Fih_max': [], + 'Fih_mode': [], + 'Fih_iqr': [], + 'Fih_range': [], + 'Fih_mad': [], + 'Fih_rmad': [], + 'Fih_medad': [], + 'Fih_cov': [], + 'Fih_qcod': [], + 'Fih_entropy': [], + 'Fih_uniformity': [], + 'Fih_max_grad': [], + 'Fih_max_grad_gl': [], + 'Fih_min_grad': [], + 'Fih_min_grad_gl': [] + } + + # STARTING COMPUTATION + # Intensity histogram mean + u = np.matmul(levels, pt) + int_hist['Fih_mean'] = u + + # Intensity histogram variance + var = np.matmul(np.power(levels - u, 2), pt) + int_hist['Fih_var'] = var + + # Intensity histogram skewness and kurtosis + skew = 0 + kurt = 0 + + if var != 0: + skew = np.matmul(np.power(levels - u, 3), pt) / np.power(var, 3/2) + kurt = np.matmul(np.power(levels - u, 4), pt) / np.power(var, 2) - 3 + + int_hist['Fih_skew'] = skew + int_hist['Fih_kurt'] = kurt + + # Intensity histogram median + int_hist['Fih_median'] = np.median(x) + + # Intensity histogram minimum grey level + int_hist['Fih_min'] = np.min(x) + + # Intensity histogram 10th percentile + p10 = scoreatpercentile(x, 10) + int_hist['Fih_P10'] = p10 + + # Intensity histogram 90th percentile + p90 = scoreatpercentile(x, 90) + int_hist['Fih_P90'] = p90 + + # Intensity histogram maximum grey level + int_hist['Fih_max'] = np.max(x) + + # Intensity histogram mode + # levels = 1:max(x), so the index of the ith bin of h is the same as i + mh = np.max(h) + mode = np.where(h == mh)[0] + 1 + + if np.size(mode) > 1: + dist = np.abs(mode - u) + ind_min = np.argmin(dist) + int_hist['Fih_mode'] = mode[ind_min] + else: + int_hist['Fih_mode'] = mode[0] + + # Intensity histogram interquantile range + # Since x goes from 1:max(x), all with integer values, + # the result is an integer + int_hist['Fih_iqr'] = scoreatpercentile(x, 75) - scoreatpercentile(x, 25) + + # Intensity histogram range + int_hist['Fih_range'] = np.max(x) - np.min(x) + + # Intensity histogram mean absolute deviation + int_hist['Fih_mad'] = np.mean(abs(x - u)) + + # Intensity histogram robust mean absolute deviation + x_10_90 = x[np.where((x >= p10) & (x <= p90), True, False)] + int_hist['Fih_rmad'] = np.mean(np.abs(x_10_90 - np.mean(x_10_90))) + + # Intensity histogram median absolute deviation + int_hist['Fih_medad'] = np.mean(np.absolute(x - np.median(x))) + + # Intensity histogram coefficient of variation + int_hist['Fih_cov'] = variation(x) + + # Intensity histogram quartile coefficient of dispersion + x_75_25 = scoreatpercentile(x, 75) + scoreatpercentile(x, 25) + int_hist['Fih_qcod'] = int_hist['Fih_iqr'] / x_75_25 + + # Intensity histogram entropy + p = p[p > 0] + int_hist['Fih_entropy'] = -np.sum(p * np.log2(p)) + + # Intensity histogram uniformity + int_hist['Fih_uniformity'] = np.sum(np.power(p, 2)) + + # Calculation of histogram gradient + hist_grad = np.zeros(n_g) + hist_grad[0] = h[1] - h[0] + hist_grad[-1] = h[-1] - h[-2] + + for i in np.arange(1, n_g-1): + hist_grad[i] = (h[i+1] - h[i-1])/2 + + # Maximum histogram gradient + int_hist['Fih_max_grad'] = np.max(hist_grad) + + # Maximum histogram gradient grey level + ind_max = np.where(hist_grad == int_hist['Fih_max_grad'])[0][0] + int_hist['Fih_max_grad_gl'] = levels[ind_max] + + # Minimum histogram gradient + int_hist['Fih_min_grad'] = np.min(hist_grad) + + # Minimum histogram gradient grey level + ind_min = np.where(hist_grad == int_hist['Fih_min_grad'])[0][0] + int_hist['Fih_min_grad_gl'] = levels[ind_min] + + return int_hist + +def mean(vol: np.ndarray) -> float: + """Compute Intensity histogram mean feature of the input dataset (3D Array). + This feature refers to "Fih_mean" (ID = X6K6) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Intensity histogram mean + """ + _, levels, _, _, _, pt = init_IH(vol) # Initialization + + return np.matmul(levels, pt) # Intensity histogram mean + +def var(vol: np.ndarray) -> float: + """Compute Intensity histogram variance feature of the input dataset (3D Array). + This feature refers to "Fih_var" (ID = CH89) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Intensity histogram variance + """ + _, levels, _, _, _, pt = init_IH(vol) # Initialization + u = np.matmul(levels, pt) # Intensity histogram mean + + return np.matmul(np.power(levels - u, 2), pt) # Intensity histogram variance + +def skewness(vol: np.ndarray) -> float: + """Compute Intensity histogram skewness feature of the input dataset (3D Array). + This feature refers to "Fih_skew" (ID = 88K1) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Intensity histogram skewness. + """ + _, levels, _, _, _, pt = init_IH(vol) # Initialization + u = np.matmul(levels, pt) # Intensity histogram mean + var = np.matmul(np.power(levels - u, 2), pt) # Intensity histogram variance + if var != 0: + skew = np.matmul(np.power(levels - u, 3), pt) / np.power(var, 3/2) + + return skew # Skewness + +def kurt(vol: np.ndarray) -> float: + """Compute Intensity histogram kurtosis feature of the input dataset (3D Array). + This feature refers to "Fih_kurt" (ID = C3I7) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: The Intensity histogram kurtosis feature + """ + _, levels, _, _, _, pt = init_IH(vol) # Initialization + u = np.matmul(levels, pt) # Intensity histogram mean + var = np.matmul(np.power(levels - u, 2), pt) # Intensity histogram variance + if var != 0: + kurt = np.matmul(np.power(levels - u, 4), pt) / np.power(var, 2) - 3 + + return kurt # Kurtosis + +def median(vol: np.ndarray) -> float: + """Compute Intensity histogram median feature along the specified axis of the input dataset (3D Array). + This feature refers to "Fih_median" (ID = WIFQ) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Intensity histogram median feature. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.median(x) # Median + +def min(vol: np.ndarray) -> float: + """Compute Intensity histogram minimum grey level feature of the input dataset (3D Array). + This feature refers to "Fih_min" (ID = 1PR8) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Intensity histogram minimum grey level feature. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.min(x) # Minimum grey level + +def p10(vol: np.ndarray) -> float: + """Compute Intensity histogram 10th percentile feature of the input dataset (3D Array). + This feature refers to "Fih_P10" (ID = GPMT) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Intensity histogram 10th percentile feature. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return scoreatpercentile(x, 10) # 10th percentile + +def p90(vol: np.ndarray) -> float: + """Compute Intensity histogram 90th percentile feature of the input dataset (3D Array). + This feature refers to "Fih_P90" (ID = OZ0C) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + Returns: + float: Intensity histogram 90th percentile feature. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return scoreatpercentile(x, 90) # 90th percentile + +def max(vol: np.ndarray) -> float: + """Compute Intensity histogram maximum grey level feature of the input dataset (3D Array). + This feature refers to "Fih_max" (ID = 3NCY) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Intensity histogram maximum grey level feature. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.max(x) # Maximum grey level + +def mode(vol: np.ndarray) -> int: + """Compute Intensity histogram mode feature of the input dataset (3D Array). + This feature refers to "Fih_mode" (ID = AMMC) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + integer: Intensity histogram mode. + levels = 1:max(x), so the index of the ith bin of h is the same as i + """ + _, levels, _, h, _, pt = init_IH(vol) # Initialization + u = np.matmul(levels, pt) + mh = np.max(h) + mode = np.where(h == mh)[0] + 1 + + if np.size(mode) > 1: + dist = np.abs(mode - u) + ind_min = np.argmin(dist) + + return mode[ind_min] # Intensity histogram mode. + else: + + return mode[0] # Intensity histogram mode. + +def iqrange(vol: np.ndarray) -> float: + r"""Compute Intensity histogram interquantile range feature of the input dataset (3D Array). + This feature refers to "Fih_iqr" (ID = WR0O) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Interquartile range. If :math:`axis ≠ None` , the output data-type is the same as that of the input. + Since x goes from :math:`1:max(x)` , all with integer values, the result is an integer. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return scoreatpercentile(x, 75) - scoreatpercentile(x, 25) # Intensity histogram interquantile range + +def range(vol: np.ndarray) -> float: + """Compute Intensity histogram range of values (maximum - minimum) feature of the input dataset (3D Array). + This feature refers to "Fih_range" (ID = 5Z3W) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Intensity histogram range. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.max(x) - np.min(x) # Intensity histogram range + +def mad(vol: np.ndarray) -> float: + """Compute Intensity histogram mean absolute deviation feature of the input dataset (3D Array). + This feature refers to "Fih_mad" (ID = D2ZX) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float : Intensity histogram mean absolute deviation feature. + """ + x, levels, _, _, _, pt = init_IH(vol) # Initialization + u = np.matmul(levels, pt) + + return np.mean(abs(x - u)) # Intensity histogram mean absolute deviation + +def rmad(vol: np.ndarray) -> float: + """Compute Intensity histogram robust mean absolute deviation feature of the input dataset (3D Array). + This feature refers to "Fih_rmad" (ID = WRZB) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + P10(ndarray): Score at 10th percentil. + P90(ndarray): Score at 90th percentil. + + Returns: + float: Intensity histogram robust mean absolute deviation + """ + x = vol[~np.isnan(vol[:])] # Initialization + P10 = scoreatpercentile(x, 10) # 10th percentile + P90 = scoreatpercentile(x, 90) # 90th percentile + x_10_90 = x[np.where((x >= P10) & + (x <= P90), True, False)] # Holding x for (x >= P10) and (x<= P90) + + return np.mean(np.abs(x_10_90 - np.mean(x_10_90))) # Intensity histogram robust mean absolute deviation + +def medad(vol: np.ndarray) -> float: + """Intensity histogram median absolute deviation feature of the input dataset (3D Array). + This feature refers to "Fih_medad" (ID = 4RNL) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Intensity histogram median absolute deviation feature. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.mean(np.absolute(x - np.median(x))) # Intensity histogram median absolute deviation + +def cov(vol: np.ndarray) -> float: + """Compute Intensity histogram coefficient of variation feature of the input dataset (3D Array). + This feature refers to "Fih_cov" (ID = CWYJ) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Intensity histogram coefficient of variation feature. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return variation(x) # Intensity histogram coefficient of variation + +def qcod(vol: np.ndarray) -> float: + """Compute the quartile coefficient of dispersion feature of the input dataset (3D Array). + This feature refers to "Fih_qcod" (ID = SLWD) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + ndarray: A new array holding the quartile coefficient of dispersion feature. + """ + x = vol[~np.isnan(vol[:])] # Initialization + x_75_25 = scoreatpercentile(x, 75) + scoreatpercentile(x, 25) + + return iqrange(x) / x_75_25 # Quartile coefficient of dispersion + +def entropy(vol: np.ndarray) -> float: + """Compute Intensity histogram entropy feature of the input dataset (3D Array). + This feature refers to "Fih_entropy" (ID = TLU2) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Intensity histogram entropy feature. + """ + x, _, _, _, p, _ = init_IH(vol) # Initialization + p = p[p > 0] + + return -np.sum(p * np.log2(p)) # Intensity histogram entropy + +def uniformity(vol: np.ndarray) -> float: + """Compute Intensity histogram uniformity feature of the input dataset (3D Array). + This feature refers to "Fih_uniformity" (ID = BJ5W) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Intensity histogram uniformity feature. + """ + x, _, _, _, p, _ = init_IH(vol) # Initialization + p = p[p > 0] + + return np.sum(np.power(p, 2)) # Intensity histogram uniformity + +def hist_grad_calc(vol: np.ndarray) -> np.ndarray: + """Calculation of histogram gradient. + This feature refers to "Fih_hist_grad_calc" (ID = 12CE) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + ndarray: Histogram gradient + """ + _, _, n_g, h, _, _ = init_IH(vol) # Initialization + hist_grad = np.zeros(n_g) + hist_grad[0] = h[1] - h[0] + hist_grad[-1] = h[-1] - h[-2] + for i in np.arange(1, n_g-1): + hist_grad[i] = (h[i+1] - h[i-1])/2 + + return hist_grad # Intensity histogram uniformity + +def max_grad(vol: np.ndarray) -> float: + """Calculation of Maximum histogram gradient feature. + This feature refers to "Fih_max_grad" (ID = 12CE) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Maximum histogram gradient feature. + """ + hist_grad = hist_grad_calc(vol) # Initialization + + return np.max(hist_grad) # Maximum histogram gradient + +def max_grad_gl(vol: np.ndarray) -> float: + """Calculation of Maximum histogram gradient grey level feature. + This feature refers to "Fih_max_grad_gl" (ID = 8E6O) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Maximum histogram gradient grey level feature. + """ + _, levels, _, _, _, _ = init_IH(vol) # Initialization + hist_grad = hist_grad_calc(vol) + ind_max = np.where(hist_grad == np.max(hist_grad))[0][0] + + return levels[ind_max] # Maximum histogram gradient grey level + +def min_grad(vol: np.ndarray) -> float: + """Calculation of Minimum histogram gradient feature. + This feature refers to "Fih_min_grad" (ID = VQB3) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Minimum histogram gradient feature. + """ + hist_grad = hist_grad_calc(vol) # Initialization + + return np.min(hist_grad) # Minimum histogram gradient + +def min_grad_gl(vol: np.ndarray) -> float: + """Calculation of Minimum histogram gradient grey level feature. + This feature refers to "Fih_min_grad_gl" (ID = RHQZ) in + the `IBSI1 reference manual `__. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Minimum histogram gradient grey level feature. + """ + _, levels, _, _, _, _ = init_IH(vol) # Initialization + hist_grad = hist_grad_calc(vol) + ind_min = np.where(hist_grad == np.min(hist_grad))[0][0] + + return levels[ind_min] # Minimum histogram gradient grey level diff --git a/MEDiml/biomarkers/local_intensity.py b/MEDiml/biomarkers/local_intensity.py new file mode 100644 index 0000000..13d809a --- /dev/null +++ b/MEDiml/biomarkers/local_intensity.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from typing import Dict + +from numpy import ndarray + +from ..biomarkers.utils import get_glob_peak, get_loc_peak + + +def extract_all(img_obj: ndarray, + roi_obj: ndarray, + res: ndarray, + intensity_type: str, + compute_global: bool = False) -> Dict: + """Compute Local Intensity Features. + This features refer to Local Intensity family in + the `IBSI1 reference manual `__. + + Args: + img_obj (ndarray): Continuous image intensity distribution, with no NaNs + outside the ROI. + roi_obj (ndarray): Array of the mask defining the ROI. + res (List[float]): [a,b,c] vector specifying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + intensity_type (str): Type of intensity to compute. Can be "arbitrary", "definite" or "filtered". + Will compute features only for "definite" intensity type. + compute_global (bool, optional): If True, will compute global intensity peak, we + recommend you don't set it to True if not necessary in your study or analysis as it + takes too much time for calculation. Default: False. + + Returns: + Dict: Dict of the Local Intensity Features. + + Raises: + ValueError: If `intensity_type` is not "arbitrary", "definite" or "filtered". + """ + assert intensity_type in ["arbitrary", "definite", "filtered"], \ + "intensity_type must be 'arbitrary', 'definite' or 'filtered'" + + loc_int = {'Floc_peak_local': [], 'Floc_peak_global': []} + + if intensity_type == "definite": + loc_int['Floc_peak_local'] = (get_loc_peak(img_obj, roi_obj, res)) + + # NEEDS TO BE VECTORIZED FOR FASTER CALCULATION! OR + # SIMPLY JUST CONVOLUTE A 3D AVERAGING FILTER! + if compute_global: + loc_int['Floc_peak_global'] = (get_glob_peak(img_obj,roi_obj, res)) + + return loc_int + +def peak_local(img_obj: ndarray, + roi_obj: ndarray, + res: ndarray) -> float: + """Computes local intensity peak. + This feature refers to "Floc_peak_local" (ID = VJGA) in + the `IBSI1 reference manual `__. + + Args: + img_obj (ndarray): Continuous image intensity distribution, with no NaNs + outside the ROI. + roi_obj (ndarray): Array of the mask defining the ROI. + res (List[float]): [a,b,c] vector specifying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Local intensity peak. + """ + return get_loc_peak(img_obj, roi_obj, res) + +def peak_global(img_obj: ndarray, + roi_obj: ndarray, + res: ndarray) -> float: + """Computes global intensity peak. + This feature refers to "Floc_peak_global" (ID = 0F91) in + the `IBSI1 reference manual `__. + + Args: + img_obj (ndarray): Continuous image intensity distribution, with no NaNs + outside the ROI. + roi_obj (ndarray): Array of the mask defining the ROI. + res (List[float]): [a,b,c] vector specifying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Global intensity peak. + """ + return get_glob_peak(img_obj, roi_obj, res) diff --git a/MEDiml/biomarkers/morph.py b/MEDiml/biomarkers/morph.py new file mode 100644 index 0000000..35cda15 --- /dev/null +++ b/MEDiml/biomarkers/morph.py @@ -0,0 +1,1756 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import copy +import logging +from typing import Dict, List, Tuple, Union + +import numpy as np +import pandas as pd +import scipy.spatial as sc +from scipy.spatial import ConvexHull +from skimage.measure import marching_cubes + +from ..biomarkers.get_oriented_bound_box import rot_matrix, sig_proc_find_peaks + + +def get_mesh(mask: np.ndarray, + res: Union[np.ndarray, List]) -> Tuple[np.ndarray, + np.ndarray, + np.ndarray]: + """Compute Mesh. + + Note: + Make sure the `mask` is padded with a layer of 0's in all + dimensions to reduce potential isosurface computation errors. + + Args: + mask (ndarray): Contains only 0's and 1's. + res (ndarray or List): [a,b,c] vector specifying the resolution of the volume in mm. + xyz resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + Tuple[np.ndarray, np.ndarray, np.ndarray]: + - Array of the [X,Y,Z] positions of the ROI. + - Array of the spatial coordinates for `mask` unique mesh vertices. + - Array of triangular faces via referencing vertex indices from vertices. + """ + # Getting the grid of X,Y,Z positions, where the coordinate reference + # system (0,0,0) is located at the upper left corner of the first voxel + # (-0.5: half a voxel distance). For the whole volume defining the mask, + # no matter if it is a 1 or a 0. + mask = mask.copy() + res = res.copy() + + x = res[0]*((np.arange(1, np.shape(mask)[0]+1))-0.5) + y = res[1]*((np.arange(1, np.shape(mask)[1]+1))-0.5) + z = res[2]*((np.arange(1, np.shape(mask)[2]+1))-0.5) + X, Y, Z = np.meshgrid(x, y, z, indexing='ij') + + # Getting the isosurface of the mask + vertices, faces, _, _ = marching_cubes(volume=mask, level=0.5, spacing=res) + + # Getting the X,Y,Z positions of the ROI (i.e. 1's) of the mask + X = np.reshape(X, (np.size(X), 1), order='F') + Y = np.reshape(Y, (np.size(Y), 1), order='F') + Z = np.reshape(Z, (np.size(Z), 1), order='F') + + xyz = np.concatenate((X, Y, Z), axis=1) + xyz = xyz[np.where(np.reshape(mask, np.size(mask), order='F') == 1)[0], :] + + return xyz, faces, vertices + +def get_com(xgl_int: np.ndarray, + xgl_morph: np.ndarray, + xyz_int: np.ndarray, + xyz_morph: np.ndarray) -> Union[float, + np.ndarray]: + """Calculates center of mass shift (in mm, since resolution is in mm). + + Note: + Row positions of "x_gl" and "xyz" must correspond for each point. + + Args: + xgl_int (ndarray): Vector of intensity values in the volume to analyze + (only values in the intensity mask). + xgl_morph (ndarray): Vector of intensity values in the volume to analyze + (only values in the morphological mask). + xyz_int (ndarray): [n_points X 3] matrix of three column vectors, defining the [X,Y,Z] + positions of the points in the ROI (1's) of the mask volume (In mm). + (Mesh-based volume calculated from the ROI intensity mesh) + xyz_morph (ndarray): [n_points X 3] matrix of three column vectors, defining the [X,Y,Z] + positions of the points in the ROI (1's) of the mask volume (In mm). + (Mesh-based volume calculated from the ROI morphological mesh) + + Returns: + Union[float, np.ndarray]: The ROI volume centre of mass. + + """ + + # Getting the geometric centre of mass + n_v = np.size(xgl_morph) + + com_geom = np.sum(xyz_morph, 0)/n_v # [1 X 3] vector + + # Getting the density centre of mass + xyz_int[:, 0] = xgl_int*xyz_int[:, 0] + xyz_int[:, 1] = xgl_int*xyz_int[:, 1] + xyz_int[:, 2] = xgl_int*xyz_int[:, 2] + com_gl = np.sum(xyz_int, 0)/np.sum(xgl_int, 0) # [1 X 3] vector + + # Calculating the shift + com = np.linalg.norm(com_geom - com_gl) + + return com + +def get_area_dens_approx(a: float, + b: float, + c: float, + n: float) -> float: + """Computes area density - minimum volume enclosing ellipsoid + + Args: + a (float): Major semi-axis length. + b (float): Minor semi-axis length. + c (float): Least semi-axis length. + n (int): Number of iterations. + + Returns: + float: Area density - minimum volume enclosing ellipsoid. + + """ + alpha = np.sqrt(1 - b**2/a**2) + beta = np.sqrt(1 - c**2/a**2) + ab = alpha * beta + point = (alpha**2+beta**2) / (2*ab) + a_ell = 0 + + for v in range(0, n+1): + coef = [0]*v + [1] + legen = np.polynomial.legendre.legval(x=point, c=coef) + a_ell = a_ell + ab**v / (1-4*v**2) * legen + + a_ell = a_ell * 4 * np.pi * a * b + + return a_ell + +def get_axis_lengths(xyz: np.ndarray) -> Tuple[float, float, float]: + """Computes AxisLengths. + + Args: + xyz (ndarray): Array of three column vectors, defining the [X,Y,Z] + positions of the points in the ROI (1's) of the mask volume. In mm. + + Returns: + Tuple[float, float, float]: Array of three column vectors + [Major axis lengths, Minor axis lengths, Least axis lengths]. + + """ + xyz = xyz.copy() + + # Getting the geometric centre of mass + com_geom = np.sum(xyz, 0)/np.shape(xyz)[0] # [1 X 3] vector + + # Subtracting the centre of mass + xyz[:, 0] = xyz[:, 0] - com_geom[0] + xyz[:, 1] = xyz[:, 1] - com_geom[1] + xyz[:, 2] = xyz[:, 2] - com_geom[2] + + # Getting the covariance matrix + cov_mat = np.cov(xyz, rowvar=False) + + # Getting the eigenvalues + eig_val, _ = np.linalg.eig(cov_mat) + eig_val = np.sort(eig_val) + + major = eig_val[2] + minor = eig_val[1] + least = eig_val[0] + + return major, minor, least + +def min_oriented_bound_box(pos_mat: np.ndarray) -> np.ndarray: + """Computes the minimum bounding box of an arbitrary solid: an iterative approach. + This feature refers to "Volume density (oriented minimum bounding box)" (ID = ZH1A) + in the `IBSI1 reference manual `_. + + Args: + pos_mat (ndarray): matrix position + + Returns: + ndarray: return bounding box dimensions + """ + + ########################## + # Internal functions + ########################## + + def calc_rot_aabb_surface(theta: float, + hull_mat: np.ndarray) -> np.ndarray: + """Function to calculate surface of the axis-aligned bounding box of a rotated 2D contour + + Args: + theta (float): angle in radian + hull_mat (nddarray): convex hull matrix + + Returns: + ndarray: the surface of the axis-aligned bounding box of a rotated 2D contour + """ + + # Create rotation matrix and rotate over theta + rot_mat = rot_matrix(theta=theta, dim=2) + rot_hull = np.dot(rot_mat, hull_mat) + + # Calculate bounding box surface of the rotated contour + rot_aabb_dims = np.max(rot_hull, axis=1) - np.min(rot_hull, axis=1) + rot_aabb_area = np.product(rot_aabb_dims) + + return rot_aabb_area + + def approx_min_theta(hull_mat: np.ndarray, + theta_sel: float, + res: float, + max_rep: int=5) -> np.ndarray: + """Iterative approximator for finding angle theta that minimises surface area + + Args: + hull_mat (ndarray): convex hull matrix + theta_sel (float): angle in radian + res (float): value in radian + max_rep (int, optional): maximum repetition. Defaults to 5. + + Returns: + ndarray: the angle theta that minimises surfae area + """ + + for i in np.arange(0, max_rep): + + # Select new thetas in vicinity of + theta = np.array([theta_sel-res, theta_sel-0.5*res, + theta_sel, theta_sel+0.5*res, theta_sel+res]) + + # Calculate projection areas for current angles theta + rot_area = np.array( + list(map(lambda x: calc_rot_aabb_surface(theta=x, hull_mat=hull_mat), theta))) + + # Find global minimum and corresponding angle theta_sel + theta_sel = theta[np.argmin(rot_area)] + + # Shrink resolution and iterate + res = res / 2.0 + + return theta_sel + + def rotate_minimal_projection(input_pos: float, + rot_axis: int, + n_minima: int=3, + res_init: float=5.0): + """Function to that rotates input_pos to find the rotation that + minimises the projection of input_pos on the + plane normal to the rot_axis + + Args: + input_pos (float): input position value + rot_axis (int): rotation axis value + n_minima (int, optional): _description_. Defaults to 3. + res_init (float, optional): _description_. Defaults to 5.0. + + Returns: + _type_: _description_ + """ + + + # Find axis aligned bounding box of the point set + aabb_max = np.max(input_pos, axis=0) + aabb_min = np.min(input_pos, axis=0) + + # Center the point set at the AABB center + output_pos = input_pos - 0.5 * (aabb_min + aabb_max) + + # Project model to plane + proj_pos = copy.deepcopy(output_pos) + proj_pos = np.delete(proj_pos, [rot_axis], axis=1) + + # Calculate 2D convex hull of the model projection in plane + if np.shape(proj_pos)[0] >= 10: + hull_2d = ConvexHull(points=proj_pos) + hull_mat = proj_pos[hull_2d.vertices, :] + del hull_2d, proj_pos + else: + hull_mat = proj_pos + del proj_pos + + # Transpose hull_mat so that the array is (ndim, npoints) instead of (npoints, ndim) + hull_mat = np.transpose(hull_mat) + + # Calculate bounding box surface of a series of rotated contours + # Note we can program a min-search algorithm here as well + + # Calculate initial surfaces + theta_init = np.arange(start=0.0, stop=90.0 + + res_init, step=res_init) * np.pi / 180.0 + rot_area = np.array( + list(map(lambda x: calc_rot_aabb_surface(theta=x, hull_mat=hull_mat), theta_init))) + + # Find local minima + df_min = sig_proc_find_peaks(x=rot_area, ddir="neg") + + # Check if any minimum was generated + if len(df_min) > 0: + # Investigate up to n_minima number of local minima, starting with the global minimum + df_min = df_min.sort_values(by="val", ascending=True) + + # Determine max number of minima evaluated + max_iter = np.min([n_minima, len(df_min)]) + + # Initialise place holder array + theta_min = np.zeros(max_iter) + + # Iterate over local minima + for k in np.arange(0, max_iter): + + # Find initial angle corresponding to i-th minimum + sel_ind = df_min.ind.values[k] + theta_curr = theta_init[sel_ind] + + # Zoom in to improve the approximation of theta + theta_min[k] = approx_min_theta( + hull_mat=hull_mat, theta_sel=theta_curr, res=res_init*np.pi/180.0) + + # Calculate surface areas corresponding to theta_min and theta that + # minimises the surface + rot_area = np.array( + list(map(lambda x: calc_rot_aabb_surface(theta=x, hull_mat=hull_mat), theta_min))) + theta_sel = theta_min[np.argmin(rot_area)] + + else: + theta_sel = theta_init[0] + + # Rotate original point along the angle that minimises the projected AABB area + output_pos = np.transpose(output_pos) + rot_mat = rot_matrix(theta=theta_sel, dim=3, rot_axis=rot_axis) + output_pos = np.dot(rot_mat, output_pos) + + # Rotate output_pos back to (npoints, ndim) + output_pos = np.transpose(output_pos) + + return output_pos + + ########################## + # Main function + ########################## + + rot_df = pd.DataFrame({"rot_axis_0": np.array([0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2]), + "rot_axis_1": np.array([1, 2, 1, 2, 0, 2, 0, 2, 0, 1, 0, 1]), + "rot_axis_2": np.array([2, 1, 0, 0, 2, 0, 1, 1, 1, 0, 2, 2]), + "aabb_axis_0": np.zeros(12), + "aabb_axis_1": np.zeros(12), + "aabb_axis_2": np.zeros(12), + "vol": np.zeros(12)}) + + # Rotate over different sequences + for i in np.arange(0, len(rot_df)): + # Create a local copy + work_pos = copy.deepcopy(pos_mat) + + # Rotate over sequence of rotation axes + work_pos = rotate_minimal_projection( + input_pos=work_pos, rot_axis=rot_df.rot_axis_0[i]) + work_pos = rotate_minimal_projection( + input_pos=work_pos, rot_axis=rot_df.rot_axis_1[i]) + work_pos = rotate_minimal_projection( + input_pos=work_pos, rot_axis=rot_df.rot_axis_2[i]) + + # Determine resultant minimum bounding box + aabb_dims = np.max(work_pos, axis=0) - np.min(work_pos, axis=0) + rot_df.loc[i, "aabb_axis_0"] = aabb_dims[0] + rot_df.loc[i, "aabb_axis_1"] = aabb_dims[1] + rot_df.loc[i, "aabb_axis_2"] = aabb_dims[2] + rot_df.loc[i, "vol"] = np.product(aabb_dims) + + del work_pos, aabb_dims + + # Find minimal volume of all rotations and return bounding box dimensions + idxmin = rot_df.vol.idxmin() + sel_row = rot_df.loc[idxmin] + ombb_dims = np.array( + [sel_row.aabb_axis_0, sel_row.aabb_axis_1, sel_row.aabb_axis_2]) + + return ombb_dims + +def get_moran_i(vol: np.ndarray, + res: List[float]) -> float: + """Computes Moran's Index. + This feature refers to "Moran’s I index" (ID = N365) + in the `IBSI1 reference manual `_. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + res (List[float]): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world) or JIK resolution (intrinsic matlab). + + Returns: + float: Value of Moran's Index. + + """ + vol = vol.copy() + res = res.copy() + + # Find the location(s) of all non NaNs voxels + I, J, K = np.nonzero(~np.isnan(vol)) + n_vox = np.size(I) + + # Get the mean + u = np.mean(vol[~np.isnan(vol[:])]) + vol_mean = vol.copy() - u # (x_gl,i - u) + vol_m_mean_s = np.power((vol.copy() - u), 2) # (x_gl,i - u).^2 + # Sum of (x_gl,i - u).^2 over all i + sum_s = np.sum(vol_m_mean_s[~np.isnan(vol_m_mean_s[:])]) + + # Get a meshgrid first + x = res[0]*((np.arange(1, np.shape(vol)[0]+1))-0.5) + y = res[1]*((np.arange(1, np.shape(vol)[1]+1))-0.5) + z = res[2]*((np.arange(1, np.shape(vol)[2]+1))-0.5) + X, Y, Z = np.meshgrid(x, y, z, indexing='ij') + + temp = 0 + sum_w = 0 + for i in range(1, n_vox+1): + # Distance mesh + temp_x = X - X[I[i-1], J[i-1], K[i-1]] + temp_y = Y - Y[I[i-1], J[i-1], K[i-1]] + temp_z = Z - Z[I[i-1], J[i-1], K[i-1]] + + # meshgrid of weigths + temp_dist_mesh = 1 / np.sqrt(temp_x**2 + temp_y**2 + temp_z**2) + + # Removing NaNs + temp_dist_mesh[np.isnan(vol)] = np.NaN + temp_dist_mesh[I[i-1], J[i-1], K[i-1]] = np.NaN + # Running sum of weights + w_sum = np.sum(temp_dist_mesh[~np.isnan(temp_dist_mesh[:])]) + sum_w = sum_w + w_sum + + # Inside sum calculation + # Removing NaNs + temp_vol = vol_mean.copy() + temp_vol[I[i-1], J[i-1], K[i-1]] = np.NaN + temp_vol = temp_dist_mesh * temp_vol # (wij .* (x_gl,j - u)) + # Summing (wij .* (x_gl,j - u)) over all j + sum_val = np.sum(temp_vol[~np.isnan(temp_vol[:])]) + # Running sum of (x_gl,i - u)*(wij .* (x_gl,j - u)) over all i + temp = temp + vol_mean[I[i-1], J[i-1], K[i-1]] * sum_val + + moran_i = temp*n_vox/sum_s/sum_w + + return moran_i + +def get_mesh_volume(faces: np.ndarray, + vertices:np.ndarray) -> float: + """Computes MeshVolume feature. + This feature refers to "Volume (mesh)" (ID = RNU0) + in the `IBSI1 reference manual `_. + + Args: + faces (np.ndarray): matrix of three column vectors, defining the [X,Y,Z] + positions of the ``faces`` of the isosurface or convex hull of the mask + (output from "isosurface.m" or "convhull.m" functions of MATLAB). + --> These are more precisely indexes to ``vertices`` + vertices (np.ndarray): matrix of three column vectors, defining the + [X,Y,Z] positions of the ``vertices`` of the isosurface of the mask (output + from "isosurface.m" function of MATLAB). + --> In mm. + + Returns: + float: Mesh volume + """ + faces = faces.copy() + vertices = vertices.copy() + + # Getting vectors for the three vertices + # (with respect to origin) of each face + a = vertices[faces[:, 0], :] + b = vertices[faces[:, 1], :] + c = vertices[faces[:, 2], :] + + # Calculating volume + v_cross = np.cross(b, c) + v_dot = np.sum(a.conj()*v_cross, axis=1) + volume = np.abs(np.sum(v_dot))/6 + + return volume + +def get_mesh_area(faces: np.ndarray, + vertices: np.ndarray) -> float: + """Computes the surface area (mesh) feature from the ROI mesh by + summing over the triangular face surface areas. + This feature refers to "Surface area (mesh)" (ID = C0JK) + in the `IBSI1 reference manual `_. + + Args: + faces (np.ndarray): matrix of three column vectors, defining the [X,Y,Z] + positions of the ``faces`` of the isosurface or convex hull of the mask + (output from "isosurface.m" or "convhull.m" functions of MATLAB). + --> These are more precisely indexes to ``vertices`` + vertices (np.ndarray): matrix of three column vectors, + defining the [X,Y,Z] + positions of the ``vertices`` of the isosurface of the mask (output + from "isosurface.m" function of MATLAB). + --> In mm. + + Returns: + float: Mesh area. + """ + + faces = faces.copy() + vertices = vertices.copy() + + # Getting two vectors of edges for each face + a = vertices[faces[:, 1], :] - vertices[faces[:, 0], :] + b = vertices[faces[:, 2], :] - vertices[faces[:, 0], :] + + # Calculating the surface area of each face and summing it up all at once. + c = np.cross(a, b) + area = 1/2 * np.sum(np.sqrt(np.sum(np.power(c, 2), 1))) + + return area + +def get_geary_c(vol: np.ndarray, + res: np.ndarray) -> float: + """Computes Geary'C measure (Assesses intensity differences between voxels). + This feature refers to "Geary's C measure" (ID = NPT7) + in the `IBSI1 reference manual `_. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + + Returns: + float: computes value of Geary'C measure. + """ + vol = vol.copy() + res = res.copy() + + # Find the location(s) of all non NaNs voxels + I, J, K = np.nonzero(~np.isnan(vol)) + n_vox = np.size(I) + + # Get the mean + u = np.mean(vol[~np.isnan(vol[:])]) + vol_m_mean_s = np.power((vol.copy() - u), 2) # (x_gl,i - u).^2 + + # Sum of (x_gl,i - u).^2 over all i + sum_s = np.sum(vol_m_mean_s[~np.isnan(vol_m_mean_s[:])]) + + # Get a meshgrid first + x = res[0]*((np.arange(1, np.shape(vol)[0]+1))-0.5) + y = res[1]*((np.arange(1, np.shape(vol)[1]+1))-0.5) + z = res[2]*((np.arange(1, np.shape(vol)[2]+1))-0.5) + X, Y, Z = np.meshgrid(x, y, z, indexing='ij') + + temp = 0 + sum_w = 0 + + for i in range(1, n_vox+1): + # Distance mesh + temp_x = X - X[I[i-1], J[i-1], K[i-1]] + temp_y = Y - Y[I[i-1], J[i-1], K[i-1]] + temp_z = Z - Z[I[i-1], J[i-1], K[i-1]] + + # meshgrid of weigths + temp_dist_mesh = 1/np.sqrt(temp_x**2 + temp_y**2 + temp_z**2) + + # Removing NaNs + temp_dist_mesh[np.isnan(vol)] = np.NaN + temp_dist_mesh[I[i-1], J[i-1], K[i-1]] = np.NaN + + # Running sum of weights + w_sum = np.sum(temp_dist_mesh[~np.isnan(temp_dist_mesh[:])]) + sum_w = sum_w + w_sum + + # Inside sum calculation + val = vol[I[i-1], J[i-1], K[i-1]].copy() # x_gl,i + # wij.*(x_gl,i - x_gl,j).^2 + temp_vol = temp_dist_mesh*(vol - val)**2 + + # Removing i voxel to be sure; + temp_vol[I[i-1], J[i-1], K[i-1]] = np.NaN + + # Sum of wij.*(x_gl,i - x_gl,j).^2 over all j + sum_val = np.sum(temp_vol[~np.isnan(temp_vol[:])]) + + # Running sum of (sum of wij.*(x_gl,i - x_gl,j).^2 over all j) over all i + temp = temp + sum_val + + geary_c = temp * (n_vox-1) / sum_s / (2*sum_w) + + return geary_c + +def min_vol_ellipse(P: np.ndarray, + tolerance: np.ndarray) -> Tuple[np.ndarray, + np.ndarray]: + """Computes min_vol_ellipse. + + Finds the minimum volume enclsing ellipsoid (MVEE) of a set of data + points stored in matrix P. The following optimization problem is solved: + + minimize $$log(det(A))$$ subject to $$(P_i - c)' * A * (P_i - c) <= 1$$ + + in variables A and c, where `P_i` is the `i-th` column of the matrix `P`. + The solver is based on Khachiyan Algorithm, and the final solution + is different from the optimal value by the pre-spesified amount of + `tolerance`. + + Note: + Adapted from MATLAB code of Nima Moshtagh (nima@seas.upenn.edu) + University of Pennsylvania. + + Args: + P (ndarray): (d x N) dimnesional matrix containing N points in R^d. + tolerance (ndarray): error in the solution with respect to the optimal value. + + Returns: + 2-element tuple containing + + - A: (d x d) matrix of the ellipse equation in the 'center form': \ + $$(x-c)' * A * (x-c) = 1$$ \ + where d is shape of `P` along 0-axis. + + - c: d-dimensional vector as the center of the ellipse. + + Examples: + + >>>P = rand(5,100) + + >>>[A, c] = :func:`min_vol_ellipse(P, .01)` + + To reduce the computation time, work with the boundary points only: + + >>>K = :func:`convhulln(P)` + + >>>K = :func:`unique(K(:))` + + >>>Q = :func:`P(:,K)` + + >>>[A, c] = :func:`min_vol_ellipse(Q, .01)` + """ + + # Solving the Dual problem + # data points + d, N = np.shape(P) + Q = np.ones((d+1, N)) + Q[:-1, :] = P[:, :] + + # initializations + err = 1 + u = np.ones(N)/N # 1st iteration + new_u = np.zeros(N) + + # Khachiyan Algorithm + + while (err > tolerance): + diag_u = np.diag(u) + trans_q = np.transpose(Q) + X = Q @ diag_u @ trans_q + + # M the diagonal vector of an NxN matrix + inv_x = np.linalg.inv(X) + M = np.diag(trans_q @ inv_x @ Q) + maximum = np.max(M) + j = np.argmax(M) + + step_size = (maximum - d - 1)/((d+1)*(maximum-1)) + new_u = (1 - step_size)*u.copy() + new_u[j] = new_u[j] + step_size + err = np.linalg.norm(new_u - u) + u = new_u.copy() + + # Computing the Ellipse parameters + # Finds the ellipse equation in the 'center form': + # (x-c)' * A * (x-c) = 1 + # It computes a dxd matrix 'A' and a d dimensional vector 'c' as the center + # of the ellipse. + U = np.diag(u) + + # the A matrix for the ellipse + c = P @ u + c = np.reshape(c, (np.size(c), 1), order='F') # center of the ellipse + + pup_t = P @ U @ np.transpose(P) + cct = c @ np.transpose(c) + a_inv = np.linalg.inv(pup_t - cct) + A = (1/d) * a_inv + + return A, c + +def padding(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Padding the volume and masks. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + + Returns: + tuple of 3 ndarray: Volume and masks after padding. + """ + + # PADDING THE VOLUME WITH A LAYER OF NaNs + # (reduce mesh computation errors of associated mask) + vol = vol.copy() + vol = np.pad(vol, pad_width=1, mode="constant", constant_values=np.NaN) + # PADDING THE MASKS WITH A LAYER OF 0's + # (reduce mesh computation errors of associated mask) + mask_int = mask_int.copy() + mask_int = np.pad(mask_int, pad_width=1, mode="constant", constant_values=0.0) + mask_morph = mask_morph.copy() + mask_morph = np.pad(mask_morph, pad_width=1, mode="constant", constant_values=0.0) + + return vol, mask_int, mask_morph + +def get_variables(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> Tuple[np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray, + np.ndarray]: + """Compute variables usefull to calculate morphological features. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + tuple of 7 ndarray: Variables usefull to calculate morphological features. + """ + # GETTING IMPORTANT VARIABLES + xgl_int = np.reshape(vol, np.size(vol), order='F')[np.where( + np.reshape(mask_int, np.size(mask_int), order='F') == 1)[0]].copy() + xgl_morph = np.reshape(vol, np.size(vol), order='F')[np.where( + np.reshape(mask_morph, np.size(mask_morph), order='F') == 1)[0]].copy() + # XYZ refers to [Xc,Yc,Zc] in ref. [1]. + xyz_int, _, _ = get_mesh(mask_int, res) + # XYZ refers to [Xc,Yc,Zc] in ref. [1]. + xyz_morph, faces, vertices = get_mesh(mask_morph, res) + # [X,Y,Z] points of the convex hull. + # conv_hull Matlab is conv_hull.simplices + conv_hull = sc.ConvexHull(vertices) + + return xgl_int, xgl_morph, xyz_int, xyz_morph, faces, vertices, conv_hull + +def extract_all(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray, + intensity_type: str, + compute_moran_i: bool=False, + compute_geary_c: bool=False) -> Dict: + """Compute Morphological Features. + This features refer to Morphological family in + the `IBSI1 reference manual `__. + + Note: + Moran's Index and Geary's C measure takes so much computation time. Please + use `compute_moran_i` `compute_geary_c` carefully. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + intensity_type (str): Type of intensity to compute. Can be "arbitrary", "definite" or "filtered". + Will compute all features for "definite" intensity type and all but intergrated intensity for + "arbitrary" intensity type. + compute_moran_i (bool, optional): True to compute Moran's Index. + compute_geary_c (bool, optional): True to compute Geary's C measure. + + Raises: + ValueError: If `intensity_type` is not "arbitrary", "definite" or "filtered". + """ + assert intensity_type in ["arbitrary", "definite", "filtered"], \ + "intensity_type must be 'arbitrary', 'definite' or 'filtered'" + + # Initialization of final structure (Dictionary) containing all features. + morph = {'Fmorph_vol': [], + 'Fmorph_approx_vol': [], + 'Fmorph_area': [], + 'Fmorph_av': [], + 'Fmorph_comp_1': [], + 'Fmorph_comp_2': [], + 'Fmorph_sph_dispr': [], + 'Fmorph_sphericity': [], + 'Fmorph_asphericity': [], + 'Fmorph_com': [], + 'Fmorph_diam': [], + 'Fmorph_pca_major': [], + 'Fmorph_pca_minor': [], + 'Fmorph_pca_least': [], + 'Fmorph_pca_elongation': [], + 'Fmorph_pca_flatness': [], # until here + 'Fmorph_v_dens_aabb': [], + 'Fmorph_a_dens_aabb': [], + 'Fmorph_v_dens_ombb': [], + 'Fmorph_a_dens_ombb': [], + 'Fmorph_v_dens_aee': [], + 'Fmorph_a_dens_aee': [], + 'Fmorph_v_dens_mvee': [], + 'Fmorph_a_dens_mvee': [], + 'Fmorph_v_dens_conv_hull': [], + 'Fmorph_a_dens_conv_hull': [], + 'Fmorph_integ_int': [], + 'Fmorph_moran_i': [], + 'Fmorph_geary_c': [] + } + #Initialization + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + xgl_int, xgl_morph, xyz_int, xyz_morph, faces, vertices, conv_hull = get_variables(vol, mask_int, mask_morph, res) + + # STARTING COMPUTATION + if intensity_type != "filtered": + # Volume in mm^3 + volume = get_mesh_volume(faces, vertices) + morph['Fmorph_vol'] = volume # Volume + + # Approximate Volume + morph['Fmorph_approx_vol'] = np.sum(mask_morph[:]) * np.prod(res) + + # Surface area in mm^2 + area = get_mesh_area(faces, vertices) + morph['Fmorph_area'] = area + + # Surface to volume ratio + morph['Fmorph_av'] = area / volume + + # Compactness 1 + morph['Fmorph_comp_1'] = volume / ((np.pi**(1/2))*(area**(3/2))) + + # Compactness 2 + morph['Fmorph_comp_2'] = 36*np.pi*(volume**2) / (area**3) + + # Spherical disproportion + morph['Fmorph_sph_dispr'] = area / (36*np.pi*volume**2)**(1/3) + + # Sphericity + morph['Fmorph_sphericity'] = ((36*np.pi*volume**2)**(1/3)) / area + + # Asphericity + morph['Fmorph_asphericity'] = ((area**3) / (36*np.pi*volume**2))**(1/3) - 1 + + # Centre of mass shift + morph['Fmorph_com'] = get_com(xgl_int, xgl_morph, xyz_int, xyz_morph) + + # Maximum 3D diameter + morph['Fmorph_diam'] = np.max(sc.distance.pdist(conv_hull.points[conv_hull.vertices])) + + # Major axis length + [major, minor, least] = get_axis_lengths(xyz_morph) + morph['Fmorph_pca_major'] = 4 * np.sqrt(major) + + # Minor axis length + morph['Fmorph_pca_minor'] = 4 * np.sqrt(minor) + + # Least axis length + morph['Fmorph_pca_least'] = 4 * np.sqrt(least) + + # Elongation + morph['Fmorph_pca_elongation'] = np.sqrt(minor / major) + + # Flatness + morph['Fmorph_pca_flatness'] = np.sqrt(least / major) + + # Volume density - axis-aligned bounding box + xc_aabb = np.max(vertices[:, 0]) - np.min(vertices[:, 0]) + yc_aabb = np.max(vertices[:, 1]) - np.min(vertices[:, 1]) + zc_aabb = np.max(vertices[:, 2]) - np.min(vertices[:, 2]) + v_aabb = xc_aabb * yc_aabb * zc_aabb + morph['Fmorph_v_dens_aabb'] = volume / v_aabb + + # Area density - axis-aligned bounding box + a_aabb = 2*xc_aabb*yc_aabb + 2*xc_aabb*zc_aabb + 2*yc_aabb*zc_aabb + morph['Fmorph_a_dens_aabb'] = area / a_aabb + + # Volume density - oriented minimum bounding box + # Implementation of Chan and Tan's algorithm (C.K. Chan, S.T. Tan. + # Determination of the minimum bounding box of an + # arbitrary solid: an iterative approach. + # Comp Struc 79 (2001) 1433-1449 + bound_box_dims = min_oriented_bound_box(vertices) + vol_bb = np.prod(bound_box_dims) + morph['Fmorph_v_dens_ombb'] = volume / vol_bb + + # Area density - oriented minimum bounding box + a_ombb = 2 * (bound_box_dims[0]*bound_box_dims[1] + + bound_box_dims[0]*bound_box_dims[2] + + bound_box_dims[1]*bound_box_dims[2]) + morph['Fmorph_a_dens_ombb'] = area / a_ombb + + # Volume density - approximate enclosing ellipsoid + a = 2*np.sqrt(major) + b = 2*np.sqrt(minor) + c = 2*np.sqrt(least) + v_aee = (4*np.pi*a*b*c) / 3 + morph['Fmorph_v_dens_aee'] = volume / v_aee + + # Area density - approximate enclosing ellipsoid + a_aee = get_area_dens_approx(a, b, c, 20) + morph['Fmorph_a_dens_aee'] = area / a_aee + + # Volume density - minimum volume enclosing ellipsoid + # (Rotate the volume first??) + # Copyright (c) 2009, Nima Moshtagh + # http://www.mathworks.com/matlabcentral/fileexchange/ + # 9542-minimum-volume-enclosing-ellipsoid + # Subsequent singular value decomposition of matrix A and and + # taking the inverse of the square root of the diagonal of the + # sigma matrix will produce respective semi-axis lengths. + # Subsequent singular value decomposition of matrix A and + # taking the inverse of the square root of the diagonal of the + # sigma matrix will produce respective semi-axis lengths. + p = np.stack((conv_hull.points[conv_hull.simplices[:, 0], 0], + conv_hull.points[conv_hull.simplices[:, 1], 1], + conv_hull.points[conv_hull.simplices[:, 2], 2]), axis=1) + A, _ = min_vol_ellipse(np.transpose(p), 0.01) + # New semi-axis lengths + _, Q, _ = np.linalg.svd(A) + a = 1/np.sqrt(Q[2]) + b = 1/np.sqrt(Q[1]) + c = 1/np.sqrt(Q[0]) + v_mvee = (4*np.pi*a*b*c)/3 + morph['Fmorph_v_dens_mvee'] = volume / v_mvee + + # Area density - minimum volume enclosing ellipsoid + # Using a new set of (a,b,c), see Volume density - minimum + # volume enclosing ellipsoid + a_mvee = get_area_dens_approx(a, b, c, 20) + morph['Fmorph_a_dens_mvee'] = area / a_mvee + + # Volume density - convex hull + v_convex = conv_hull.volume + morph['Fmorph_v_dens_conv_hull'] = volume / v_convex + + # Area density - convex hull + a_convex = conv_hull.area + morph['Fmorph_a_dens_conv_hull'] = area / a_convex + + # Integrated intensity + if intensity_type == "definite": + volume = get_mesh_volume(faces, vertices) + morph['Fmorph_integ_int'] = np.mean(xgl_int) * volume + + # Moran's I index + if compute_moran_i: + vol_mor = vol.copy() + vol_mor[mask_int == 0] = np.NaN + morph['Fmorph_moran_i'] = get_moran_i(vol_mor, res) + + # Geary's C measure + if compute_geary_c: + morph['Fmorph_geary_c'] = get_geary_c(vol_mor, res) + + return morph + +def vol(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes morphological volume feature. + This feature refers to "Fmorph_vol" (ID = RNUO) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the morphological volume feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + volume = get_mesh_volume(faces, vertices) + + return volume # Morphological volume feature + +def approx_vol(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes morphological approximate volume feature. + This feature refers to "Fmorph_approx_vol" (ID = YEKZ) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the morphological approximate volume feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + volume_appro = np.sum(mask_morph[:]) * np.prod(res) + + return volume_appro # Morphological approximate volume feature + +def area(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Surface area feature. + This feature refers to "Fmorph_area" (ID = COJJK) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the surface area feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + area = get_mesh_area(faces, vertices) + + return area # Surface area + +def av(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Surface to volume ratio feature. + This feature refers to "Fmorph_av" (ID = 2PR5) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Surface to volume ratio feature. + """ + _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + volume = get_mesh_volume(faces, vertices) + area = get_mesh_area(faces, vertices) + ratio = area / volume + + return ratio # Surface to volume ratio + +def comp_1(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Compactness 1 feature. + This feature refers to "Fmorph_comp_1" (ID = SKGS) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Compactness 1 feature. + """ + _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + volume = get_mesh_volume(faces, vertices) + area = get_mesh_area(faces, vertices) + comp_1 = volume / ((np.pi**(1/2))*(area**(3/2))) + + return comp_1 # Compactness 1 + +def comp_2(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Compactness 2 feature. + This feature refers to "Fmorph_comp_2" (ID = BQWJ) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Compactness 2 feature. + """ + _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + volume = get_mesh_volume(faces, vertices) + area = get_mesh_area(faces, vertices) + comp_2 = 36*np.pi*(volume**2) / (area**3) + + return comp_2 # Compactness 2 + +def sph_dispr(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Spherical disproportion feature. + This feature refers to "Fmorph_sph_dispr" (ID = KRCK) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Spherical disproportion feature. + """ + _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + volume = get_mesh_volume(faces, vertices) + area = get_mesh_area(faces, vertices) + sph_dispr = area / (36*np.pi*volume**2)**(1/3) + + return sph_dispr # Spherical disproportion + +def sphericity(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Sphericity feature. + This feature refers to "Fmorph_sphericity" (ID = QCFX) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Sphericity feature. + """ + _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + volume = get_mesh_volume(faces, vertices) + area = get_mesh_area(faces, vertices) + sphericity = ((36*np.pi*volume**2)**(1/3)) / area + + return sphericity # Sphericity + +def asphericity(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Asphericity feature. + This feature refers to "Fmorph_asphericity" (ID = 25C) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Asphericity feature. + """ + _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + volume = get_mesh_volume(faces, vertices) + area = get_mesh_area(faces, vertices) + asphericity = ((area**3) / (36*np.pi*volume**2))**(1/3) - 1 + + return asphericity # Asphericity + +def com(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Centre of mass shift feature. + This feature refers to "Fmorph_com" (ID = KLM) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Centre of mass shift feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + xgl_int, xgl_morph, xyz_int, xyz_morph, _, _, _ = get_variables(vol, mask_int, mask_morph, res) + com = get_com(xgl_int, xgl_morph, xyz_int, xyz_morph) + + return com # Centre of mass shift + +def diam(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Maximum 3D diameter feature. + This feature refers to "Fmorph_diam" (ID = L0JK) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Maximum 3D diameter feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, _, _, _, conv_hull = get_variables(vol, mask_int, mask_morph, res) + diam = np.max(sc.distance.pdist(conv_hull.points[conv_hull.vertices])) + + return diam # Maximum 3D diameter + +def pca_major(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Major axis length feature. + This feature refers to "Fmorph_pca_major" (ID = TDIC) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Major axis length feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, xyz_morph, _, _, _ = get_variables(vol, mask_int, mask_morph, res) + [major, _, _] = get_axis_lengths(xyz_morph) + pca_major = 4 * np.sqrt(major) + + return pca_major # Major axis length + +def pca_minor(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Minor axis length feature. + This feature refers to "Fmorph_pca_minor" (ID = P9VJ) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Minor axis length feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, xyz_morph, _, _, _ = get_variables(vol, mask_int, mask_morph, res) + [_, minor, _] = get_axis_lengths(xyz_morph) + pca_minor = 4 * np.sqrt(minor) + + return pca_minor # Minor axis length + +def pca_least(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Least axis length feature. + This feature refers to "Fmorph_pca_least" (ID = 7J51) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Least axis length feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, xyz_morph, _, _, _ = get_variables(vol, mask_int, mask_morph, res) + [_, _, least] = get_axis_lengths(xyz_morph) + pca_least = 4 * np.sqrt(least) + + return pca_least # Least axis length + +def pca_elongation(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Elongation feature. + This feature refers to "Fmorph_pca_elongation" (ID = Q3CK) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Elongation feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, xyz_morph, _, _, _ = get_variables(vol, mask_int, mask_morph, res) + [major, minor, _] = get_axis_lengths(xyz_morph) + pca_elongation = np.sqrt(minor / major) + + return pca_elongation # Elongation + +def pca_flatness(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Flatness feature. + This feature refers to "Fmorph_pca_flatness" (ID = N17B) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Flatness feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, xyz_morph, _, _, _ = get_variables(vol, mask_int, mask_morph, res) + [major, _, least] = get_axis_lengths(xyz_morph) + pca_flatness = np.sqrt(least / major) + + return pca_flatness # Flatness + +def v_dens_aabb(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Volume density - axis-aligned bounding box feature. + This feature refers to "Fmorph_v_dens_aabb" (ID = PBX1) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Volume density - axis-aligned bounding box feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + volume = get_mesh_volume(faces, vertices) + xc_aabb = np.max(vertices[:, 0]) - np.min(vertices[:, 0]) + yc_aabb = np.max(vertices[:, 1]) - np.min(vertices[:, 1]) + zc_aabb = np.max(vertices[:, 2]) - np.min(vertices[:, 2]) + v_aabb = xc_aabb * yc_aabb * zc_aabb + v_dens_aabb = volume / v_aabb + + return v_dens_aabb # Volume density - axis-aligned bounding box + +def a_dens_aabb(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Area density - axis-aligned bounding box feature. + This feature refers to "Fmorph_a_dens_aabb" (ID = R59B) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Area density - axis-aligned bounding box feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + area = get_mesh_area(faces, vertices) + xc_aabb = np.max(vertices[:, 0]) - np.min(vertices[:, 0]) + yc_aabb = np.max(vertices[:, 1]) - np.min(vertices[:, 1]) + zc_aabb = np.max(vertices[:, 2]) - np.min(vertices[:, 2]) + a_aabb = 2*xc_aabb*yc_aabb + 2*xc_aabb*zc_aabb + 2*yc_aabb*zc_aabb + a_dens_aabb = area / a_aabb + + return a_dens_aabb # Area density - axis-aligned bounding box + +def v_dens_ombb(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Volume density - oriented minimum bounding box feature. + Implementation of Chan and Tan's algorithm (C.K. Chan, S.T. Tan. + Determination of the minimum bounding box of an + arbitrary solid: an iterative approach. + Comp Struc 79 (2001) 1433-1449. + This feature refers to "Fmorph_v_dens_ombb" (ID = ZH1A) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Volume density - oriented minimum bounding box feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + volume = get_mesh_volume(faces, vertices) + bound_box_dims = min_oriented_bound_box(vertices) + vol_bb = np.prod(bound_box_dims) + v_dens_ombb = volume / vol_bb + + return v_dens_ombb # Volume density - oriented minimum bounding box + +def a_dens_ombb(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Area density - oriented minimum bounding box feature. + Implementation of Chan and Tan's algorithm (C.K. Chan, S.T. Tan. + Determination of the minimum bounding box of an + arbitrary solid: an iterative approach. + This feature refers to "Fmorph_a_dens_ombb" (ID = IQYR) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Area density - oriented minimum bounding box feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + area = get_mesh_area(faces, vertices) + + bound_box_dims = min_oriented_bound_box(vertices) + a_ombb = 2 * (bound_box_dims[0] * bound_box_dims[1] + + bound_box_dims[0] * bound_box_dims[2] + + bound_box_dims[1] * bound_box_dims[2]) + a_dens_ombb = area / a_ombb + + return a_dens_ombb # Area density - oriented minimum bounding box + +def v_dens_aee(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Volume density - approximate enclosing ellipsoid feature. + This feature refers to "Fmorph_v_dens_aee" (ID = 6BDE) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Volume density - approximate enclosing ellipsoid feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, xyz_morph, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + volume = get_mesh_volume(faces, vertices) + [major, minor, least] = get_axis_lengths(xyz_morph) + a = 2*np.sqrt(major) + b = 2*np.sqrt(minor) + c = 2*np.sqrt(least) + v_aee = (4*np.pi*a*b*c) / 3 + v_dens_aee = volume / v_aee + + return v_dens_aee # Volume density - approximate enclosing ellipsoid + +def a_dens_aee(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Area density - approximate enclosing ellipsoid feature. + This feature refers to "Fmorph_a_dens_aee" (ID = RDD2) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Area density - approximate enclosing ellipsoid feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, xyz_morph, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + area = get_mesh_area(faces, vertices) + [major, minor, least] = get_axis_lengths(xyz_morph) + a = 2*np.sqrt(major) + b = 2*np.sqrt(minor) + c = 2*np.sqrt(least) + a_aee = get_area_dens_approx(a, b, c, 20) + a_dens_aee = area / a_aee + + return a_dens_aee # Area density - approximate enclosing ellipsoid + +def v_dens_mvee(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Volume density - minimum volume enclosing ellipsoid feature. + Subsequent singular value decomposition of matrix A and and + taking the inverse of the square root of the diagonal of the + sigma matrix will produce respective semi-axis lengths. + Subsequent singular value decomposition of matrix A and + taking the inverse of the square root of the diagonal of the + sigma matrix will produce respective semi-axis lengths. + This feature refers to "Fmorph_v_dens_mvee" (ID = SWZ1) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Volume density - minimum volume enclosing ellipsoid feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, _, faces, vertices, conv_hull = get_variables(vol, mask_int, mask_morph, res) + volume = get_mesh_volume(faces, vertices) + p = np.stack((conv_hull.points[conv_hull.simplices[:, 0], 0], + conv_hull.points[conv_hull.simplices[:, 1], 1], + conv_hull.points[conv_hull.simplices[:, 2], 2]), axis=1) + A, _ = min_vol_ellipse(np.transpose(p), 0.01) + # New semi-axis lengths + _, Q, _ = np.linalg.svd(A) + a = 1/np.sqrt(Q[2]) + b = 1/np.sqrt(Q[1]) + c = 1/np.sqrt(Q[0]) + v_mvee = (4*np.pi*a*b*c) / 3 + v_dens_mvee = volume / v_mvee + + return v_dens_mvee # Volume density - minimum volume enclosing ellipsoid + +def a_dens_mvee(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Area density - minimum volume enclosing ellipsoid feature. + Subsequent singular value decomposition of matrix A and and + taking the inverse of the square root of the diagonal of the + sigma matrix will produce respective semi-axis lengths. + Subsequent singular value decomposition of matrix A and + taking the inverse of the square root of the diagonal of the + sigma matrix will produce respective semi-axis lengths. + This feature refers to "Fmorph_a_dens_mvee" (ID = BRI8) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Area density - minimum volume enclosing ellipsoid feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, _, faces, vertices, conv_hull = get_variables(vol, mask_int, mask_morph, res) + area = get_mesh_area(faces, vertices) + p = np.stack((conv_hull.points[conv_hull.simplices[:, 0], 0], + conv_hull.points[conv_hull.simplices[:, 1], 1], + conv_hull.points[conv_hull.simplices[:, 2], 2]), axis=1) + A, _ = min_vol_ellipse(np.transpose(p), 0.01) + # New semi-axis lengths + _, Q, _ = np.linalg.svd(A) + a = 1/np.sqrt(Q[2]) + b = 1/np.sqrt(Q[1]) + c = 1/np.sqrt(Q[0]) + a_mvee = get_area_dens_approx(a, b, c, 20) + a_dens_mvee = area / a_mvee + + return a_dens_mvee # Area density - minimum volume enclosing ellipsoid + +def v_dens_conv_hull(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Volume density - convex hull feature. + This feature refers to "Fmorph_v_dens_conv_hull" (ID = R3ER) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Volume density - convex hull feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, _, faces, vertices, conv_hull = get_variables(vol, mask_int, mask_morph, res) + volume = get_mesh_volume(faces, vertices) + v_convex = conv_hull.volume + v_dens_conv_hull = volume / v_convex + + return v_dens_conv_hull # Volume density - convex hull + +def a_dens_conv_hull(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Area density - convex hull feature. + This feature refers to "Fmorph_a_dens_conv_hull" (ID = 7T7F) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Area density - convex hull feature. + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + _, _, _, _, faces, vertices, conv_hull = get_variables(vol, mask_int, mask_morph, res) + area = get_mesh_area(faces, vertices) + v_convex = conv_hull.area + a_dens_conv_hull = area / v_convex + + return a_dens_conv_hull # Area density - convex hull + +def integ_int(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray) -> float: + """Computes Integrated intensity feature. + This feature refers to "Fmorph_integ_int" (ID = 99N0) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the Integrated intensity feature. + + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + xgl_int, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) + volume = get_mesh_volume(faces, vertices) + integ_int = np.mean(xgl_int) * volume + + return integ_int # Integrated intensity + +def moran_i(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray, + compute_moran_i: bool=False) -> float: + """Computes Moran's I index feature. + This feature refers to "Fmorph_moran_i" (ID = N365) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + compute_moran_i (bool, optional): True to compute Moran's Index. + + Returns: + float: Value of the Moran's I index feature. + + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + + if compute_moran_i: + vol_mor = vol.copy() + vol_mor[mask_int == 0] = np.NaN + moran_i = get_moran_i(vol_mor, res) + + return moran_i # Moran's I index + +def geary_c(vol: np.ndarray, + mask_int: np.ndarray, + mask_morph: np.ndarray, + res: np.ndarray, + compute_geary_c: bool=False) -> float: + """Computes Geary's C measure feature. + This feature refers to "Fmorph_geary_c" (ID = NPT7) in + the `IBSI1 reference manual `__. + + Args: + vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. + mask_int (ndarray): Intensity mask. + mask_morph (ndarray): Morphological mask. + res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. + XYZ resolution (world), or JIK resolution (intrinsic matlab). + compute_geary_c (bool, optional): True to compute Geary's C measure. + + Returns: + float: Value of the Geary's C measure feature. + + """ + vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) + + if compute_geary_c: + vol_mor = vol.copy() + vol_mor[mask_int == 0] = np.NaN + geary_c = get_geary_c(vol_mor, res) + + return geary_c # Geary's C measure diff --git a/MEDiml/biomarkers/ngldm.py b/MEDiml/biomarkers/ngldm.py new file mode 100644 index 0000000..a44eada --- /dev/null +++ b/MEDiml/biomarkers/ngldm.py @@ -0,0 +1,780 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from copy import deepcopy +from typing import Dict, List + +import numpy as np +import pandas as pd + +from ..utils.textureTools import (coord2index, get_neighbour_direction, + get_value, is_list_all_none) + + +def get_matrix(roi_only: np.array, + levels: np.ndarray) -> float: + """Computes Neighbouring grey level dependence matrix. + This matrix refers to "Neighbouring grey level dependence based features" (ID = REK0) + in the `IBSI1 reference manual `_. + + Args: + roi_only_int (ndarray): Smallest box containing the ROI, with the imaging data ready + for texture analysis computations. Voxels outside the ROI are + set to NaNs. + levels (ndarray or List): Vector containing the quantized gray-levels + in the tumor region (or reconstruction ``levels`` of quantization). + + Returns: + ndarray: Array of neighbouring grey level dependence matrix of ``roi_only``. + + """ + roi_only = roi_only.copy() + + # PRELIMINARY + level_temp = np.max(levels)+1 + roi_only[np.isnan(roi_only)] = level_temp + levels = np.append(levels, level_temp) + dim = np.shape(roi_only) + if np.size(dim) == 2: + np.append(dim, 1) + + q_2 = np.reshape(roi_only, np.prod(dim), order='F').astype("int") + + # QUANTIZATION EFFECTS CORRECTION (M. Vallieres) + # In case (for example) we initially wanted to have 64 levels, but due to + # quantization, only 60 resulted. + # q_s = round(levels*adjust)/adjust; + # q_2 = round(q_2*adjust)/adjust; + q_s = levels.copy() + + # EL NAQA CODE + q_3 = q_2*0 + lqs = np.size(q_s) + for k in range(1, lqs+1): + q_3[q_2 == q_s[k-1]] = k + + q_3 = np.reshape(q_3, dim, order='F') + + # Min dependence = 0, Max dependence = 26; So 27 columns + ngldm = np.zeros((lqs, 27)) + for i in range(1, dim[0]+1): + i_min = max(1, i-1) + i_max = min(i+1, dim[0]) + for j in range(1, dim[1]+1): + j_min = max(1, j-1) + j_max = min(j+1, dim[1]) + for k in range(1, dim[2]+1): + k_min = max(1, k-1) + k_max = min(k+1, dim[2]) + val_q3 = q_3[i-1, j-1, k-1] + count = 0 + for I2 in range(i_min, i_max+1): + for J2 in range(j_min, j_max+1): + for K2 in range(k_min, k_max+1): + if (I2 == i) & (J2 == j) & (K2 == k): + continue + else: + # a = 0 + if (val_q3 - q_3[I2-1, J2-1, K2-1] == 0): + count += 1 + + ngldm[val_q3-1, count] = ngldm[val_q3-1, count] + 1 + + # Last column was for the NaN voxels, to be removed + ngldm = np.delete(ngldm, -1, 0) + stop = np.nonzero(np.sum(ngldm, 0))[0][-1] + ngldm = np.delete(ngldm, range(stop+1, np.shape(ngldm)[1]+1), 1) + + return ngldm + +def extract_all(vol: np.ndarray) -> Dict : + """Compute NGLDM features + + Args: + vol (np.ndarray): volume with discretised intensities as 3D numpy array (x, y, z) + method (str, optional): Either 'old' (deprecated) or 'new' (faster) method. + + Raises: + ValueError: Ngldm should either be calculated using the faster \"new\" method, or the slow \"old\" method. + + Returns: + dict: Dictionary of NGTDM features. + """ + + ngldm = get_ngldm_features(vol=vol) + + return ngldm + + +def get_ngldm_features(vol: np.ndarray, + ngldm_spatial_method: str="3d", + ngldm_diff_lvl: float=0.0, + ngldm_dist: float=1.0) -> Dict: + """Extract neighbouring grey level dependence matrix-based features from the intensity roi mask. + These features refer to "Neighbouring grey level dependence based features" (ID = REK0) in + the `IBSI1 reference manual `__. + + Args: + + vol (ndarray): volume with discretised intensities as 3D numpy array (x, y, z). + intensity_range (ndarray): range of potential discretised intensities, + provided as a list: [minimal discretised intensity, maximal discretised intensity]. + If one or both values are unknown, replace the respective values with np.nan. + ngldm_spatial_method(str): spatial method which determines the way neighbouring grey level dependence + matrices are calculated and how features are determined. One of "2d", "2.5d" or "3d". + ngldm_diff_lvl (float): also called coarseness. Coarseness determines which intensity + differences are allowed for intensities to be considered similar. Typically 0, and + changing discretisation levels may have the same effect as increasing + the coarseness parameter. + ngldm_dist (float): the chebyshev distance that forms a local neighbourhood around a center voxel. + + Returns: + dict: dictionary with feature values. + """ + if type(ngldm_spatial_method) is not list: + ngldm_spatial_method = [ngldm_spatial_method] + + if type(ngldm_diff_lvl) is not list: + ngldm_diff_lvl = [ngldm_diff_lvl] + + if type(ngldm_dist) is not list: + ngldm_dist = [ngldm_dist] + + # Get the roi in tabular format + img_dims = vol.shape + index_id = np.arange(start=0, stop=vol.size) + coords = np.unravel_index(indices=index_id, shape=img_dims) + df_img = pd.DataFrame({"index_id": index_id, + "g": np.ravel(vol), + "x": coords[0], + "y": coords[1], + "z": coords[2], + "roi_int_mask": np.ravel(np.isfinite(vol))}) + + # Generate an empty feature list + feat_list = [] + + # Iterate over spatial arrangements + for ii_spatial in ngldm_spatial_method: + + # Iterate over difference levels + for ii_diff_lvl in ngldm_diff_lvl: + + # Iterate over distances + for ii_dist in ngldm_dist: + + # Initiate list of ngldm objects + ngldm_list = [] + + # Perform 2D analysis + if ii_spatial.lower() in ["2d", "2.5d"]: + + # Iterate over slices + for ii_slice in np.arange(0, img_dims[2]): + + # Add ngldm matrices to list + ngldm_list += [GreyLevelDependenceMatrix(distance=int(ii_dist), diff_lvl=ii_diff_lvl, + spatial_method=ii_spatial.lower(), img_slice=ii_slice)] + + # Perform 3D analysis + elif ii_spatial.lower() == "3d": + + # Add ngldm matrices to list + ngldm_list += [GreyLevelDependenceMatrix(distance=int(ii_dist), diff_lvl=ii_diff_lvl, + spatial_method=ii_spatial.lower(), img_slice=None)] + else: + raise ValueError("Spatial methods for ngldm should be \"2d\", \"2.5d\" or \"3d\".") + + # Calculate ngldm matrices + for ngldm in ngldm_list: + ngldm.calculate_ngldm_matrix(df_img=df_img, img_dims=img_dims) + + # Merge matrices according to the given method + upd_list = combine_ngldm_matrices(ngldm_list=ngldm_list, spatial_method=ii_spatial.lower()) + + # Calculate features + feat_run_list = [] + for ngldm in upd_list: + feat_run_list += [ngldm.compute_ngldm_features()] + + # Average feature values + feat_list += [pd.concat(feat_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] + + # Merge feature tables into a single dictionary + df_feat = pd.concat(feat_list, axis=1).to_dict(orient="records")[0] + + return df_feat + + +def combine_ngldm_matrices(ngldm_list: List, + spatial_method: str) -> List: + """Function to merge neighbouring grey level dependence matrices prior to feature calculation. + + Args: + ngldm_list (List): list of GreyLevelDependenceMatrix objects. + spatial_method (str): spatial method which determines the way neighbouring grey level + dependence matrices are calculated and how features are determined. + One of "2d", "2.5d" or "3d". + + Returns: + List: list of one or more merged GreyLevelDependenceMatrix objects. + """ + # Initiate empty list + use_list = [] + + if spatial_method == "2d": + # Average features over slice: maintain original ngldms + + # Make copy of ngldm_list + use_list = [] + for ngldm in ngldm_list: + use_list += [ngldm.copy()] + + elif spatial_method in ["2.5d", "3d"]: + # Merge all ngldms into a single representation + + # Select all matrices within the slice + sel_matrix_list = [] + for ngldm_id in np.arange(len(ngldm_list)): + sel_matrix_list += [ngldm_list[ngldm_id].matrix] + + # Check if any matrix has been created + if is_list_all_none(sel_matrix_list): + # No matrix was created + use_list += [GreyLevelDependenceMatrix(distance=ngldm_list[0].distance, diff_lvl=ngldm_list[0].diff_lvl, + spatial_method=spatial_method, img_slice=None, matrix=None, n_v=0.0)] + else: + # Merge neighbouring grey level difference matrices + merge_ngldm = pd.concat(sel_matrix_list, axis=0) + merge_ngldm = merge_ngldm.groupby(by=["i", "j"]).sum().reset_index() + + # Update the number of voxels + merge_n_v = 0.0 + for ngldm_id in np.arange(len(ngldm_list)): + merge_n_v += ngldm_list[ngldm_id].n_v + + # Create new neighbouring grey level difference matrix + use_list += [GreyLevelDependenceMatrix(distance=ngldm_list[0].distance, diff_lvl=ngldm_list[0].diff_lvl, + spatial_method=spatial_method, img_slice=None, matrix=merge_ngldm, n_v=merge_n_v)] + else: + use_list = None + + # Return to new ngldm list to calling function + return use_list + + +class GreyLevelDependenceMatrix: + + def __init__(self, + distance: float, + diff_lvl: float, + spatial_method: str, + img_slice: np.ndarray=None, + matrix: np.ndarray=None, + n_v: float=None) -> None: + """Initialising function for a new neighbouring grey level dependence ``matrix``. + + Args: + distance (float): chebyshev ``distance`` used to determine the local neighbourhood. + diff_lvl (float): coarseness parameter which determines which intensities are considered similar. + spatial_method (str): spatial method used to calculate the ngldm: 2d, 2.5d or 3d + img_slice (ndarray): corresponding slice index (only if the ngldm corresponds to a 2d image slice) + matrix (ndarray): the actual ngldm in sparse format (row, column, count) + n_v (float): the number of voxels in the volume + """ + + # Distance used + self.distance = distance + self.diff_lvl = diff_lvl + + # Slice for which the current matrix is extracted + self.img_slice = img_slice + + # Spatial analysis method (2d, 2.5d, 3d) + self.spatial_method = spatial_method + + # Place holders + self.matrix = matrix + self.n_v = n_v + + def copy(self): + """Returns a copy of the GreyLevelDependenceMatrix object.""" + return deepcopy(self) + + def set_empty(self): + """Creates an empty GreyLevelDependenceMatrix""" + self.n_v = 0 + self.matrix = None + + def calculate_ngldm_matrix(self, + df_img: pd.DataFrame, + img_dims: int): + """ + Function that calculates an ngldm for the settings provided during initialisation and the input image. + + Args: + df_img (pd.DataFrame): data table containing image intensities, x, y and z coordinates, + and mask labels corresponding to voxels in the volume. + img_dims (int): dimensions of the image volume + """ + + # Check if the input image and roi exist + if df_img is None: + self.set_empty() + return + + # Check if the roi contains any masked voxels. If this is not the case, don't construct the ngldm. + if not np.any(df_img.roi_int_mask): + self.set_empty() + return + + if self.spatial_method == "3d": + # Set up neighbour vectors + nbrs = get_neighbour_direction(d=self.distance, distance="chebyshev", centre=False, complete=True, dim3=True) + + # Set up work copy + df_ngldm = deepcopy(df_img) + elif self.spatial_method in ["2d", "2.5d"]: + # Set up neighbour vectors + nbrs = get_neighbour_direction(d=self.distance, distance="chebyshev", centre=False, complete=True, dim3=False) + + # Set up work copy + df_ngldm = deepcopy(df_img[df_img.z == self.img_slice]) + df_ngldm["index_id"] = np.arange(0, len(df_ngldm)) + df_ngldm["z"] = 0 + df_ngldm = df_ngldm.reset_index(drop=True) + else: + raise ValueError("The spatial method for neighbouring grey level dependence matrices should be one of \"2d\", \"2.5d\" or \"3d\".") + + # Set grey level of voxels outside ROI to NaN + df_ngldm.loc[df_ngldm.roi_int_mask == False, "g"] = np.nan + + # Update number of voxels for current iteration + self.n_v = np.sum(df_ngldm.roi_int_mask.values) + + # Initialise sum of grey levels and number of neighbours + df_ngldm["occur"] = 0.0 + df_ngldm["n_nbrs"] = 0.0 + + for k in range(0, np.shape(nbrs)[1]): + # Determine potential transitions from valid voxels + df_ngldm["to_index"] = coord2index(x=df_ngldm.x.values + nbrs[2, k], + y=df_ngldm.y.values + nbrs[1, k], + z=df_ngldm.z.values + nbrs[0, k], + dims=img_dims) + + # Get grey level value from transitions + df_ngldm["to_g"] = get_value(x=df_ngldm.g.values, index=df_ngldm.to_index.values) + + # Determine which voxels have valid neighbours + sel_index = np.isfinite(df_ngldm.to_g) + + # Determine co-occurrence within diff_lvl + df_ngldm.loc[sel_index, "occur"] += ((np.abs(df_ngldm.to_g - df_ngldm.g)[sel_index]) <= self.diff_lvl) * 1 + + # Work with voxels within the intensity roi + df_ngldm = df_ngldm[df_ngldm.roi_int_mask] + + # Drop superfluous columns + df_ngldm = df_ngldm.drop(labels=["index_id", "x", "y", "z", "to_index", "to_g", "roi_int_mask"], axis=1) + + # Sum s over voxels + df_ngldm = df_ngldm.groupby(by=["g", "occur"]).size().reset_index(name="n") + + # Rename columns + df_ngldm.columns = ["i", "j", "s"] + + # Add one to dependency count as features are not defined for k=0 + df_ngldm.j += 1.0 + + # Add matrix to object + self.matrix = df_ngldm + + def compute_ngldm_features(self) -> pd.DataFrame: + """Computes neighbouring grey level dependence matrix features for the current neighbouring grey level dependence matrix. + + Returns: + pandas data frame: with values for each feature. + """ + # Create feature table + feat_names = ["Fngl_lde", + "Fngl_hde", + "Fngl_lgce", + "Fngl_hgce", + "Fngl_ldlge", + "Fngl_ldhge", + "Fngl_hdlge", + "Fngl_hdhge", + "Fngl_glnu", + "Fngl_glnu_norm", + "Fngl_dcnu", + "Fngl_dcnu_norm", + "Fngl_gl_var", + "Fngl_dc_var", + "Fngl_dc_entr", + "Fngl_dc_energy"] + df_feat = pd.DataFrame(np.full(shape=(1, len(feat_names)), fill_value=np.nan)) + df_feat.columns = feat_names + + # Don't return data for empty slices or slices without a good matrix + if self.matrix is None: + # Update names + # df_feat.columns += self.parse_feature_names() + return df_feat + elif len(self.matrix) == 0: + # Update names + # df_feat.columns += self.parse_feature_names() + return df_feat + + # Dependence count dataframe + df_sij = deepcopy(self.matrix) + df_sij.columns = ("i", "j", "sij") + + # Sum over grey levels + df_si = df_sij.groupby(by="i")["sij"].agg(np.sum).reset_index().rename(columns={"sij": "si"}) + + # Sum over dependence counts + df_sj = df_sij.groupby(by="j")["sij"].agg(np.sum).reset_index().rename(columns={"sij": "sj"}) + + # Constant definitions + n_s = np.sum(df_sij.sij) * 1.0 # Number of neighbourhoods considered + n_v = self.n_v # Number of voxels + + ############################################### + # ngldm features + ############################################### + + # Low dependence emphasis + df_feat.loc[0, "Fngl_lde"] = np.sum(df_sj.sj / df_sj.j ** 2.0) / n_s + + # High dependence emphasis + df_feat.loc[0, "Fngl_hde"] = np.sum(df_sj.sj * df_sj.j ** 2.0) / n_s + + # Grey level non-uniformity + df_feat.loc[0, "Fngl_glnu"] = np.sum(df_si.si ** 2.0) / n_s + + # Grey level non-uniformity, normalised + df_feat.loc[0, "Fngl_glnu_norm"] = np.sum(df_si.si ** 2.0) / n_s ** 2.0 + + # Dependence count non-uniformity + df_feat.loc[0, "Fngl_dcnu"] = np.sum(df_sj.sj ** 2.0) / n_s + + # Dependence count non-uniformity, normalised + df_feat.loc[0, "Fngl_dcnu_norm"] = np.sum(df_sj.sj ** 2.0) / n_s ** 2.0 + + # Dependence count percentage + # df_feat.loc[0, "ngl_dc_perc"] = n_s / n_v + + # Low grey level count emphasis + df_feat.loc[0, "Fngl_lgce"] = np.sum(df_si.si / df_si.i ** 2.0) / n_s + + # High grey level count emphasis + df_feat.loc[0, "Fngl_hgce"] = np.sum(df_si.si * df_si.i ** 2.0) / n_s + + # Low dependence low grey level emphasis + df_feat.loc[0, "Fngl_ldlge"] = np.sum(df_sij.sij / (df_sij.i * df_sij.j) ** 2.0) / n_s + + # Low dependence high grey level emphasis + df_feat.loc[0, "Fngl_ldhge"] = np.sum(df_sij.sij * df_sij.i ** 2.0 / df_sij.j ** 2.0) / n_s + + # High dependence low grey level emphasis + df_feat.loc[0, "Fngl_hdlge"] = np.sum(df_sij.sij * df_sij.j ** 2.0 / df_sij.i ** 2.0) / n_s + + # High dependence high grey level emphasis + df_feat.loc[0, "Fngl_hdhge"] = np.sum(df_sij.sij * df_sij.i ** 2.0 * df_sij.j ** 2.0) / n_s + + # Grey level variance + mu = np.sum(df_sij.sij * df_sij.i) / n_s + df_feat.loc[0, "Fngl_gl_var"] = np.sum((df_sij.i - mu) ** 2.0 * df_sij.sij) / n_s + del mu + + # Dependence count variance + mu = np.sum(df_sij.sij * df_sij.j) / n_s + df_feat.loc[0, "Fngl_dc_var"] = np.sum((df_sij.j - mu) ** 2.0 * df_sij.sij) / n_s + del mu + + # Dependence count entropy + df_feat.loc[0, "Fngl_dc_entr"] = - np.sum(df_sij.sij * np.log2(df_sij.sij / n_s)) / n_s + + # Dependence count energy + df_feat.loc[0, "Fngl_dc_energy"] = np.sum(df_sij.sij ** 2.0) / (n_s ** 2.0) + + # Update names + # df_feat.columns += self.parse_feature_names() + + return df_feat + + def parse_feature_names(self): + """ + Adds additional settings-related identifiers to each feature. + Not used currently, as the use of different settings for the + neighbouring grey level dependence matrix is not supported. + """ + parse_str = "" + + # Add distance + parse_str += "_d" + str(np.round(self.distance, 1)) + + # Add difference level + parse_str += "_a" + str(np.round(self.diff_lvl, 0)) + + # Add spatial method + if self.spatial_method is not None: + parse_str += "_" + self.spatial_method + + return parse_str + +def get_dict(vol: np.ndarray) -> dict: + """ + Extract neighbouring grey level dependence matrix-based features from the intensity roi mask. + + Args: + vol (ndarray): volume with discretised intensities as 3D numpy array (x, y, z) + + Returns: + dict: dictionary with feature values + + """ + ngldm_dict = get_ngldm_features(vol, intensity_range=[np.nan, np.nan]) + return ngldm_dict + +def lde(ngldm_dict: np.ndarray)-> float: + """ + Computes low dependence emphasis feature. + This feature refers to "Fngl_lde" (ID = SODN) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: low depence emphasis value + + """ + return ngldm_dict["Fngl_lde"] + +def hde(ngldm_dict: np.ndarray)-> float: + """ + Computes high dependence emphasis feature. + This feature refers to "Fngl_hde" (ID = IMOQ) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: high depence emphasis value + + """ + return ngldm_dict["Fngl_hde"] + +def lgce(ngldm_dict: np.ndarray)-> float: + """ + Computes low grey level count emphasis feature. + This feature refers to "Fngl_lgce" (ID = TL9H) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: low grey level count emphasis value + + """ + return ngldm_dict["Fngl_lgce"] + +def hgce(ngldm_dict: np.ndarray)-> float: + """ + Computes high grey level count emphasis feature. + This feature refers to "Fngl_hgce" (ID = OAE7) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: high grey level count emphasis value + + """ + return ngldm_dict["Fngl_hgce"] + +def ldlge(ngldm_dict: np.ndarray)-> float: + """ + Computes low dependence low grey level emphasis feature. + This feature refers to "Fngl_ldlge" (ID = EQ3F) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: low dependence low grey level emphasis value + + """ + return ngldm_dict["Fngl_ldlge"] + +def ldhge(ngldm_dict: np.ndarray)-> float: + """ + Computes low dependence high grey level emphasis feature. + This feature refers to "Fngl_ldhge" (ID = JA6D) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: low dependence high grey level emphasis value + + """ + return ngldm_dict["Fngl_ldhge"] + +def hdlge(ngldm_dict: np.ndarray)-> float: + """ + Computes high dependence low grey level emphasis feature. + This feature refers to "Fngl_hdlge" (ID = NBZI) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: high dependence low grey level emphasis value + + """ + return ngldm_dict["Fngl_hdlge"] + +def hdhge(ngldm_dict: np.ndarray)-> float: + """ + Computes high dependence high grey level emphasis feature. + This feature refers to "Fngl_hdhge" (ID = 9QMG) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: high dependence high grey level emphasis value + + """ + return ngldm_dict["Fngl_hdhge"] + +def glnu(ngldm_dict: np.ndarray)-> float: + """ + Computes grey level non-uniformity feature. + This feature refers to "Fngl_glnu" (ID = FP8K) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: grey level non-uniformity value + + """ + return ngldm_dict["Fngl_glnu"] + +def glnu_norm(ngldm_dict: np.ndarray)-> float: + """ + Computes grey level non-uniformity normalised feature. + This feature refers to "Fngl_glnu_norm" (ID = 5SPA) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: grey level non-uniformity normalised value + + """ + return ngldm_dict["Fngl_glnu_norm"] + +def dcnu(ngldm_dict: np.ndarray)-> float: + """ + Computes dependence count non-uniformity feature. + This feature refers to "Fngl_dcnu" (ID = Z87G) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: dependence count non-uniformity value + + """ + return ngldm_dict["Fngl_dcnu"] + +def dcnu_norm(ngldm_dict: np.ndarray)-> float: + """ + Computes dependence count non-uniformity normalised feature. + This feature refers to "Fngl_dcnu_norm" (ID = OKJI) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: dependence count non-uniformity normalised value + + """ + return ngldm_dict["Fngl_dcnu_norm"] + +def gl_var(ngldm_dict: np.ndarray)-> float: + """ + Computes grey level variance feature. + This feature refers to "Fngl_gl_var" (ID = 1PFV) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: grey level variance value + + """ + return ngldm_dict["Fngl_gl_var"] + +def dc_var(ngldm_dict: np.ndarray)-> float: + """ + Computes dependence count variance feature. + This feature refers to "Fngl_dc_var" (ID = DNX2) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: dependence count variance value + + """ + return ngldm_dict["Fngl_dc_var"] + +def dc_entr(ngldm_dict: np.ndarray)-> float: + """ + Computes dependence count entropy feature. + This feature refers to "Fngl_dc_entr" (ID = FCBV) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: dependence count entropy value + + """ + return ngldm_dict["Fngl_dc_entr"] + +def dc_energy(ngldm_dict: np.ndarray)-> float: + """ + Computes dependence count energy feature. + This feature refers to "Fngl_dc_energy" (ID = CAS9) in + the `IBSI1 reference manual `__. + + Args: + ngldm (ndarray): array of neighbouring grey level dependence matrix + + Returns: + float: dependence count energy value + + """ + return ngldm_dict["Fngl_dc_energy"] diff --git a/MEDiml/biomarkers/ngtdm.py b/MEDiml/biomarkers/ngtdm.py new file mode 100644 index 0000000..d3a3e6d --- /dev/null +++ b/MEDiml/biomarkers/ngtdm.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + + +from typing import Dict, Tuple, Union + +import numpy as np + + +def get_matrix(roi_only:np.ndarray, + levels:np.ndarray, + dist_correction: bool=False) -> Tuple[np.ndarray, np.ndarray]: + """This function computes the Neighborhood Gray-Tone Difference Matrix + (NGTDM) of the region of interest (ROI) of an input volume. The input + volume is assumed to be isotropically resampled. The ngtdm is computed + using 26-voxel connectivity. To account for discretization length + differences, all averages around a center voxel are performed such that + the neighbours at a distance of :math:`\sqrt{3}` voxels are given a weight of + :math:`\sqrt{3}`, and the neighbours at a distance of :math:`\sqrt{2}` voxels are given a + weight of :math:`\sqrt{2}`. + This matrix refers to "Neighbourhood grey tone difference based features" (ID = IPET) + in the `IBSI1 reference manual `_. + + Note: + This function is compatible with 2D analysis (language not adapted in the text) + + Args: + roi_only (ndarray): Smallest box containing the ROI, with the imaging data ready + for texture analysis computations. Voxels outside the ROI are set to NaNs. + levels (ndarray): Vector containing the quantized gray-levels in the tumor region + (or reconstruction ``levels`` of quantization). + dist_correction (bool, optional): Set this variable to true in order to use + discretization length difference corrections as used by the `Institute of Physics and + Engineering in Medicine `_. + Set this variable to false to replicate IBSI results. + + Returns: + Tuple[np.ndarray, np.ndarray]: + - ngtdm: Neighborhood Gray-Tone Difference Matrix of ``roi_only'``. + - count_valid: Array of number of valid voxels used in the ngtdm computation. + + REFERENCES: + [1] Amadasun, M., & King, R. (1989). Textural Features Corresponding to + Textural Properties. IEEE Transactions on Systems Man and Cybernetics, + 19(5), 1264–1274. + + """ + + # PARSING "dist_correction" ARGUMENT + if type(dist_correction) is not bool: + # The user did not input either "true" or "false", + # so the default behavior is used. + dist_correction = True # By default + + # PRELIMINARY + if np.size(np.shape(roi_only)) == 2: # generalization to 2D inputs + two_d = 1 + else: + two_d = 0 + + roi_only = np.pad(roi_only, [1, 1], 'constant', constant_values=np.NaN) + + # # QUANTIZATION EFFECTS CORRECTION + # # In case (for example) we initially wanted to have 64 levels, but due to + # # quantization, only 60 resulted. + unique_vol = levels.astype('int') + NL = np.size(levels) + temp = roi_only.copy().astype('int') + for i in range(1, NL+1): + roi_only[temp == unique_vol[i-1]] = i + + # INTIALIZATION + ngtdm = np.zeros(NL) + count_valid = np.zeros(NL) + + # COMPUTATION OF ngtdm + if two_d: + indices = np.where(~np.isnan(np.reshape( + roi_only, np.size(roi_only), order='F')))[0] + pos_valid = np.unravel_index(indices, np.shape(roi_only), order='F') + n_valid_temp = np.size(pos_valid[0]) + w4 = 1 + if dist_correction: + # Weights given to different neighbors to correct + # for discretization length differences + w8 = 1/np.sqrt(2) + else: + w8 = 1 + + weights = np.array([w8, w4, w8, w4, w4, w4, w8, w4, w8]) + + for n in range(1, n_valid_temp+1): + + neighbours = roi_only[(pos_valid[0][n-1]-1):(pos_valid[0][n-1]+2), + (pos_valid[1][n-1]-1):(pos_valid[1][n-1]+2)].copy() + neighbours = np.reshape(neighbours, 9, order='F') + neighbours = neighbours*weights + value = neighbours[4].astype('int') + neighbours[4] = np.NaN + neighbours = neighbours/np.sum(weights[~np.isnan(neighbours)]) + neighbours = np.delete(neighbours, 4) # Remove the center voxel + # Thus only excluding voxels with NaNs only as neighbors. + if np.size(neighbours[~np.isnan(neighbours)]) > 0: + ngtdm[value-1] = ngtdm[value-1] + np.abs( + value-np.sum(neighbours[~np.isnan(neighbours)])) + count_valid[value-1] = count_valid[value-1] + 1 + else: + + indices = np.where(~np.isnan(np.reshape( + roi_only, np.size(roi_only), order='F')))[0] + pos_valid = np.unravel_index(indices, np.shape(roi_only), order='F') + n_valid_temp = np.size(pos_valid[0]) + w6 = 1 + if dist_correction: + # Weights given to different neighbors to correct + # for discretization length differences + w26 = 1 / np.sqrt(3) + w18 = 1 / np.sqrt(2) + else: + w26 = 1 + w18 = 1 + + weights = np.array([w26, w18, w26, w18, w6, w18, w26, w18, w26, w18, + w6, w18, w6, w6, w6, w18, w6, w18, w26, w18, + w26, w18, w6, w18, w26, w18, w26]) + + for n in range(1, n_valid_temp+1): + neighbours = roi_only[(pos_valid[0][n-1]-1) : (pos_valid[0][n-1]+2), + (pos_valid[1][n-1]-1) : (pos_valid[1][n-1]+2), + (pos_valid[2][n-1]-1) : (pos_valid[2][n-1]+2)].copy() + neighbours = np.reshape(neighbours, 27, order='F') + neighbours = neighbours * weights + value = neighbours[13].astype('int') + neighbours[13] = np.NaN + neighbours = neighbours / np.sum(weights[~np.isnan(neighbours)]) + neighbours = np.delete(neighbours, 13) # Remove the center voxel + # Thus only excluding voxels with NaNs only as neighbors. + if np.size(neighbours[~np.isnan(neighbours)]) > 0: + ngtdm[value-1] = ngtdm[value-1] + np.abs(value - np.sum(neighbours[~np.isnan(neighbours)])) + count_valid[value-1] = count_valid[value-1] + 1 + + return ngtdm, count_valid + +def extract_all(vol: np.ndarray, + dist_correction :Union[bool, str]=None) -> Dict: + """Compute Neighbourhood grey tone difference based features. + These features refer to "Neighbourhood grey tone difference based features" (ID = IPET) in + the `IBSI1 reference manual `__. + + Args: + + vol (ndarray): 3D volume, isotropically resampled, quantized + (e.g. n_g = 32, levels = [1, ..., n_g]), with NaNs outside the region + of interest. + dist_correction (Union[bool, str], optional): Set this variable to true in order to use + discretization length difference corrections as used + by the `Institute of Physics and Engineering in + Medicine `__. + Set this variable to false to replicate IBSI results. + Or use string and specify the norm for distance weighting. + Weighting is only performed if this argument is + "manhattan", "euclidean" or "chebyshev". + + Returns: + Dict: Dict of Neighbourhood grey tone difference based features. + """ + ngtdm_features = {'Fngt_coarseness': [], + 'Fngt_contrast': [], + 'Fngt_busyness': [], + 'Fngt_complexity': [], + 'Fngt_strength': []} + + # GET THE NGTDM MATRIX + # Correct definition, without any assumption + levels = np.arange(1, np.max(vol[~np.isnan(vol[:])].astype("int"))+1) + ngtdm, count_valid = get_matrix(vol, levels, dist_correction) + + n_tot = np.sum(count_valid) + # Now representing the probability of gray-level occurences + count_valid = count_valid/n_tot + nl = np.size(ngtdm) + n_g = np.sum(count_valid != 0) + p_valid = np.where(np.reshape(count_valid, np.size( + count_valid), order='F') > 0)[0]+1 + n_valid = np.size(p_valid) + + # COMPUTING TEXTURES + # Coarseness + coarseness = 1 / np.matmul(np.transpose(count_valid), ngtdm) + coarseness = min(coarseness, 10**6) + ngtdm_features['Fngt_coarseness'] = coarseness + + # Contrast + if n_g == 1: + ngtdm_features['Fngt_contrast'] = 0 + else: + val = 0 + for i in range(1, nl+1): + for j in range(1, nl+1): + val = val + count_valid[i-1] * count_valid[j-1] * ((i-j)**2) + ngtdm_features['Fngt_contrast'] = val * np.sum(ngtdm) / (n_g*(n_g-1)*n_tot) + + # Busyness + if n_g == 1: + ngtdm_features['Fngt_busyness'] = 0 + else: + denom = 0 + for i in range(1, n_valid+1): + for j in range(1, n_valid+1): + denom = denom + np.abs(p_valid[i-1]*count_valid[p_valid[i-1]-1] - + p_valid[j-1]*count_valid[p_valid[j-1]-1]) + ngtdm_features['Fngt_busyness'] = np.matmul(np.transpose(count_valid), ngtdm) / denom + + # Complexity + val = 0 + for i in range(1, n_valid+1): + for j in range(1, n_valid+1): + val = val + (np.abs( + p_valid[i-1]-p_valid[j-1]) / (n_tot*( + count_valid[p_valid[i-1]-1] + + count_valid[p_valid[j-1]-1])))*( + count_valid[p_valid[i-1]-1]*ngtdm[p_valid[i-1]-1] + + count_valid[p_valid[j-1]-1]*ngtdm[p_valid[j-1]-1]) + + ngtdm_features['Fngt_complexity'] = val + + # Strength + if np.sum(ngtdm) == 0: + ngtdm_features['Fngt_strength'] = 0 + else: + val = 0 + for i in range(1, n_valid+1): + for j in range(1, n_valid+1): + val = val + (count_valid[p_valid[i-1]-1] + count_valid[p_valid[j-1]-1])*( + p_valid[i-1]-p_valid[j-1])**2 + + ngtdm_features['Fngt_strength'] = val/np.sum(ngtdm) + + return ngtdm_features + +def get_single_matrix(vol: np.ndarray, + dist_correction = None)-> Tuple[np.ndarray, + np.ndarray]: + """Compute neighbourhood grey tone difference matrix in order to compute the single features. + + Args: + + vol (ndarray): 3D volume, isotropically resampled, quantized + (e.g. n_g = 32, levels = [1, ..., n_g]), with NaNs outside the region of interest. + dist_correction (Union[bool, str], optional): Set this variable to true in order to use + discretization length difference corrections as used + by the `Institute of Physics and Engineering in + Medicine `__. + Set this variable to false to replicate IBSI results. + Or use string and specify the norm for distance weighting. + Weighting is only performed if this argument is + "manhattan", "euclidean" or "chebyshev". + + Returns: + np.ndarray: array of neighbourhood grey tone difference matrix + """ + # GET THE NGTDM MATRIX + # Correct definition, without any assumption + levels = np.arange(1, np.max(vol[~np.isnan(vol[:])].astype("int"))+1) + + ngtdm, count_valid = get_matrix(vol, levels, dist_correction) + + return ngtdm, count_valid + +def coarseness(ngtdm: np.ndarray, count_valid: np.ndarray)-> float: + """ + Computes coarseness feature. + This feature refers to "Coarseness" (ID = QCDE) in + the `IBSI1 reference manual `__. + + Args: + ngtdm (ndarray): array of neighbourhood grey tone difference matrix + + Returns: + float: coarseness value + + """ + n_tot = np.sum(count_valid) + count_valid = count_valid/n_tot + coarseness = 1 / np.matmul(np.transpose(count_valid), ngtdm) + coarseness = min(coarseness, 10**6) + return coarseness + +def contrast(ngtdm: np.ndarray, count_valid: np.ndarray)-> float: + """ + Computes contrast feature. + This feature refers to "Contrast" (ID = 65HE) in + the `IBSI1 reference manual `__. + + Args: + ngtdm (ndarray): array of neighbourhood grey tone difference matrix + + Returns: + float: contrast value + + """ + n_tot = np.sum(count_valid) + count_valid = count_valid/n_tot + nl = np.size(ngtdm) + n_g = np.sum(count_valid != 0) + + if n_g == 1: + return 0 + else: + val = 0 + for i in range(1, nl+1): + for j in range(1, nl+1): + val = val + count_valid[i-1] * count_valid[j-1] * ((i-j)**2) + contrast = val * np.sum(ngtdm) / (n_g*(n_g-1)*n_tot) + return contrast + +def busyness(ngtdm: np.ndarray, count_valid: np.ndarray)-> float: + """ + Computes busyness feature. + This feature refers to "Busyness" (ID = NQ30) in + the `IBSI1 reference manual `__. + + Args: + ngtdm (ndarray): array of neighbourhood grey tone difference matrix + + Returns: + float: busyness value + + """ + n_tot = np.sum(count_valid) + count_valid = count_valid/n_tot + n_g = np.sum(count_valid != 0) + p_valid = np.where(np.reshape(count_valid, np.size( + count_valid), order='F') > 0)[0]+1 + n_valid = np.size(p_valid) + + if n_g == 1: + busyness = 0 + return busyness + else: + denom = 0 + for i in range(1, n_valid+1): + for j in range(1, n_valid+1): + denom = denom + np.abs(p_valid[i-1]*count_valid[p_valid[i-1]-1] - + p_valid[j-1]*count_valid[p_valid[j-1]-1]) + busyness = np.matmul(np.transpose(count_valid), ngtdm) / denom + return busyness + +def complexity(ngtdm: np.ndarray, count_valid: np.ndarray)-> float: + """ + Computes complexity feature. + This feature refers to "Complexity" (ID = HDEZ) in + the `IBSI1 reference manual `__. + + Args: + ngtdm (ndarray): array of neighbourhood grey tone difference matrix + + Returns: + float: complexity value + + """ + n_tot = np.sum(count_valid) + # Now representing the probability of gray-level occurences + count_valid = count_valid/n_tot + p_valid = np.where(np.reshape(count_valid, np.size( + count_valid), order='F') > 0)[0]+1 + n_valid = np.size(p_valid) + + val = 0 + for i in range(1, n_valid+1): + for j in range(1, n_valid+1): + val = val + (np.abs( + p_valid[i-1]-p_valid[j-1]) / (n_tot*( + count_valid[p_valid[i-1]-1] + + count_valid[p_valid[j-1]-1])))*( + count_valid[p_valid[i-1]-1]*ngtdm[p_valid[i-1]-1] + + count_valid[p_valid[j-1]-1]*ngtdm[p_valid[j-1]-1]) + complexity = val + return complexity + +def strength(ngtdm: np.ndarray, count_valid: np.ndarray)-> float: + """ + Computes strength feature. + This feature refers to "Strength" (ID = 1X9X) in + the `IBSI1 reference manual `__. + + Args: + ngtdm (ndarray): array of neighbourhood grey tone difference matrix + + Returns: + float: strength value + + """ + + n_tot = np.sum(count_valid) + # Now representing the probability of gray-level occurences + count_valid = count_valid/n_tot + p_valid = np.where(np.reshape(count_valid, np.size( + count_valid), order='F') > 0)[0]+1 + n_valid = np.size(p_valid) + + if np.sum(ngtdm) == 0: + strength = 0 + return strength + else: + val = 0 + for i in range(1, n_valid+1): + for j in range(1, n_valid+1): + val = val + (count_valid[p_valid[i-1]-1] + count_valid[p_valid[j-1]-1])*( + p_valid[i-1]-p_valid[j-1])**2 + + strength = val/np.sum(ngtdm) + return strength diff --git a/MEDiml/biomarkers/stats.py b/MEDiml/biomarkers/stats.py new file mode 100644 index 0000000..1dc0c3e --- /dev/null +++ b/MEDiml/biomarkers/stats.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import numpy as np +from scipy.stats import iqr, kurtosis, skew, scoreatpercentile, variation + + +def extract_all(vol: np.ndarray, intensity_type: str) -> dict: + """Computes Intensity-based statistical features. + These features refer to "Intensity-based statistical features" (ID = UHIW) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution). + intensity_type (str): Type of intensity to compute. Can be "arbitrary", "definite" or "filtered". + Will compute features only for "definite" intensity type. + + Return: + dict: Dictionnary containing all stats features. + + Raises: + ValueError: If `intensity_type` is not "arbitrary", "definite" or "filtered". + """ + assert intensity_type in ["arbitrary", "definite", "filtered"], \ + "intensity_type must be 'arbitrary', 'definite' or 'filtered'" + + x = vol[~np.isnan(vol[:])] # Initialization + + # Initialization of final structure (Dictionary) containing all features. + stats = {'Fstat_mean': [], + 'Fstat_var': [], + 'Fstat_skew': [], + 'Fstat_kurt': [], + 'Fstat_median': [], + 'Fstat_min': [], + 'Fstat_P10': [], + 'Fstat_P90': [], + 'Fstat_max': [], + 'Fstat_iqr': [], + 'Fstat_range': [], + 'Fstat_mad': [], + 'Fstat_rmad': [], + 'Fstat_medad': [], + 'Fstat_cov': [], + 'Fstat_qcod': [], + 'Fstat_energy': [], + 'Fstat_rms': [] + } + + # STARTING COMPUTATION + if intensity_type == "definite": + stats['Fstat_mean'] = np.mean(x) # Mean + stats['Fstat_var'] = np.var(x) # Variance + stats['Fstat_skew'] = skew(x) # Skewness + stats['Fstat_kurt'] = kurtosis(x) # Kurtosis + stats['Fstat_median'] = np.median(x) # Median + stats['Fstat_min'] = np.min(x) # Minimum grey level + stats['Fstat_P10'] = scoreatpercentile(x, 10) # 10th percentile + stats['Fstat_P90'] = scoreatpercentile(x, 90) # 90th percentile + stats['Fstat_max'] = np.max(x) # Maximum grey level + stats['Fstat_iqr'] = iqr(x) # Interquantile range + stats['Fstat_range'] = np.ptp(x) # Range max(x) - min(x) + stats['Fstat_mad'] = np.mean(np.absolute(x - np.mean(x))) # Mean absolute deviation + x_10_90 = x[np.where((x >= stats['Fstat_P10']) & + (x <= stats['Fstat_P90']), True, False)] + stats['Fstat_rmad'] = np.mean(np.abs(x_10_90 - np.mean(x_10_90))) # Robust mean absolute deviation + stats['Fstat_medad'] = np.mean(np.absolute(x - np.median(x))) # Median absolute deviation + stats['Fstat_cov'] = variation(x) # Coefficient of variation + x_75_25 = scoreatpercentile(x, 75) + scoreatpercentile(x, 25) + stats['Fstat_qcod'] = iqr(x)/x_75_25 # Quartile coefficient of dispersion + stats['Fstat_energy'] = np.sum(np.power(x, 2)) # Energy + stats['Fstat_rms'] = np.sqrt(np.mean(np.power(x, 2))) # Root mean square + + return stats + +def mean(vol: np.ndarray) -> float: + """Computes statistical mean feature of the input dataset (3D Array). + This feature refers to "Fstat_mean" (ID = Q4LE) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Statistical mean feature + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.mean(x) # Mean + +def var(vol: np.ndarray) -> float: + """Computes statistical variance feature of the input dataset (3D Array). + This feature refers to "Fstat_var" (ID = ECT3) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Statistical variance feature + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.var(x) # Variance + +def skewness(vol: np.ndarray) -> float: + """Computes the sample skewness feature of the input dataset (3D Array). + This feature refers to "Fstat_skew" (ID = KE2A) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: The skewness feature of values along an axis. Returning 0 where all values are + equal. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return skew(x) # Skewness + +def kurt(vol: np.ndarray) -> float: + """Computes the kurtosis (Fisher or Pearson) feature of the input dataset (3D Array). + This feature refers to "Fstat_kurt" (ID = IPH6) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: The kurtosis feature of values along an axis. If all values are equal, + return -3 for Fisher's definition and 0 for Pearson's definition. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return kurtosis(x) # Kurtosis + +def median(vol: np.ndarray) -> float: + """Computes the median feature along the specified axis of the input dataset (3D Array). + This feature refers to "Fstat_median" (ID = Y12H) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: The median feature of the array elements. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.median(x) # Median + +def min(vol: np.ndarray) -> float: + """Computes the minimum grey level feature of the input dataset (3D Array). + This feature refers to "Fstat_min" (ID = 1GSF) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: The minimum grey level feature of the array elements. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.min(x) # Minimum grey level + +def p10(vol: np.ndarray) -> float: + """Computes the score at the 10th percentile feature of the input dataset (3D Array). + This feature refers to "Fstat_P10" (ID = QG58) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Score at 10th percentil. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return scoreatpercentile(x, 10) # 10th percentile + +def p90(vol: np.ndarray) -> float: + """Computes the score at the 90th percentile feature of the input dataset (3D Array). + This feature refers to "Fstat_P90" (ID = 8DWT) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Score at 90th percentil. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return scoreatpercentile(x, 90) # 90th percentile + +def max(vol: np.ndarray) -> float: + """Computes the maximum grey level feature of the input dataset (3D Array). + This feature refers to "Fstat_max" (ID = 84IY) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: The maximum grey level feature of the array elements. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.max(x) # Maximum grey level + +def iqrange(vol: np.ndarray) -> float: + """Computes the interquartile range feature of the input dataset (3D Array). + This feature refers to "Fstat_iqr" (ID = SALO) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: Interquartile range. If axis != None, the output data-type is the same as that of the input. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return iqr(x) # Interquartile range + +def range(vol: np.ndarray) -> float: + """Range of values (maximum - minimum) feature along an axis of the input dataset (3D Array). + This feature refers to "Fstat_range" (ID = 2OJQ) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: A new array holding the range of values, unless out was specified, + in which case a reference to out is returned. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.ptp(x) # Range max(x) - min(x) + +def mad(vol: np.ndarray) -> float: + """Mean absolute deviation feature of the input dataset (3D Array). + This feature refers to "Fstat_mad" (ID = 4FUA) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float : A new array holding mean absolute deviation feature. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.mean(np.absolute(x - np.mean(x))) # Mean absolute deviation + +def rmad(vol: np.ndarray) -> float: + """Robust mean absolute deviation feature of the input dataset (3D Array). + This feature refers to "Fstat_rmad" (ID = 1128) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + P10(ndarray): Score at 10th percentil. + P90(ndarray): Score at 90th percentil. + + Returns: + float: A new array holding the robust mean absolute deviation. + """ + x = vol[~np.isnan(vol[:])] # Initialization + P10 = scoreatpercentile(x, 10) # 10th percentile + P90 = scoreatpercentile(x, 90) # 90th percentile + x_10_90 = x[np.where((x >= P10) & + (x <= P90), True, False)] # Holding x for (x >= P10) and (x<= P90) + + return np.mean(np.abs(x_10_90 - np.mean(x_10_90))) # Robust mean absolute deviation + +def medad(vol: np.ndarray) -> float: + """Median absolute deviation feature of the input dataset (3D Array). + This feature refers to "Fstat_medad" (ID = N72L) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: A new array holding the median absolute deviation feature. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.mean(np.absolute(x - np.median(x))) # Median absolute deviation + +def cov(vol: np.ndarray) -> float: + """Computes the coefficient of variation feature of the input dataset (3D Array). + This feature refers to "Fstat_cov" (ID = 7TET) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: A new array holding the coefficient of variation feature. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return variation(x) # Coefficient of variation + +def qcod(vol: np.ndarray) -> float: + """Computes the quartile coefficient of dispersion feature of the input dataset (3D Array). + This feature refers to "Fstat_qcod" (ID = 9S40) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: A new array holding the quartile coefficient of dispersion feature. + """ + x = vol[~np.isnan(vol[:])] # Initialization + x_75_25 = scoreatpercentile(x, 75) + scoreatpercentile(x, 25) + + return iqr(x) / x_75_25 # Quartile coefficient of dispersion + +def energy(vol: np.ndarray) -> float: + """Computes the energy feature of the input dataset (3D Array). + This feature refers to "Fstat_energy" (ID = N8CA) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: A new array holding the energy feature. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.sum(np.power(x, 2)) # Energy + +def rms(vol: np.ndarray) -> float: + """Computes the root mean square feature of the input dataset (3D Array). + This feature refers to "Fstat_rms" (ID = 5ZWQ) in + the `IBSI1 reference manual `_. + + Args: + vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest + (continuous imaging intensity distribution) + + Returns: + float: A new array holding the root mean square feature. + """ + x = vol[~np.isnan(vol[:])] # Initialization + + return np.sqrt(np.mean(np.power(x, 2))) # Root mean square diff --git a/MEDiml/biomarkers/utils.py b/MEDiml/biomarkers/utils.py new file mode 100644 index 0000000..6a524bd --- /dev/null +++ b/MEDiml/biomarkers/utils.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import math +from typing import List, Tuple, Union + +import numpy as np +from skimage.measure import marching_cubes + + +def find_i_x(levels: np.ndarray, + fract_vol: np.ndarray, + x: float) -> np.ndarray: + """Computes intensity at volume fraction. + + Args: + levels (ndarray): COMPLETE INTEGER grey-levels. + fract_vol (ndarray): Fractional volume. + x (float): Fraction percentage, between 0 and 100. + + Returns: + ndarray: Array of minimum discretised intensity present in at most :math:`x` % of the volume. + + """ + ind = np.where(fract_vol <= x/100)[0][0] + ix = levels[ind] + + return ix + +def find_v_x(fract_int: np.ndarray, + fract_vol: np.ndarray, + x: float) -> np.ndarray: + """Computes volume at intensity fraction. + + Args: + fract_int (ndarray): Intensity fraction. + fract_vol (ndarray): Fractional volume. + x (float): Fraction percentage, between 0 and 100. + + Returns: + ndarray: Array of largest volume fraction ``fract_vol`` that has an + intensity fraction ``fract_int`` of at least :math:`x` %. + + """ + ind = np.where(fract_int >= x/100)[0][0] + vx = fract_vol[ind] + + return vx + +def get_area_dens_approx(a: float, + b: float, + c: float, + n: float) -> float: + """Computes area density - minimum volume enclosing ellipsoid + + Args: + a (float): Major semi-axis length. + b (float): Minor semi-axis length. + c (float): Least semi-axis length. + n (int): Number of iterations. + + Returns: + float: Area density - minimum volume enclosing ellipsoid. + + """ + alpha = np.sqrt(1 - b**2/a**2) + beta = np.sqrt(1 - c**2/a**2) + ab = alpha * beta + point = (alpha**2+beta**2) / (2*ab) + a_ell = 0 + + for v in range(0, n+1): + coef = [0]*v + [1] + legen = np.polynomial.legendre.legval(x=point, c=coef) + a_ell = a_ell + ab**v / (1-4*v**2) * legen + + a_ell = a_ell * 4 * np.pi * a * b + + return a_ell + +def get_axis_lengths(xyz: np.ndarray) -> Tuple[float, float, float]: + """Computes AxisLengths. + + Args: + xyz (ndarray): Array of three column vectors, defining the [X,Y,Z] + positions of the points in the ROI (1's) of the mask volume. In mm. + + Returns: + Tuple[float, float, float]: Array of three column vectors + [Major axis lengths, Minor axis lengths, Least axis lengths]. + + """ + xyz = xyz.copy() + + # Getting the geometric centre of mass + com_geom = np.sum(xyz, 0)/np.shape(xyz)[0] # [1 X 3] vector + + # Subtracting the centre of mass + xyz[:, 0] = xyz[:, 0] - com_geom[0] + xyz[:, 1] = xyz[:, 1] - com_geom[1] + xyz[:, 2] = xyz[:, 2] - com_geom[2] + + # Getting the covariance matrix + cov_mat = np.cov(xyz, rowvar=False) + + # Getting the eigenvalues + eig_val, _ = np.linalg.eig(cov_mat) + eig_val = np.sort(eig_val) + + major = eig_val[2] + minor = eig_val[1] + least = eig_val[0] + + return major, minor, least + +def get_glcm_cross_diag_prob(p_ij: np.ndarray) -> np.ndarray: + """Computes cross diagonal probabilities. + + Args: + p_ij (ndarray): Joint probability of grey levels + i and j occurring in neighboring voxels. (Elements + of the probability distribution for grey level + co-occurrences). + + Returns: + ndarray: Array of the cross diagonal probability. + + """ + n_g = np.size(p_ij, 0) + val_k = np.arange(2, 2*n_g + 100*np.finfo(float).eps) + n_k = np.size(val_k) + p_iplusj = np.zeros(n_k) + + for iteration_k in range(0, n_k): + k = val_k[iteration_k] + p = 0 + for i in range(0, n_g): + for j in range(0, n_g): + if (k - (i+j+2)) == 0: + p += p_ij[i, j] + + p_iplusj[iteration_k] = p + + return p_iplusj + +def get_glcm_diag_prob(p_ij: np.ndarray) -> np.ndarray: + """Computes diagonal probabilities. + + Args: + p_ij (ndarray): Joint probability of grey levels + i and j occurring in neighboring voxels. (Elements + of the probability distribution for grey level + co-occurrences). + + Returns: + ndarray: Array of the diagonal probability. + + """ + + n_g = np.size(p_ij, 0) + val_k = np.arange(0, n_g) + n_k = np.size(val_k) + p_iminusj = np.zeros(n_k) + + for iteration_k in range(0, n_k): + k = val_k[iteration_k] + p = 0 + for i in range(0, n_g): + for j in range(0, n_g): + if (k - abs(i-j)) == 0: + p += p_ij[i, j] + + p_iminusj[iteration_k] = p + + return p_iminusj + +def get_com(xgl_int: np.ndarray, + xgl_morph: np.ndarray, + xyz_int: np.ndarray, + xyz_morph: np.ndarray) -> Union[float, + np.ndarray]: + """Calculates center of mass shift (in mm, since resolution is in mm). + + Note: + Row positions of "x_gl" and "xyz" must correspond for each point. + + Args: + xgl_int (ndarray): Vector of intensity values in the volume to analyze + (only values in the intensity mask). + xgl_morph (ndarray): Vector of intensity values in the volume to analyze + (only values in the morphological mask). + xyz_int (ndarray): [n_points X 3] matrix of three column vectors, defining the [X,Y,Z] + positions of the points in the ROI (1's) of the mask volume (In mm). + (Mesh-based volume calculated from the ROI intensity mesh) + xyz_morph (ndarray): [n_points X 3] matrix of three column vectors, defining the [X,Y,Z] + positions of the points in the ROI (1's) of the mask volume (In mm). + (Mesh-based volume calculated from the ROI morphological mesh) + + Returns: + Union[float, np.ndarray]: The ROI volume centre of mass. + + """ + + # Getting the geometric centre of mass + n_v = np.size(xgl_morph) + + com_geom = np.sum(xyz_morph, 0)/n_v # [1 X 3] vector + + # Getting the density centre of mass + xyz_int[:, 0] = xgl_int*xyz_int[:, 0] + xyz_int[:, 1] = xgl_int*xyz_int[:, 1] + xyz_int[:, 2] = xgl_int*xyz_int[:, 2] + com_gl = np.sum(xyz_int, 0)/np.sum(xgl_int, 0) # [1 X 3] vector + + # Calculating the shift + com = np.linalg.norm(com_geom - com_gl) + + return com + +def get_loc_peak(img_obj: np.ndarray, + roi_obj: np.ndarray, + res: np.ndarray) -> float: + """Computes Local intensity peak. + + Note: + This works only in 3D for now. + + Args: + img_obj (ndarray): Continuos image intensity distribution, with no NaNs + outside the ROI. + roi_obj (ndarray): Array of the mask defining the ROI. + res (List[float]): [a,b,c] vector specifying the resolution of the volume in mm. + xyz resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the local intensity peak. + + """ + # INITIALIZATION + # About 6.2 mm, as defined in document + dist_thresh = (3/(4*math.pi))**(1/3)*10 + + # Insert -inf outside ROI + temp = img_obj.copy() + img_obj = img_obj.copy() + img_obj[roi_obj == 0] = -np.inf + + # Find the location(s) of the maximal voxel + max_val = np.max(img_obj) + I, J, K = np.nonzero(img_obj == max_val) + n_max = np.size(I) + + # Reconverting to full object without -Inf + img_obj = temp + + # Get a meshgrid first + x = res[0]*(np.arange(img_obj.shape[1])+0.5) + y = res[1]*(np.arange(img_obj.shape[0])+0.5) + z = res[2]*(np.arange(img_obj.shape[2])+0.5) + X, Y, Z = np.meshgrid(x, y, z) # In mm + + # Calculate the local peak + max_val = -np.inf + + for n in range(n_max): + temp_x = X - X[I[n], J[n], K[n]] + temp_y = Y - Y[I[n], J[n], K[n]] + temp_z = Z - Z[I[n], J[n], K[n]] + temp_dist_mesh = (np.sqrt(np.power(temp_x, 2) + + np.power(temp_y, 2) + + np.power(temp_z, 2))) + val = img_obj[temp_dist_mesh <= dist_thresh] + val[np.isnan(val)] = [] + + if np.size(val) == 0: + temp_local_peak = img_obj[I[n], J[n], K[n]] + else: + temp_local_peak = np.mean(val) + if temp_local_peak > max_val: + max_val = temp_local_peak + + local_peak = max_val + + return local_peak + +def get_mesh(mask: np.ndarray, + res: Union[np.ndarray, List]) -> Tuple[np.ndarray, + np.ndarray, + np.ndarray]: + """Compute Mesh. + + Note: + Make sure the `mask` is padded with a layer of 0's in all + dimensions to reduce potential isosurface computation errors. + + Args: + mask (ndarray): Contains only 0's and 1's. + res (ndarray or List): [a,b,c] vector specifying the resolution of the volume in mm. + xyz resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + Tuple[np.ndarray, np.ndarray, np.ndarray]: + - Array of the [X,Y,Z] positions of the ROI. + - Array of the spatial coordinates for `mask` unique mesh vertices. + - Array of triangular faces via referencing vertex indices from vertices. + """ + # Getting the grid of X,Y,Z positions, where the coordinate reference + # system (0,0,0) is located at the upper left corner of the first voxel + # (-0.5: half a voxel distance). For the whole volume defining the mask, + # no matter if it is a 1 or a 0. + mask = mask.copy() + res = res.copy() + + x = res[0]*((np.arange(1, np.shape(mask)[0]+1))-0.5) + y = res[1]*((np.arange(1, np.shape(mask)[1]+1))-0.5) + z = res[2]*((np.arange(1, np.shape(mask)[2]+1))-0.5) + X, Y, Z = np.meshgrid(x, y, z, indexing='ij') + + # Getting the isosurface of the mask + vertices, faces, _, _ = marching_cubes(volume=mask, level=0.5, spacing=res) + + # Getting the X,Y,Z positions of the ROI (i.e. 1's) of the mask + X = np.reshape(X, (np.size(X), 1), order='F') + Y = np.reshape(Y, (np.size(Y), 1), order='F') + Z = np.reshape(Z, (np.size(Z), 1), order='F') + + xyz = np.concatenate((X, Y, Z), axis=1) + xyz = xyz[np.where(np.reshape(mask, np.size(mask), order='F') == 1)[0], :] + + return xyz, faces, vertices + +def get_glob_peak(img_obj: np.ndarray, + roi_obj: np.ndarray, + res: np.ndarray) -> float: + """Computes Global intensity peak. + + Note: + This works only in 3D for now. + + Args: + img_obj (ndarray): Continuos image intensity distribution, with no NaNs + outside the ROI. + roi_obj (ndarray): Array of the mask defining the ROI. + res (List[float]): [a,b,c] vector specifying the resolution of the volume in mm. + xyz resolution (world), or JIK resolution (intrinsic matlab). + + Returns: + float: Value of the global intensity peak. + + """ + # INITIALIZATION + # About 6.2 mm, as defined in document + dist_thresh = (3/(4*math.pi))**(1/3)*10 + + # Find the location(s) of all voxels within the ROI + indices = np.nonzero(np.reshape(roi_obj, np.size(roi_obj), order='F') == 1)[0] + I, J, K = np.unravel_index(indices, np.shape(img_obj), order='F') + n_max = np.size(I) + + # Get a meshgrid first + x = res[0]*(np.arange(img_obj.shape[1])+0.5) + y = res[1]*(np.arange(img_obj.shape[0])+0.5) + z = res[2]*(np.arange(img_obj.shape[2])+0.5) + X, Y, Z = np.meshgrid(x, y, z) # In mm + + # Calculate the local peak + max_val = -np.inf + + for n in range(n_max): + temp_x = X - X[I[n], J[n], K[n]] + temp_y = Y - Y[I[n], J[n], K[n]] + temp_z = Z - Z[I[n], J[n], K[n]] + temp_dist_mesh = (np.sqrt(np.power(temp_x, 2) + + np.power(temp_y, 2) + + np.power(temp_z, 2))) + val = img_obj[temp_dist_mesh <= dist_thresh] + val[np.isnan(val)] = [] + + if np.size(val) == 0: + temp_local_peak = img_obj[I[n], J[n], K[n]] + else: + temp_local_peak = np.mean(val) + if temp_local_peak > max_val: + max_val = temp_local_peak + + global_peak = max_val + + return global_peak + \ No newline at end of file diff --git a/MEDiml/filters/TexturalFilter.py b/MEDiml/filters/TexturalFilter.py new file mode 100644 index 0000000..c98293a --- /dev/null +++ b/MEDiml/filters/TexturalFilter.py @@ -0,0 +1,299 @@ +from copy import deepcopy +from typing import Union + +import numpy as np +try: + import pycuda.autoinit + import pycuda.driver as cuda + from pycuda.autoinit import context + from pycuda.compiler import SourceModule +except Exception as e: + print("PyCUDA is not installed. Please install it to use the textural filters.", e) + import_failed = True + +from ..processing.discretisation import discretize +from .textural_filters_kernels import glcm_kernel, single_glcm_kernel + + +class TexturalFilter(): + """The Textural filter class. This class is used to apply textural filters to an image. The textural filters are + chosen from the following families: GLCM, NGTDM, GLDZM, GLSZM, NGLDM, GLRLM. The computation is done using CUDA.""" + + def __init__( + self, + family: str, + size: int = 3, + local: bool = False + ): + + """ + The constructor for the textural filter class. + + Args: + family (str): The family of the textural filter. + size (int, optional): The size of the kernel, which will define the filter kernel dimension. + local (bool, optional): If true, the discrete will be computed locally, else globally. + + Returns: + None. + """ + + assert size % 2 == 1 and size > 0, "size should be a positive odd number." + assert isinstance(family, str) and family.upper() in ["GLCM", "NGTDM", "GLDZM", "GLSZM", "NGLDM", "GLRLM"],\ + "family should be a string and should be one of the following: GLCM, NGTDM, GLDZM, GLSZM, NGLDM, GLRLM." + + self.family = family + self.size = size + self.local = local + self.glcm_features = [ + "Fcm_joint_max", + "Fcm_joint_avg", + "Fcm_joint_var", + "Fcm_joint_entr", + "Fcm_diff_avg", + "Fcm_diff_var", + "Fcm_diff_entr", + "Fcm_sum_avg", + "Fcm_sum_var", + "Fcm_sum_entr", + "Fcm_energy", + "Fcm_contrast", + "Fcm_dissimilarity", + "Fcm_inv_diff", + "Fcm_inv_diff_norm", + "Fcm_inv_diff_mom", + "Fcm_inv_diff_mom_norm", + "Fcm_inv_var", + "Fcm_corr", + "Fcm_auto_corr", + "Fcm_clust_tend", + "Fcm_clust_shade", + "Fcm_clust_prom", + "Fcm_info_corr1", + "Fcm_info_corr2" + ] + + def __glcm_filter( + self, + input_images: np.ndarray, + discretization : dict, + user_set_min_val: float, + feature = None + ) -> np.ndarray: + """ + Apply a textural filter to the input image. + + Args: + input_images (ndarray): The images to filter. + discretization (dict): The discretization parameters. + user_set_min_val (float): The minimum value to use for the discretization. + family (str, optional): The family of the textural filter. + feature (str, optional): The feature to extract from the family. if not specified, all the features of the + family will be extracted. + + Returns: + ndarray: The filtered image. + """ + + if feature: + if isinstance(feature, str): + assert feature in self.glcm_features,\ + "feature should be a string or an integer and should be one of the following: " + ", ".join(self.glcm_features) + "." + elif isinstance(feature, int): + assert feature in range(len(self.glcm_features)),\ + "feature's index should be an integer between 0 and " + str(len(self.glcm_features) - 1) + "." + else: + raise TypeError("feature should be an integer or a string from the following list: " + ", ".join(self.glcm_features) + ".") + + + # Pre-processing of the input volume + padding_size = (self.size - 1) // 2 + input_images = np.pad(input_images[:, :, :], padding_size, mode="constant", constant_values=np.nan) + input_images_copy = deepcopy(input_images) + + # Set up the strides + strides = ( + input_images_copy.shape[2] * input_images_copy.shape[1] * input_images_copy.dtype.itemsize, + input_images_copy.shape[2] * input_images_copy.dtype.itemsize, + input_images_copy.dtype.itemsize + ) + input_images = np.lib.stride_tricks.as_strided(input_images, shape=input_images.shape, strides=strides) + input_images[:,:,:] = input_images_copy[:, :, :] + + if self.local: + # Discretization (to get the global max value) + if discretization['type'] == "FBS": + print("Warning: FBS local discretization is equivalent to global discretization.") + n_q = discretization['bw'] + elif discretization['type'] == "FBN" and discretization['adapted']: + n_q = (np.nanmax(input_images) - np.nanmin(input_images)) // discretization['bw'] + user_set_min_val = np.nanmin(input_images) + elif discretization['type'] == "FBN": + n_q = discretization['bn'] + user_set_min_val = np.nanmin(input_images) + else: + raise ValueError("Discretization should be either FBS or FBN.") + + temp_vol, _ = discretize( + vol_re=input_images, + discr_type=discretization['type'], + n_q=n_q, + user_set_min_val=user_set_min_val, + ivh=False + ) + + # Initialize the filtering parameters + max_vol = np.nanmax(temp_vol) + + del temp_vol + + else: + # Discretization + if discretization['type'] == "FBS": + n_q = discretization['bw'] + elif discretization['type'] == "FBN": + n_q = discretization['bn'] + user_set_min_val = np.nanmin(input_images) + else: + raise ValueError("Discretization should be either FBS or FBN.") + + input_images, _ = discretize( + vol_re=input_images, + discr_type=discretization['type'], + n_q=n_q, + user_set_min_val=user_set_min_val, + ivh=False + ) + + # Initialize the filtering parameters + max_vol = np.nanmax(input_images) + + volume_copy = deepcopy(input_images) + + # Filtering + if feature is not None: + # Select the feature to compute + feature = self.glcm_features.index(feature) if isinstance(feature, str) else feature + + # Initialize the kernel + kernel_glcm = single_glcm_kernel.substitute( + max_vol=int(max_vol), + filter_size=self.size, + shape_volume_0=int(volume_copy.shape[0]), + shape_volume_1=int(volume_copy.shape[1]), + shape_volume_2=int(volume_copy.shape[2]), + discr_type=discretization['type'], + n_q=n_q, + min_val=user_set_min_val, + feature_index=feature + ) + + else: + # Create the final volume to store the results + input_images = np.zeros((input_images.shape[0], input_images.shape[1], input_images.shape[2], 25), dtype=np.float32) + + # Fill with nan + input_images[:] = np.nan + + # Initialize the kernel + kernel_glcm = glcm_kernel.substitute( + max_vol=int(max_vol), + filter_size=self.size, + shape_volume_0=int(volume_copy.shape[0]), + shape_volume_1=int(volume_copy.shape[1]), + shape_volume_2=int(volume_copy.shape[2]), + discr_type=discretization['type'], + n_q=n_q, + min_val=user_set_min_val + ) + + # Compile the CUDA kernel + if not import_failed: + mod = SourceModule(kernel_glcm, no_extern_c=True) + if self.local: + process_loop_kernel = mod.get_function("glcm_filter_local") + else: + process_loop_kernel = mod.get_function("glcm_filter_global") + + # Allocate GPU memory + volume_gpu = cuda.mem_alloc(input_images.nbytes) + volume_gpu_copy = cuda.mem_alloc(volume_copy.nbytes) + + # Copy data to the GPU + cuda.memcpy_htod(volume_gpu, input_images) + cuda.memcpy_htod(volume_gpu_copy, volume_copy) + + # Set up the grid and block dimensions + block_dim = (16, 16, 1) # threads per block + grid_dim = ( + int((volume_copy.shape[0] - 1) // block_dim[0] + 1), + int((volume_copy.shape[1] - 1) // block_dim[1] + 1), + int((volume_copy.shape[2] - 1) // block_dim[2] + 1) + ) # blocks in the grid + + # Run the kernel + process_loop_kernel(volume_gpu, volume_gpu_copy, block=block_dim, grid=grid_dim) + + # Synchronize to ensure all CUDA operations are complete + context.synchronize() + + # Copy data back to the CPU + cuda.memcpy_dtoh(input_images, volume_gpu) + + # Free the allocated GPU memory + volume_gpu.free() + volume_gpu_copy.free() + del volume_copy + + # unpad the volume + if feature: # 3D (single-feature) + input_images = input_images[padding_size:-padding_size, padding_size:-padding_size, padding_size:-padding_size] + else: # 4D (all features) + input_images = input_images[padding_size:-padding_size, padding_size:-padding_size, padding_size:-padding_size, :] + + return input_images + + else: + return None + + def __call__( + self, + input_images: np.ndarray, + discretization : dict, + user_set_min_val: float, + family: str = "GLCM", + feature : str = None, + size: int = None, + local: bool = False + ) -> np.ndarray: + """ + Apply a textural filter to the input image. + + Args: + input_images (ndarray): The images to filter. + discretization (dict): The discretization parameters. + user_set_min_val (float): The minimum value to use for the discretization. + family (str, optional): The family of the textural filter. + feature (str, optional): The feature to extract from the family. if not specified, all the features of the + family will be extracted. + size (int, optional): The filter size. + local (bool, optional): If true, the discretization will be computed locally, else globally. + + Returns: + ndarray: The filtered image. + """ + # Initialization + if family: + self.family = family + if size: + self.size = size + if local: + self.local = local + + # Filtering + if self.family.lower() == "glcm": + filtered_images = self.__glcm_filter(input_images, discretization, user_set_min_val, feature) + else: + raise NotImplementedError("Only GLCM is implemented for now.") + + return filtered_images diff --git a/MEDiml/filters/__init__.py b/MEDiml/filters/__init__.py new file mode 100644 index 0000000..30b73a2 --- /dev/null +++ b/MEDiml/filters/__init__.py @@ -0,0 +1,9 @@ +from . import * +from .apply_filter import * +from .gabor import * +from .laws import * +from .log import * +from .mean import * +from .TexturalFilter import * +from .utils import * +from .wavelet import * diff --git a/MEDiml/filters/apply_filter.py b/MEDiml/filters/apply_filter.py new file mode 100644 index 0000000..87cb901 --- /dev/null +++ b/MEDiml/filters/apply_filter.py @@ -0,0 +1,134 @@ +import numpy as np + +from ..MEDscan import MEDscan +from ..utils.image_volume_obj import image_volume_obj +from .gabor import * +from .laws import * +from .log import * +from .mean import * +try: + from .TexturalFilter import TexturalFilter +except ImportError: + import_failed = True +from .wavelet import * + + +def apply_filter( + medscan: MEDscan, + vol_obj: Union[image_volume_obj, np.ndarray], + user_set_min_val: float = None, + feature: str = None + ) -> Union[image_volume_obj, np.ndarray]: + """Applies mean filter on the given data + + Args: + medscan (MEDscan): Instance of the MEDscan class that holds the filtering params + vol_obj (image_volume_obj): Imaging data to be filtered + user_set_min_val (float, optional): The minimum value to use for the discretization. Defaults to None. + feature (str, optional): The feature to extract from the family. In batch extraction, all the features + of the family will be extracted. Defaults to None. + + Returns: + image_volume_obj: Filtered imaging data. + """ + filter_type = medscan.params.filter.filter_type + + if filter_type.lower() == "mean": + input = np.expand_dims(vol_obj.data.astype(np.float64), axis=0) # Convert to shape : (B, W, H, D) + # Initialize filter class instance + _filter = Mean( + ndims=medscan.params.filter.mean.ndims, + size=medscan.params.filter.mean.size, + padding=medscan.params.filter.mean.padding + ) + # Run convolution + result = _filter.convolve(input, orthogonal_rot=medscan.params.filter.mean.orthogonal_rot) + + elif filter_type.lower() == "log": + # Initialize filter class params & instance + input = np.expand_dims(vol_obj.data.astype(np.float64), axis=0) # Convert to shape : (B, W, H, D) + voxel_length = medscan.params.process.scale_non_text[0] + sigma = medscan.params.filter.log.sigma / voxel_length + length = 2 * int(4 * sigma + 0.5) + 1 + _filter = LaplacianOfGaussian( + ndims=medscan.params.filter.log.ndims, + size=length, + sigma=sigma, + padding=medscan.params.filter.log.padding + ) + # Run convolution + result = _filter.convolve(input, orthogonal_rot=medscan.params.filter.log.orthogonal_rot) + + elif filter_type.lower() == "laws": + # Initialize filter class instance + input = np.expand_dims(vol_obj.data.astype(np.float64), axis=0) # Convert to shape : (B, W, H, D) + _filter = Laws( + config=medscan.params.filter.laws.config, + energy_distance=medscan.params.filter.laws.energy_distance, + rot_invariance=medscan.params.filter.laws.rot_invariance, + padding=medscan.params.filter.laws.padding + ) + # Run convolution + result = _filter.convolve( + input, + orthogonal_rot=medscan.params.filter.laws.orthogonal_rot, + energy_image=medscan.params.filter.laws.energy_image + ) + + elif filter_type.lower() == "gabor": + # Initialize filter class params & instance + input = np.expand_dims(vol_obj.data.astype(np.float64), axis=0) # Convert to shape : (B, W, H, D) + voxel_length = medscan.params.process.scale_non_text[0] + sigma = medscan.params.filter.gabor.sigma / voxel_length + lamb = medscan.params.filter.gabor._lambda / voxel_length + size = 2 * int(7 * sigma + 0.5) + 1 + _filter = Gabor(size=size, + sigma=sigma, + lamb=lamb, + gamma=medscan.params.filter.gabor.gamma, + theta=-medscan.params.filter.gabor.theta, + rot_invariance=medscan.params.filter.gabor.rot_invariance, + padding=medscan.params.filter.gabor.padding + ) + # Run convolution + result = _filter.convolve(input, orthogonal_rot=medscan.params.filter.gabor.orthogonal_rot) + + elif filter_type.lower().startswith("wavelet"): + # Initialize filter class instance + input = np.expand_dims(vol_obj.data.astype(np.float64), axis=0) # Convert to shape : (B, W, H, D) + _filter = Wavelet( + ndims=medscan.params.filter.wavelet.ndims, + wavelet_name=medscan.params.filter.wavelet.basis_function, + rot_invariance=medscan.params.filter.wavelet.rot_invariance, + padding=medscan.params.filter.wavelet.padding + ) + # Run convolution + result = _filter.convolve( + input, + _filter=medscan.params.filter.wavelet.subband, + level=medscan.params.filter.wavelet.level + ) + elif filter_type.lower() == "textural": + if not import_failed: + # Initialize filter class instance + _filter = TexturalFilter( + family=medscan.params.filter.textural.family, + ) + # Apply filter + vol_obj = _filter( + vol_obj, + size=medscan.params.filter.textural.size, + discretization=medscan.params.filter.textural.discretization, + local=medscan.params.filter.textural.local, + user_set_min_val=user_set_min_val, + feature=feature + ) + else: + raise ValueError( + r'Filter name should either be: "mean", "log", "laws", "gabor" or "wavelet".' + ) + + if not filter_type.lower() == "textural": + vol_obj.data = np.squeeze(result,axis=0) + + return vol_obj diff --git a/MEDiml/filters/gabor.py b/MEDiml/filters/gabor.py new file mode 100644 index 0000000..77b5ed4 --- /dev/null +++ b/MEDiml/filters/gabor.py @@ -0,0 +1,215 @@ +import math +from itertools import product +from typing import List, Union + +import numpy as np + +from ..MEDscan import MEDscan +from ..utils.image_volume_obj import image_volume_obj +from .utils import convolve + + +class Gabor(): + """ + The Gabor filter class + """ + + def __init__( + self, + size: int, + sigma: float, + lamb: float, + gamma: float, + theta: float, + rot_invariance=False, + padding="symmetric" + ) -> None: + """ + The constructor of the Gabor filter. Highly inspired by Ref 1. + + Args: + size (int): An integer that represent the length along one dimension of the kernel. + sigma (float): A positive float that represent the scale of the Gabor filter + lamb (float): A positive float that represent the wavelength in the Gabor filter. (mm or pixel?) + gamma (float): A positive float that represent the spacial aspect ratio + theta (float): Angle parameter used in the rotation matrix + rot_invariance (bool): If true, rotation invariance will be done on the kernel and the kernel + will be rotate 2*pi / theta times. + padding: The padding type that will be used to produce the convolution + + Returns: + None + """ + + assert ((size + 1) / 2).is_integer() and size > 0, "size should be a positive odd number." + assert sigma > 0, "sigma should be a positive float" + assert lamb > 0, "lamb represent the wavelength, so it should be a positive float" + assert gamma > 0, "gamma is the ellipticity of the support of the filter, so it should be a positive float" + + self.dim = 2 + self.padding = padding + self.size = size + self.sigma = sigma + self.lamb = lamb + self.gamma = gamma + self.theta = theta + self.rot = rot_invariance + self.create_kernel() + + def create_kernel(self) -> List[np.ndarray]: + """Create the kernel of the Gabor filter + + Returns: + List[ndarray]: A list of numpy 2D-array that contain the kernel of the real part and + the imaginary part respectively. + """ + + def compute_weight(position, theta): + k_2 = position[0]*math.cos(theta) + position[1] * math.sin(theta) + k_1 = position[1]*math.cos(theta) - position[0] * math.sin(theta) + + common = math.e**(-(k_1**2 + (self.gamma*k_2)**2)/(2*self.sigma**2)) + real = math.cos(2*math.pi*k_1/self.lamb) + im = math.sin(2*math.pi*k_1/self.lamb) + return common*real, common*im + + # Rotation invariance + nb_rot = round(2*math.pi/abs(self.theta)) if self.rot else 1 + real_list = [] + im_list = [] + + for i in range(1, nb_rot+1): + # Initialize the kernel as tensor of zeros + real_kernel = np.zeros([self.size for _ in range(2)]) + im_kernel = np.zeros([self.size for _ in range(2)]) + + for k in product(range(self.size), repeat=2): + real_kernel[k], im_kernel[k] = compute_weight(np.array(k)-int((self.size-1)/2), self.theta*i) + + real_list.extend([real_kernel]) + im_list.extend([im_kernel]) + + self.kernel = np.expand_dims( + np.concatenate((real_list, im_list), axis=0), + axis=1 + ) + + def convolve(self, + images: np.ndarray, + orthogonal_rot=False, + pooling_method='mean') -> np.ndarray: + """Filter a given image using the Gabor kernel defined during the construction of this instance. + + Args: + images (ndarray): A n-dimensional numpy array that represent the images to filter + orthogonal_rot (bool): If true, the 3D images will be rotated over coronal, axial and sagittal axis + + Returns: + ndarray: The filtered image as a numpy ndarray + """ + + # Swap the second axis with the last, to convert image B, W, H, D --> B, D, H, W + image = np.swapaxes(images, 1, 3) + + result = convolve(self.dim, self.kernel, image, orthogonal_rot, self.padding) + + # Reshape to get real and imaginary response on the first axis. + _dim = 2 if orthogonal_rot else 1 + nb_rot = int(result.shape[_dim]/2) + result = np.stack(np.array_split(result, np.array([nb_rot]), _dim), axis=0) + + # 2D modulus response map + result = np.linalg.norm(result, axis=0) + + # Rotation invariance. + if pooling_method == 'mean': + result = np.mean(result, axis=2) if orthogonal_rot else np.mean(result, axis=1) + elif pooling_method == 'max': + result = np.max(result, axis=2) if orthogonal_rot else np.max(result, axis=1) + else: + raise ValueError("Pooling method should be either 'mean' or 'max'.") + + # Aggregate orthogonal rotation + result = np.mean(result, axis=0) if orthogonal_rot else result + + return np.swapaxes(result, 1, 3) + +def apply_gabor( + input_images: Union[image_volume_obj, np.ndarray], + medscan: MEDscan = None, + voxel_length: float = 0.0, + sigma: float = 0.0, + _lambda: float = 0.0, + gamma: float = 0.0, + theta: float = 0.0, + rot_invariance: bool = False, + padding: str = "symmetric", + orthogonal_rot: bool = False, + pooling_method: str = "mean" + ) -> np.ndarray: + """Apply the Gabor filter to a given imaging data. + + Args: + input_images (Union[image_volume_obj, np.ndarray]): The input images to filter. + medscan (MEDscan, optional): The MEDscan object that will provide the filter parameters. + voxel_length (float, optional): The voxel size of the input image. + sigma (float, optional): A positive float that represent the scale of the Gabor filter. + _lambda (float, optional): A positive float that represent the wavelength in the Gabor filter. + gamma (float, optional): A positive float that represent the spacial aspect ratio. + theta (float, optional): Angle parameter used in the rotation matrix. + rot_invariance (bool, optional): If true, rotation invariance will be done on the kernel and the kernel + will be rotate 2*pi / theta times. + padding (str, optional): The padding type that will be used to produce the convolution. Check options + here: `numpy.pad `__. + orthogonal_rot (bool, optional): If true, the 3D images will be rotated over coronal, axial and sagittal axis. + + Returns: + ndarray: The filtered image. + """ + # Check if the input is a numpy array or a Image volume object + spatial_ref = None + if type(input_images) == image_volume_obj: + spatial_ref = input_images.spatialRef + input_images = input_images.data + + # Convert to shape : (B, W, H, D) + input_images = np.expand_dims(input_images.astype(np.float64), axis=0) + + if medscan: + # Initialize filter class params & instance + voxel_length = medscan.params.process.scale_non_text[0] + sigma = medscan.params.filter.gabor.sigma / voxel_length + lamb = medscan.params.filter.gabor._lambda / voxel_length + size = 2 * int(7 * sigma + 0.5) + 1 + _filter = Gabor(size=size, + sigma=sigma, + lamb=lamb, + gamma=medscan.params.filter.gabor.gamma, + theta=-medscan.params.filter.gabor.theta, + rot_invariance=medscan.params.filter.gabor.rot_invariance, + padding=medscan.params.filter.gabor.padding + ) + # Run convolution + result = _filter.convolve(input_images, orthogonal_rot=medscan.params.filter.gabor.orthogonal_rot) + else: + if not (voxel_length and sigma and _lambda and gamma and theta): + raise ValueError("Missing parameters to build the Gabor filter.") + # Initialize filter class params & instance + sigma = sigma / voxel_length + lamb = _lambda / voxel_length + size = 2 * int(7 * sigma + 0.5) + 1 + _filter = Gabor(size=size, + sigma=sigma, + lamb=lamb, + gamma=gamma, + theta=theta, + rot_invariance=rot_invariance, + padding=padding + ) + # Run convolution + result = _filter.convolve(input_images, orthogonal_rot=orthogonal_rot, pooling_method=pooling_method) + + if spatial_ref: + return image_volume_obj(np.squeeze(result), spatial_ref) + else: + return np.squeeze(result) diff --git a/MEDiml/filters/laws.py b/MEDiml/filters/laws.py new file mode 100644 index 0000000..359f5a4 --- /dev/null +++ b/MEDiml/filters/laws.py @@ -0,0 +1,283 @@ +import math +from itertools import permutations, product +from typing import List, Union + +import numpy as np +from scipy.signal import fftconvolve + +from ..MEDscan import MEDscan +from ..utils.image_volume_obj import image_volume_obj +from .utils import convolve, pad_imgs + + +class Laws(): + """ + The Laws filter class + """ + + def __init__( + self, + config: List = None, + energy_distance: int = 7, + rot_invariance: bool = False, + padding: str = "symmetric"): + """The constructor of the Laws filter + + Args: + config (str): A string list of every 1D filter used to create the Laws kernel. Since the outer product is + not commutative, we need to use a list to specify the order of the outer product. It is not + recommended to use filter of different size to create the Laws kernel. + energy_distance (float): The distance that will be used to create the energy_kernel. + rot_invariance (bool): If true, rotation invariance will be done on the kernel. + padding (str): The padding type that will be used to produce the convolution + + Returns: + None + """ + + ndims = len(config) + + self.config = config + self.energy_dist = energy_distance + self.dim = ndims + self.padding = padding + self.rot = rot_invariance + self.energy_kernel = None + self.create_kernel() + self.__create_energy_kernel() + + @staticmethod + def __get_filter(name, + pad=False) -> np.ndarray: + """This method create a 1D filter according to the given filter name. + + Args: + name (float): The filter name. (Such as L3, L5, E3, E5, S3, S5, W5 or R5) + pad (bool): If true, add zero padding of length 1 each side of kernel L3, E3 and S3 + + Returns: + ndarray: A 1D filter that is needed to construct the Laws kernel. + """ + + if name == "L3": + ker = np.array([0, 1, 2, 1, 0]) if pad else np.array([1, 2, 1]) + return 1/math.sqrt(6) * ker + elif name == "L5": + return 1/math.sqrt(70) * np.array([1, 4, 6, 4, 1]) + elif name == "E3": + ker = np.array([0, -1, 0, 1, 0]) if pad else np.array([-1, 0, 1]) + return 1 / math.sqrt(2) * ker + elif name == "E5": + return 1 / math.sqrt(10) * np.array([-1, -2, 0, 2, 1]) + elif name == "S3": + ker = np.array([0, -1, 2, -1, 0]) if pad else np.array([-1, 2, -1]) + return 1 / math.sqrt(6) * ker + elif name == "S5": + return 1 / math.sqrt(6) * np.array([-1, 0, 2, 0, -1]) + elif name == "W5": + return 1 / math.sqrt(10) * np.array([-1, 2, 0, -2, 1]) + elif name == "R5": + return 1 / math.sqrt(70) * np.array([1, -4, 6, -4, 1]) + else: + raise Exception(f"{name} is not a valid filter name. " + "Choose between : L3, L5, E3, E5, S3, S5, W5 or R5") + + def __verify_padding_need(self) -> bool: + """Check if we need to pad the kernels + + Returns: + bool: A boolean that indicate if a kernel is smaller than at least one other. + """ + + ker_length = np.array([int(name[-1]) for name in self.config]) + + return not(ker_length.min == ker_length.max) + + def create_kernel(self) -> np.ndarray: + """Create the Laws by computing the outer product of 1d filter specified in the config attribute. + Kernel = config[0] X config[1] X ... X config[n]. Where X is the outer product. + + Returns: + ndarray: A numpy multi-dimensional arrays that represent the Laws kernel. + """ + + pad = self.__verify_padding_need() + filter_list = np.array([[self.__get_filter(name, pad) for name in self.config]]) + + if self.rot: + filter_list = np.concatenate((filter_list, np.flip(filter_list, axis=2)), axis=0) + prod_list = [prod for prod in product(*np.swapaxes(filter_list, 0, 1))] + + perm_list = [] + for i in range(len(prod_list)): + perm_list.extend([perm for perm in permutations(prod_list[i])]) + + filter_list = np.unique(perm_list, axis=0) + + kernel_list = [] + for perm in filter_list: + kernel = perm[0] + shape = kernel.shape + + for i in range(1, len(perm)): + sub_kernel = perm[i] + shape += np.shape(sub_kernel) + kernel = np.outer(sub_kernel, kernel).reshape(shape) + if self.dim == 3: + kernel_list.extend([np.expand_dims(np.flip(kernel, axis=(1, 2)), axis=0)]) + else: + kernel_list.extend([np.expand_dims(np.flip(kernel, axis=(0, 1)), axis=0)]) + + self.kernel = np.unique(kernel_list, axis=0) + + def __create_energy_kernel(self) -> np.ndarray: + """Create the kernel that will be used to generate Laws texture energy images + + Returns: + ndarray: A numpy multi-dimensional arrays that represent the Laws energy kernel. + """ + + # Initialize the kernel as tensor of zeros + kernel = np.zeros([self.energy_dist*2+1 for _ in range(self.dim)]) + + for k in product(range(self.energy_dist*2 + 1), repeat=self.dim): + position = np.array(k)-self.energy_dist + kernel[k] = 1 if np.max(abs(position)) <= self.energy_dist else 0 + + self.energy_kernel = np.expand_dims(kernel/np.prod(kernel.shape), axis=(0, 1)) + + def __compute_energy_image(self, + images: np.ndarray) -> np.ndarray: + """Compute the Laws texture energy images as described in (Ref 1). + + Args: + images (ndarray): A n-dimensional numpy array that represent the filtered images + + Returns: + ndarray: A numpy multi-dimensional array of the Laws texture energy map. + """ + # If we have a 2D kernel but a 3D images, we swap dimension channel with dimension batch. + images = np.swapaxes(images, 0, 1) + + # absolute image intensities are used in convolution + result = fftconvolve(np.abs(images), self.energy_kernel, mode='valid') + + if self.dim == 2: + return np.swapaxes(result, axis1=0, axis2=1) + else: + return np.squeeze(result, axis=1) + + def convolve(self, + images: np.ndarray, + orthogonal_rot=False, + energy_image=False): + """Filter a given image using the Laws kernel defined during the construction of this instance. + + Args: + images (ndarray): A n-dimensional numpy array that represent the images to filter + orthogonal_rot (bool): If true, the 3D images will be rotated over coronal, axial and sagittal axis + energy_image (bool): If true, return also the Laws Texture Energy Images + + Returns: + ndarray: The filtered image + """ + images = np.swapaxes(images, 1, 3) + + if orthogonal_rot: + raise NotImplementedError + + result = convolve(self.dim, self.kernel, images, orthogonal_rot, self.padding) + result = np.amax(result, axis=1) if self.dim == 2 else np.amax(result, axis=0) + + if energy_image: + # We pad the response map + result = np.expand_dims(result, axis=1) if self.dim == 3 else result + ndims = len(result.shape) + + padding = [self.energy_dist for _ in range(2 * self.dim)] + pad_axis_list = [i for i in range(ndims - self.dim, ndims)] + + response = pad_imgs(result, padding, pad_axis_list, self.padding) + + # Free memory + del result + + # We compute the energy map and we squeeze the second dimension of the energy maps. + energy_imgs = self.__compute_energy_image(response) + + return np.swapaxes(energy_imgs, 1, 3) + else: + return np.swapaxes(result, 1, 3) + +def apply_laws( + input_images: Union[np.ndarray, image_volume_obj], + medscan: MEDscan = None, + config: List[str] = [], + energy_distance: int = 7, + padding: str = "symmetric", + rot_invariance: bool = False, + orthogonal_rot: bool = False, + energy_image: bool = False, + ) -> np.ndarray: + """Apply the mean filter to the input image + + Args: + input_images (ndarray): The images to filter. + medscan (MEDscan, optional): The MEDscan object that will provide the filter parameters. + config (List[str], optional): A string list of every 1D filter used to create the Laws kernel. Since the outer product is + not commutative, we need to use a list to specify the order of the outer product. It is not + recommended to use filter of different size to create the Laws kernel. + energy_distance (int, optional): The distance of the Laws energy map from the center of the image. + padding (str, optional): The padding type that will be used to produce the convolution. Check options + here: `numpy.pad `__. + rot_invariance (bool, optional): If true, rotation invariance will be done on the kernel. + orthogonal_rot (bool, optional): If true, the 3D images will be rotated over coronal, axial and sagittal axis. + energy_image (bool, optional): If true, will compute and return the Laws Texture Energy Images. + + Returns: + ndarray: The filtered image. + """ + # Check if the input is a numpy array or a Image volume object + spatial_ref = None + if type(input_images) == image_volume_obj: + spatial_ref = input_images.spatialRef + input_images = input_images.data + + # Convert to shape : (B, W, H, D) + input_images = np.expand_dims(input_images.astype(np.float64), axis=0) + + if medscan: + # Initialize filter class instance + _filter = Laws( + config=medscan.params.filter.laws.config, + energy_distance=medscan.params.filter.laws.energy_distance, + rot_invariance=medscan.params.filter.laws.rot_invariance, + padding=medscan.params.filter.laws.padding + ) + # Run convolution + result = _filter.convolve( + input_images, + orthogonal_rot=medscan.params.filter.laws.orthogonal_rot, + energy_image=medscan.params.filter.laws.energy_image + ) + elif config: + # Initialize filter class instance + _filter = Laws( + config=config, + energy_distance=energy_distance, + rot_invariance=rot_invariance, + padding=padding + ) + # Run convolution + result = _filter.convolve( + input_images, + orthogonal_rot=orthogonal_rot, + energy_image=energy_image + ) + else: + raise ValueError("Either medscan or config must be provided") + + if spatial_ref: + return image_volume_obj(np.squeeze(result), spatial_ref) + else: + return np.squeeze(result) diff --git a/MEDiml/filters/log.py b/MEDiml/filters/log.py new file mode 100644 index 0000000..1ffaec7 --- /dev/null +++ b/MEDiml/filters/log.py @@ -0,0 +1,147 @@ +import math +from itertools import product +from typing import Union + +import numpy as np + +from ..MEDscan import MEDscan +from ..utils.image_volume_obj import image_volume_obj +from .utils import convolve + + +class LaplacianOfGaussian(): + """The Laplacian of gaussian filter class.""" + + def __init__( + self, + ndims: int, + size: int, + sigma: float=0.1, + padding: str="symmetric"): + """The constructor of the laplacian of gaussian (LoG) filter + + Args: + ndims (int): Number of dimension of the kernel filter + size (int): An integer that represent the length along one dimension of the kernel. + sigma (float): The gaussian standard deviation parameter of the laplacian of gaussian filter + padding (str): The padding type that will be used to produce the convolution + + Returns: + None + """ + + assert isinstance(ndims, int) and ndims > 0, "ndims should be a positive integer" + assert ((size+1)/2).is_integer() and size > 0, "size should be a positive odd number." + assert sigma > 0, "alpha should be a positive float." + + self.dim = ndims + self.padding = padding + self.size = int(size) + self.sigma = sigma + self.create_kernel() + + def create_kernel(self) -> np.ndarray: + """This method construct the LoG kernel using the parameters specified to the constructor + + Returns: + ndarray: The laplacian of gaussian kernel as a numpy multidimensional array + """ + + def compute_weight(position): + distance_2 = np.sum(position**2) + # $\frac{-1}{\sigma^2} * \frac{1}{\sqrt{2 \pi} \sigma}^D = \frac{-1}{\sqrt{D/2}{2 \pi} * \sigma^{D+2}}$ + first_part = -1/((2*math.pi)**(self.dim/2) * self.sigma**(self.dim+2)) + + # $(D - \frac{||k||^2}{\sigma^2}) * e^{\frac{-||k||^2}{2 \sigma^2}}$ + second_part = (self.dim - distance_2/self.sigma**2)*math.e**(-distance_2/(2 * self.sigma**2)) + + return first_part * second_part + + # Initialize the kernel as tensor of zeros + kernel = np.zeros([self.size for _ in range(self.dim)]) + + for k in product(range(self.size), repeat=self.dim): + kernel[k] = compute_weight(np.array(k)-int((self.size-1)/2)) + + kernel -= np.sum(kernel)/np.prod(kernel.shape) + self.kernel = np.expand_dims(kernel, axis=(0, 1)) + + def convolve(self, + images: np.ndarray, + orthogonal_rot=False) -> np.ndarray: + """Filter a given image using the LoG kernel defined during the construction of this instance. + + Args: + images (ndarray): A n-dimensional numpy array that represent the images to filter + orthogonal_rot (bool): If true, the 3D images will be rotated over coronal, axial and sagittal axis + + Returns: + ndarray: The filtered image + """ + # Swap the second axis with the last, to convert image B, W, H, D --> B, D, H, W + image = np.swapaxes(images, 1, 3) + result = np.squeeze(convolve(self.dim, self.kernel, image, orthogonal_rot, self.padding), axis=1) + return np.swapaxes(result, 1, 3) + +def apply_log( + input_images: Union[np.ndarray, image_volume_obj], + medscan: MEDscan = None, + ndims: int = 3, + voxel_length: float = 0.0, + sigma: float = 0.1, + padding: str = "symmetric", + orthogonal_rot: bool = False + ) -> np.ndarray: + """Apply the mean filter to the input image + + Args: + input_images (ndarray): The images to filter. + medscan (MEDscan, optional): The MEDscan object that will provide the filter parameters. + ndims (int, optional): The number of dimensions of the input image. + voxel_length (float, optional): The voxel size of the input image. + sigma (float, optional): standard deviation of the Gaussian, controls the scale of the convolutional operator. + padding (str, optional): The padding type that will be used to produce the convolution. + Check options here: `numpy.pad `__. + orthogonal_rot (bool, optional): If true, the 3D images will be rotated over coronal, axial and sagittal axis. + + Returns: + ndarray: The filtered image. + """ + # Check if the input is a numpy array or a Image volume object + spatial_ref = None + if type(input_images) == image_volume_obj: + spatial_ref = input_images.spatialRef + input_images = input_images.data + + # Convert to shape : (B, W, H, D) + input_images = np.expand_dims(input_images.astype(np.float64), axis=0) + + if medscan: + # Initialize filter class params & instance + sigma = medscan.params.filter.log.sigma / voxel_length + length = 2 * int(4 * sigma + 0.5) + 1 + _filter = LaplacianOfGaussian( + ndims=medscan.params.filter.log.ndims, + size=length, + sigma=sigma, + padding=medscan.params.filter.log.padding + ) + # Run convolution + result = _filter.convolve(input_images, orthogonal_rot=medscan.params.filter.log.orthogonal_rot) + else: + # Initialize filter class params & instance + sigma = sigma / voxel_length + length = 2 * int(4 * sigma + 0.5) + 1 + _filter = LaplacianOfGaussian( + ndims=ndims, + size=length, + sigma=sigma, + padding=padding + ) + # Run convolution + result = _filter.convolve(input_images, orthogonal_rot=orthogonal_rot) + + if spatial_ref: + return image_volume_obj(np.squeeze(result), spatial_ref) + else: + return np.squeeze(result) diff --git a/MEDiml/filters/mean.py b/MEDiml/filters/mean.py new file mode 100644 index 0000000..7907b8a --- /dev/null +++ b/MEDiml/filters/mean.py @@ -0,0 +1,121 @@ +from abc import ABC +from typing import Union + +import numpy as np + +from ..MEDscan import MEDscan +from ..utils.image_volume_obj import image_volume_obj +from .utils import convolve + + +class Mean(): + """The mean filter class""" + + def __init__( + self, + ndims: int, + size: int, + padding="symmetric"): + """The constructor of the mean filter + + Args: + ndims (int): Number of dimension of the kernel filter + size (int): An integer that represent the length along one dimension of the kernel. + padding: The padding type that will be used to produce the convolution + + Returns: + None + """ + + assert isinstance(ndims, int) and ndims > 0, "ndims should be a positive integer" + assert ((size+1)/2).is_integer() and size > 0, "size should be a positive odd number." + + self.padding = padding + self.dim = ndims + self.size = int(size) + self.create_kernel() + + def create_kernel(self): + """This method construct the mean kernel using the parameters specified to the constructor. + + Returns: + ndarray: The mean kernel as a numpy multidimensional array + """ + + # Initialize the kernel as tensor of zeros + weight = 1 / np.prod(self.size ** self.dim) + kernel = np.ones([self.size for _ in range(self.dim)]) * weight + + self.kernel = np.expand_dims(kernel, axis=(0, 1)) + + def convolve(self, + images: np.ndarray, + orthogonal_rot: bool = False)-> np.ndarray: + """Filter a given image using the LoG kernel defined during the construction of this instance. + + Args: + images (ndarray): A n-dimensional numpy array that represent the images to filter + orthogonal_rot (bool, optional): If true, the 3D images will be rotated over coronal, axial and sagittal axis + + Returns: + ndarray: The filtered image + """ + # Swap the second axis with the last, to convert image B, W, H, D --> B, D, H, W + image = np.swapaxes(images, 1, 3) + result = np.squeeze(convolve(self.dim, self.kernel, image, orthogonal_rot, self.padding), axis=1) + return np.swapaxes(result, 1, 3) + +def apply_mean( + input_images: Union[np.ndarray, image_volume_obj], + medscan: MEDscan = None, + ndims: int = 3, + size: int = 15, + padding: str = "symmetric", + orthogonal_rot: bool = False + ) -> np.ndarray: + """Apply the mean filter to the input image + + Args: + input_images (ndarray): The images to filter. + medscan (MEDscan, optional): The MEDscan object that will provide the filter parameters. + ndims (int, optional): The number of dimensions of the input image. + size (int, optional): The size of the kernel. + padding (str, optional): The padding type that will be used to produce the convolution. + Check options here: `numpy.pad `__. + orthogonal_rot (bool, optional): If true, the 3D images will be rotated over coronal, axial and sagittal axis. + + Returns: + ndarray: The filtered image. + """ + # Check if the input is a numpy array or a Image volume object + spatial_ref = None + if type(input_images) == image_volume_obj: + spatial_ref = input_images.spatialRef + input_images = input_images.data + + # Convert to shape : (B, W, H, D) + input_images = np.expand_dims(input_images.astype(np.float64), axis=0) + + if medscan: + # Initialize filter class instance + _filter = Mean( + ndims=medscan.params.filter.mean.ndims, + size=medscan.params.filter.mean.size, + padding=medscan.params.filter.mean.padding + ) + # Run convolution + result = _filter.convolve(input_images, orthogonal_rot=medscan.params.filter.mean.orthogonal_rot) + else: + # Initialize filter class instance + _filter = Mean( + ndims=ndims, + size=size, + padding=padding, + ) + # Run convolution + result = _filter.convolve(input_images, orthogonal_rot=orthogonal_rot) + + if spatial_ref: + return image_volume_obj(np.squeeze(result), spatial_ref) + else: + return np.squeeze(result) diff --git a/MEDiml/filters/textural_filters_kernels.py b/MEDiml/filters/textural_filters_kernels.py new file mode 100644 index 0000000..b4d01c8 --- /dev/null +++ b/MEDiml/filters/textural_filters_kernels.py @@ -0,0 +1,1738 @@ +from string import Template + +glcm_kernel = Template(""" +#include +#include +#include + +# define MAX_SIZE ${max_vol} +# define FILTER_SIZE ${filter_size} + +// Function flatten a 3D matrix into a 1D vector +__device__ float * reshape(float(*matrix)[FILTER_SIZE][FILTER_SIZE]) { + //size of array + const int size = FILTER_SIZE* FILTER_SIZE* FILTER_SIZE; + float flattened[size]; + int index = 0; + for (int i = 0; i < FILTER_SIZE; ++i) { + for (int j = 0; j < FILTER_SIZE; ++j) { + for (int k = 0; k < FILTER_SIZE; ++k) { + flattened[index] = matrix[i][j][k]; + index++; + } + } + } + return flattened; +} + +// Function to perform histogram equalisation of the ROI imaging intensities +__device__ void discretize(float vol_quant_re[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE], + float max_val, float min_val=${min_val}) { + + // PARSING ARGUMENTS + float n_q = ${n_q}; + const char* discr_type = "${discr_type}"; + + // DISCRETISATION + if (discr_type == "FBS") { + float w_b = n_q; + for (int i = 0; i < FILTER_SIZE; i++) { + for (int j = 0; j < FILTER_SIZE; j++) { + for (int k = 0; k < FILTER_SIZE; k++) { + float value = vol_quant_re[i][j][k]; + if (!isnan(value)) { + vol_quant_re[i][j][k] = floorf((value - min_val) / w_b) + 1.0; + } + } + } + } + } + else if (discr_type == "FBN") { + float w_b = (max_val - min_val) / n_q; + for (int i = 0; i < FILTER_SIZE; i++) { + for (int j = 0; j < FILTER_SIZE; j++) { + for (int k = 0; k < FILTER_SIZE; k++) { + float value = vol_quant_re[i][j][k]; + if (!isnan(value)) { + vol_quant_re[i][j][k] = floorf(n_q * ((value - min_val) / (max_val - min_val))) + 1.0; + if (value == max_val) { + vol_quant_re[i][j][k] = n_q; + } + } + } + } + } + } + else { + printf("ERROR: discretization type not supported"); + assert(false); + } +} + +// Compute the diagonal probability +__device__ float * GLCMDiagProb(float p_ij[MAX_SIZE][MAX_SIZE], float max_vol) { + int valK[MAX_SIZE]; + for (int i = 0; i < (int)max_vol; ++i) { + valK[i] = i; + } + float p_iminusj[MAX_SIZE] = { 0.0 }; + for (int iterationK = 0; iterationK < (int)max_vol; ++iterationK) { + int k = valK[iterationK]; + float p = 0.0; + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + if (k - fabsf(i - j) == 0) { + p += p_ij[i][j]; + } + } + } + + p_iminusj[iterationK] = p; + } + + return p_iminusj; +} + +// Compute the cross-diagonal probability +__device__ float * GLCMCrossDiagProb(float p_ij[MAX_SIZE][MAX_SIZE], float max_vol) { + float valK[2 * MAX_SIZE - 1]; + // fill valK with 2, 3, 4, ..., 2*max_vol - 1 + for (int i = 0; i < 2 * (int)max_vol - 1; ++i) { + valK[i] = i + 2; + } + float p_iplusj[2*MAX_SIZE - 1] = { 0.0 }; + + for (int iterationK = 0; iterationK < 2*(int)max_vol - 1; ++iterationK) { + int k = valK[iterationK]; + float p = 0.0; + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + if (k - (i + j + 2) == 0) { + p += p_ij[i][j]; + } + } + } + + p_iplusj[iterationK] = p; + } + + return p_iplusj; +} + +__device__ void getGLCMmatrix( + float (*ROIonly)[FILTER_SIZE][FILTER_SIZE], + float GLCMfinal[MAX_SIZE][MAX_SIZE], + float max_vol, + bool distCorrection = true) +{ + // PARSING "distCorrection" ARGUMENT + + const int Ng = MAX_SIZE; + float levels[Ng] = {0}; + // initialize levels to 1, 2, 3, ..., 15 + for (int i = 0; i < (int)max_vol; ++i) { + levels[i] = i + 1; + } + + float levelTemp = max_vol + 1; + + for (int i = 0; i < FILTER_SIZE; ++i) { + for (int j = 0; j < FILTER_SIZE; ++j) { + for (int k = 0; k < FILTER_SIZE; ++k) { + if (isnan(ROIonly[i][j][k])) { + ROIonly[i][j][k] = levelTemp; + } + } + } + } + + int dim_x = FILTER_SIZE; + int dim_y = FILTER_SIZE; + int dim_z = FILTER_SIZE; + + // Reshape the 3D matrix to a 1D vector + float *q2; + q2 = reshape(ROIonly); + + // Combine levels and level_temp into qs + float qs[Ng + 1] = {0}; + for (int i = 0; i < (int)max_vol + 1; ++i) { + if (i == (int)max_vol) { + qs[i] = levelTemp; + break; + } + qs[i] = levels[i]; + } + const int lqs = Ng + 1; + + // Create a q3 matrix and assign values based on qs + int q3[FILTER_SIZE* FILTER_SIZE* FILTER_SIZE] = {0}; + + // fill q3 with 0s + for (int i = 0; i < FILTER_SIZE * FILTER_SIZE * FILTER_SIZE; ++i) { + q3[i] = 0; + } + for (int k = 0; k < (int)max_vol + 1; ++k) { + for (int i = 0; i < FILTER_SIZE * FILTER_SIZE * FILTER_SIZE; ++i) { + if (fabsf(q2[i] - qs[k]) < 1.19209e-07) { + q3[i] = k; + } + } + } + + // Reshape q3 back to the original dimensions (dimX, dimY, dimZ) + float reshaped_q3[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE]; + + int index = 0; + for (int i = 0; i < dim_x; ++i) { + for (int j = 0; j < dim_y; ++j) { + for (int k = 0; k < dim_z; ++k) { + reshaped_q3[i][j][k] = q3[index++]; + } + } + } + + + float GLCM[lqs][lqs] = {0}; + + // fill GLCM with 0s + for (int i = 0; i < (int)max_vol + 1; ++i) { + for (int j = 0; j < (int)max_vol + 1; ++j) { + GLCM[i][j] = 0; + } + } + + for (int i = 1; i <= dim_x; ++i) { + int i_min = max(1, i - 1); + int i_max = min(i + 1, dim_x); + for (int j = 1; j <= dim_y; ++j) { + int j_min = max(1, j - 1); + int j_max = min(j + 1, dim_y); + for (int k = 1; k <= dim_z; ++k) { + int k_min = max(1, k - 1); + int k_max = min(k + 1, dim_z); + int val_q3 = reshaped_q3[i - 1][j - 1][k - 1]; + for (int I2 = i_min; I2 <= i_max; ++I2) { + for (int J2 = j_min; J2 <= j_max; ++J2) { + for (int K2 = k_min; K2 <= k_max; ++K2) { + if (I2 == i && J2 == j && K2 == k) { + continue; + } + else { + int val_neighbor = reshaped_q3[I2 - 1][J2 - 1][K2 - 1]; + if (distCorrection) { + // Discretization length correction + GLCM[val_q3][val_neighbor] += + sqrtf(fabsf(I2 - i) + + fabsf(J2 - j) + + fabsf(K2 - k)); + } + else { + GLCM[val_q3][val_neighbor] += 1; + } + } + } + } + } + } + } + } + + // Eliminate last row and column + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + GLCMfinal[i][j] = GLCM[i][j]; + } + } +} + +__device__ void computeGLCMFeatures(float(*vol)[FILTER_SIZE][FILTER_SIZE], float features[25], float max_vol, bool distCorrection) { + + float GLCM[MAX_SIZE][MAX_SIZE] = { 0.0 }; + + // Call function with specified distCorrection + getGLCMmatrix(vol, GLCM, max_vol, distCorrection); + + // Normalize GLCM + float sumGLCM = 0.0; + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + sumGLCM += GLCM[i][j]; + } + } + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + GLCM[i][j] /= sumGLCM; + } + } + + // Compute textures + // // Number of gray levels + const int Ng = MAX_SIZE; + float vectNg[Ng]; + + // fill vectNg with 1, 2, ..., Ng + for (int i = 0; i < (int)max_vol; ++i) { + vectNg[i] = i + 1; + } + + // Create meshgird of size Ng x Ng + float colGrid[Ng][Ng] = { 0.0 }; + float rowGrid[Ng][Ng] = { 0.0 }; + + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + colGrid[i][j] = vectNg[j]; + } + } + for (int j = 0; j < int(max_vol); ++j) { + for (int i = 0; i < int(max_vol); ++i) { + rowGrid[i][j] = vectNg[i]; + } + } + int step_i = 0; + int step_j = 0; + + // Joint maximum + float joint_max = 0.0; + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + joint_max = max(joint_max, GLCM[i][j]); + } + } + features[0] = joint_max; + + // Joint average + float u = 0.0; + for (int i = 0; i < (int)max_vol; ++i) { + step_i = 0; + for (int j = 0; j < (int)max_vol; ++j) { + u += GLCM[i][j] * rowGrid[i][j]; + step_i++; + } + step_j++; + } + features[1] = u; + + // Joint variance + step_j = 0; + float var = 0.0; + u = 0.0; + for (int i = 0; i < (int)max_vol; ++i) { + step_i = 0; + for (int j = 0; j < (int)max_vol; ++j) { + u += GLCM[i][j] * rowGrid[i][j]; + step_i++; + } + step_j++; + } + for (int i = 0; i < (int)max_vol; ++i) { + step_i = 0; + for (int j = 0; j < (int)max_vol; ++j) { + var += GLCM[i][j] * powf(rowGrid[i][j] - u, 2); + step_i++; + } + step_j++; + } + features[2] = var; + + // Joint entropy + float entropy = 0.0; + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + if (GLCM[i][j] > 0.0) { + entropy += GLCM[i][j] * log2f(GLCM[i][j]); + } + } + } + features[3] = -entropy; + + // Difference average + float* p_iminusj; + p_iminusj = GLCMDiagProb(GLCM, max_vol); + float diff_avg = 0.0; + float k[Ng]; + // fill k with 0, 1, ..., Ng - 1 + for (int i = 0; i < int(max_vol); ++i) { + k[i] = i; + } + for (int i = 0; i < int(max_vol); ++i) { + diff_avg += p_iminusj[i] * k[i]; + } + features[4] = diff_avg; + + // Difference variance + diff_avg = 0.0; + // fill k with 0, 1, ..., Ng - 1 + for (int i = 0; i < int(max_vol); ++i) { + k[i] = i; + } + for (int i = 0; i < int(max_vol); ++i) { + diff_avg += p_iminusj[i] * k[i]; + } + float diff_var = 0.0; + step_i = 0; + for (int i = 0; i < int(max_vol); ++i) { + diff_var += p_iminusj[i] * powf(k[i] - diff_avg, 2); + step_i++; + } + features[5] = diff_var; + + // Difference entropy + float diff_entropy = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + if (p_iminusj[i] > 0.0) { + diff_entropy += p_iminusj[i] * log2f(p_iminusj[i]); + } + } + features[6] = -diff_entropy; + + // Sum average + float k1[2 * Ng - 1]; + // fill k with 2, 3, ..., 2 * Ng + for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { + k1[i] = i + 2; + } + float sum_avg = 0.0; + float* p_iplusj = GLCMCrossDiagProb(GLCM, max_vol); + for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { + sum_avg += p_iplusj[i] * k1[i]; + } + features[7] = sum_avg; + + // Sum variance + float sum_var = 0.0; + for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { + sum_var += p_iplusj[i] * powf(k1[i] - sum_avg, 2); + } + features[8] = sum_var; + + // Sum entropy + float sum_entr = 0.0; + for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { + if (p_iplusj[i] > 0.0) { + sum_entr += p_iplusj[i] * log2f(p_iplusj[i]); + } + } + features[9] = -sum_entr; + + // Angular second moment (energy) + float energy = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + for (int j = 0; j < int(max_vol); ++j) { + energy += powf(GLCM[i][j], 2); + } + } + features[10] = energy; + + // Contrast + float contrast = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + for (int j = 0; j < int(max_vol); ++j) { + contrast += powf(rowGrid[i][j] - colGrid[i][j], 2) * GLCM[i][j]; + } + } + features[11] = contrast; + + // Dissimilarity + float dissimilarity = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + for (int j = 0; j < int(max_vol); ++j) { + dissimilarity += fabsf(rowGrid[i][j] - colGrid[i][j]) * GLCM[i][j]; + } + } + features[12] = dissimilarity; + + // Inverse difference + float inv_diff = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + for (int j = 0; j < int(max_vol); ++j) { + inv_diff += GLCM[i][j] / (1 + fabsf(rowGrid[i][j] - colGrid[i][j])); + } + } + features[13] = inv_diff; + + // Inverse difference normalized + float invDiffNorm = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + for (int j = 0; j < int(max_vol); ++j) { + invDiffNorm += GLCM[i][j] / (1 + fabsf(rowGrid[i][j] - colGrid[i][j]) / int(max_vol)); + } + } + features[14] = invDiffNorm; + + // Inverse difference moment + float invDiffMom = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + for (int j = 0; j < int(max_vol); ++j) { + invDiffMom += GLCM[i][j] / (1 + powf((rowGrid[i][j] - colGrid[i][j]), 2)); + } + } + features[15] = invDiffMom; + + // Inverse difference moment normalized + float invDiffMomNorm = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + for (int j = 0; j < int(max_vol); ++j) { + invDiffMomNorm += GLCM[i][j] / (1 + powf((rowGrid[i][j] - colGrid[i][j]), 2) / powf(int(max_vol), 2)); + } + } + features[16] = invDiffMomNorm; + + // Inverse variance + float invVar = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = i + 1; j < int(max_vol); j++) { + invVar += GLCM[i][j] / powf((i - j), 2); + } + } + features[17] = 2*invVar; + + // Correlation + float u_i = 0.0; + float u_j = 0.0; + float std_i = 0.0; + float std_j = 0.0; + float p_i[Ng] = { 0.0 }; + float p_j[Ng] = { 0.0 }; + + // sum over rows + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_i[i] += GLCM[i][j]; + } + } + // sum over columns + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_j[j] += GLCM[i][j]; + } + } + for (int i = 0; i < int(max_vol); i++) { + u_i += vectNg[i] * p_i[i]; + u_j += vectNg[i] * p_j[i]; + } + for (int i = 0; i < int(max_vol); i++) { + std_i += powf(vectNg[i] - u_i, 2) * p_i[i]; + std_j += powf(vectNg[i] - u_j, 2) * p_j[i]; + } + std_i = sqrtf(std_i); + std_j = sqrtf(std_j); + + float tempSum = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + tempSum += rowGrid[i][j] * colGrid[i][j] * GLCM[i][j]; + } + } + float correlation = (1 / (std_i * std_j)) * (-u_i * u_j + tempSum); + features[18] = correlation; + + // Autocorrelation + float autoCorr = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + autoCorr += rowGrid[i][j] * colGrid[i][j] * GLCM[i][j]; + } + } + features[19] = autoCorr; + + // Cluster tendency + float clusterTend = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + clusterTend += powf(rowGrid[i][j] + colGrid[i][j] - u_i - u_j, 2) * GLCM[i][j]; + } + } + features[20] = clusterTend; + + // Cluster shade + float clusterShade = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + clusterShade += powf(rowGrid[i][j] + colGrid[i][j] - u_i - u_j, 3) * GLCM[i][j]; + } + } + features[21] = clusterShade; + + // Cluster prominence + float clusterProm = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + clusterProm += powf(rowGrid[i][j] + colGrid[i][j] - u_i - u_j, 4) * GLCM[i][j]; + } + } + features[22] = clusterProm; + + // First measure of information correlation + float HXY = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + if (GLCM[i][j] > 0.0) { + HXY += GLCM[i][j] * log2f(GLCM[i][j]); + } + } + } + HXY = -HXY; + + float HX = 0.0; + for (int i = 0; i < int(max_vol); i++) { + if (p_i[i] > 0.0) { + HX += p_i[i] * log2f(p_i[i]); + } + } + HX = -HX; + + // Repeat p_i and p_j Ng times + float p_i_temp[Ng][Ng]; + float p_j_temp[Ng][Ng]; + float p_temp[Ng][Ng]; + + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_i_temp[i][j] = p_i[i]; + p_j_temp[i][j] = p_j[j]; + p_temp[i][j] = p_i_temp[i][j] * p_j_temp[i][j]; + } + } + + float HXY1 = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + if (p_temp[i][j] > 0.0) { + HXY1 += GLCM[i][j] * log2f(p_temp[i][j]); + } + } + } + HXY1 = -HXY1; + features[23] = (HXY - HXY1) / HX; + + // Second measure of information correlation + float HXY2 = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + if (p_temp[i][j] > 0.0) { + HXY2 += p_temp[i][j] * log2f(p_temp[i][j]); + } + } + } + HXY2 = -HXY2; + if (HXY > HXY2) { + features[24] = 0.0; + } + else { + features[24] = sqrtf(1 - expf(-2 * (HXY2 - HXY))); + } +} + +extern "C" +__global__ void glcm_filter_global( + float vol[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}][25], + float vol_copy[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}], + bool distCorrection = false) +{ + int i = blockIdx.x * blockDim.x + threadIdx.x; + int j = blockIdx.y * blockDim.y + threadIdx.y; + int k = blockIdx.z * blockDim.z + threadIdx.z; + + if (i < ${shape_volume_0} && j < ${shape_volume_1} && k < ${shape_volume_2} && i >= 0 && j >= 0 && k >= 0) { + // pad size + const int padd_size = (FILTER_SIZE - 1) / 2; + + // size vol + const int size_x = ${shape_volume_0}; + const int size_y = ${shape_volume_1}; + const int size_z = ${shape_volume_2}; + + // skip all calculations if vol at position i,j,k is nan + if (!isnan(vol_copy[i][j][k])) { + // get submatrix + float sub_matrix[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE] = {NAN}; + for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { + for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { + for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { + if ((i - padd_size + idx_i) >= 0 && (i - padd_size + idx_i) < size_x && + (j - padd_size + idx_j) >= 0 && (j - padd_size + idx_j) < size_y && + (k - padd_size + idx_k) >= 0 && (k - padd_size + idx_k) < size_z) { + sub_matrix[idx_i][idx_j][idx_k] = vol_copy[i - padd_size + idx_i][j - padd_size + idx_j][k - padd_size + idx_k]; + } + } + } + } + + // get the maximum value of the submatrix + float max_vol = -3.40282e+38; + for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { + for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { + for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { + max_vol = max(max_vol, sub_matrix[idx_i][idx_j][idx_k]); + } + } + } + + // compute GLCM features + float features[25] = { 0.0 }; + computeGLCMFeatures(sub_matrix, features, max_vol, false); + + // Copy GLCM feature to voxels of the volume + if (i < size_x && j < size_y && k < size_z){ + for (int idx = 0; idx < 25; ++idx) { + vol[i][j][k][idx] = features[idx]; + } + } + } + } +} + +extern "C" +__global__ void glcm_filter_local( + float vol[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}][25], + float vol_copy[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}], + bool distCorrection = false) +{ + int i = blockIdx.x * blockDim.x + threadIdx.x; + int j = blockIdx.y * blockDim.y + threadIdx.y; + int k = blockIdx.z * blockDim.z + threadIdx.z; + + if (i < ${shape_volume_0} && j < ${shape_volume_1} && k < ${shape_volume_2} && i >= 0 && j >= 0 && k >= 0) { + // pad size + const int padd_size = (FILTER_SIZE - 1) / 2; + + // size vol + const int size_x = ${shape_volume_0}; + const int size_y = ${shape_volume_1}; + const int size_z = ${shape_volume_2}; + + // skip all calculations if vol at position i,j,k is nan + if (!isnan(vol_copy[i][j][k])) { + // get submatrix + float sub_matrix[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE] = {NAN}; + for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { + for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { + for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { + if ((i - padd_size + idx_i) >= 0 && (i - padd_size + idx_i) < size_x && + (j - padd_size + idx_j) >= 0 && (j - padd_size + idx_j) < size_y && + (k - padd_size + idx_k) >= 0 && (k - padd_size + idx_k) < size_z) { + sub_matrix[idx_i][idx_j][idx_k] = vol_copy[i - padd_size + idx_i][j - padd_size + idx_j][k - padd_size + idx_k]; + } + } + } + } + + // get the maximum value of the submatrix + float max_vol = -3.40282e+38; + for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { + for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { + for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { + max_vol = max(max_vol, sub_matrix[idx_i][idx_j][idx_k]); + } + } + } + // get the minimum value of the submatrix if discr_type is FBN + float min_val = 3.40282e+38; + if ("${discr_type}" == "FBN") { + for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { + for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { + for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { + min_val = min(min_val, sub_matrix[idx_i][idx_j][idx_k]); + } + } + } + discretize(sub_matrix, max_vol, min_val); + } + + // If FBS discretize the submatrix with user set minimum value + else{ + discretize(sub_matrix, max_vol); + } + + // get the maximum value of the submatrix after discretization + max_vol = -3.40282e+38; + for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { + for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { + for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { + max_vol = max(max_vol, sub_matrix[idx_i][idx_j][idx_k]); + } + } + } + + // compute GLCM features + float features[25] = { 0.0 }; + computeGLCMFeatures(sub_matrix, features, max_vol, false); + + // Copy GLCM feature to voxels of the volume + if (i < size_x && j < size_y && k < size_z){ + for (int idx = 0; idx < 25; ++idx) { + vol[i][j][k][idx] = features[idx]; + } + } + } + } +} +""") + +# Signle-feature kernel +single_glcm_kernel = Template(""" +#include +#include +#include + +# define MAX_SIZE ${max_vol} +# define FILTER_SIZE ${filter_size} + +// Function flatten a 3D matrix into a 1D vector +__device__ float * reshape(float(*matrix)[FILTER_SIZE][FILTER_SIZE]) { + //size of array + const int size = FILTER_SIZE* FILTER_SIZE* FILTER_SIZE; + float flattened[size]; + int index = 0; + for (int i = 0; i < FILTER_SIZE; ++i) { + for (int j = 0; j < FILTER_SIZE; ++j) { + for (int k = 0; k < FILTER_SIZE; ++k) { + flattened[index] = matrix[i][j][k]; + index++; + } + } + } + return flattened; +} + +// Function to perform discretization on the ROI imaging intensities +__device__ void discretize(float vol_quant_re[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE], + float max_val, float min_val=${min_val}) { + + // PARSING ARGUMENTS + float n_q = ${n_q}; + const char* discr_type = "${discr_type}"; + + // DISCRETISATION + if (discr_type == "FBS") { + float w_b = n_q; + for (int i = 0; i < FILTER_SIZE; i++) { + for (int j = 0; j < FILTER_SIZE; j++) { + for (int k = 0; k < FILTER_SIZE; k++) { + float value = vol_quant_re[i][j][k]; + if (!isnan(value)) { + vol_quant_re[i][j][k] = floorf((value - min_val) / w_b) + 1.0; + } + } + } + } + } + else if (discr_type == "FBN") { + float w_b = (max_val - min_val) / n_q; + for (int i = 0; i < FILTER_SIZE; i++) { + for (int j = 0; j < FILTER_SIZE; j++) { + for (int k = 0; k < FILTER_SIZE; k++) { + float value = vol_quant_re[i][j][k]; + if (!isnan(value)) { + vol_quant_re[i][j][k] = floorf(n_q * ((value - min_val) / (max_val - min_val))) + 1.0; + if (value == max_val) { + vol_quant_re[i][j][k] = n_q; + } + } + } + } + } + } + else { + printf("ERROR: discretization type not supported"); + assert(false); + } +} + +// Compute the diagonal probability +__device__ float * GLCMDiagProb(float p_ij[MAX_SIZE][MAX_SIZE], float max_vol) { + int valK[MAX_SIZE]; + for (int i = 0; i < (int)max_vol; ++i) { + valK[i] = i; + } + float p_iminusj[MAX_SIZE] = { 0.0 }; + for (int iterationK = 0; iterationK < (int)max_vol; ++iterationK) { + int k = valK[iterationK]; + float p = 0.0; + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + if (k - fabsf(i - j) == 0) { + p += p_ij[i][j]; + } + } + } + + p_iminusj[iterationK] = p; + } + + return p_iminusj; +} + +// Compute the cross-diagonal probability +__device__ float * GLCMCrossDiagProb(float p_ij[MAX_SIZE][MAX_SIZE], float max_vol) { + float valK[2 * MAX_SIZE - 1]; + // fill valK with 2, 3, 4, ..., 2*max_vol - 1 + for (int i = 0; i < 2 * (int)max_vol - 1; ++i) { + valK[i] = i + 2; + } + float p_iplusj[2*MAX_SIZE - 1] = { 0.0 }; + + for (int iterationK = 0; iterationK < 2*(int)max_vol - 1; ++iterationK) { + int k = valK[iterationK]; + float p = 0.0; + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + if (k - (i + j + 2) == 0) { + p += p_ij[i][j]; + } + } + } + + p_iplusj[iterationK] = p; + } + + return p_iplusj; +} + +__device__ void getGLCMmatrix( + float (*ROIonly)[FILTER_SIZE][FILTER_SIZE], + float GLCMfinal[MAX_SIZE][MAX_SIZE], + float max_vol, + bool distCorrection = true) +{ + // PARSING "distCorrection" ARGUMENT + + const int Ng = MAX_SIZE; + float levels[Ng] = {0}; + // initialize levels to 1, 2, 3, ..., 15 + for (int i = 0; i < (int)max_vol; ++i) { + levels[i] = i + 1; + } + + float levelTemp = max_vol + 1; + + for (int i = 0; i < FILTER_SIZE; ++i) { + for (int j = 0; j < FILTER_SIZE; ++j) { + for (int k = 0; k < FILTER_SIZE; ++k) { + if (isnan(ROIonly[i][j][k])) { + ROIonly[i][j][k] = levelTemp; + } + } + } + } + + int dim_x = FILTER_SIZE; + int dim_y = FILTER_SIZE; + int dim_z = FILTER_SIZE; + + // Reshape the 3D matrix to a 1D vector + float *q2; + q2 = reshape(ROIonly); + + // Combine levels and level_temp into qs + float qs[Ng + 1] = {0}; + for (int i = 0; i < (int)max_vol + 1; ++i) { + if (i == (int)max_vol) { + qs[i] = levelTemp; + break; + } + qs[i] = levels[i]; + } + const int lqs = Ng + 1; + + // Create a q3 matrix and assign values based on qs + int q3[FILTER_SIZE* FILTER_SIZE* FILTER_SIZE] = {0}; + + // fill q3 with 0s + for (int i = 0; i < FILTER_SIZE * FILTER_SIZE * FILTER_SIZE; ++i) { + q3[i] = 0; + } + for (int k = 0; k < (int)max_vol + 1; ++k) { + for (int i = 0; i < FILTER_SIZE * FILTER_SIZE * FILTER_SIZE; ++i) { + if (fabsf(q2[i] - qs[k]) < 1.19209e-07) { + q3[i] = k; + } + } + } + + // Reshape q3 back to the original dimensions (dimX, dimY, dimZ) + float reshaped_q3[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE]; + + int index = 0; + for (int i = 0; i < dim_x; ++i) { + for (int j = 0; j < dim_y; ++j) { + for (int k = 0; k < dim_z; ++k) { + reshaped_q3[i][j][k] = q3[index++]; + } + } + } + + + float GLCM[lqs][lqs] = {0}; + + // fill GLCM with 0s + for (int i = 0; i < (int)max_vol + 1; ++i) { + for (int j = 0; j < (int)max_vol + 1; ++j) { + GLCM[i][j] = 0; + } + } + + for (int i = 1; i <= dim_x; ++i) { + int i_min = max(1, i - 1); + int i_max = min(i + 1, dim_x); + for (int j = 1; j <= dim_y; ++j) { + int j_min = max(1, j - 1); + int j_max = min(j + 1, dim_y); + for (int k = 1; k <= dim_z; ++k) { + int k_min = max(1, k - 1); + int k_max = min(k + 1, dim_z); + int val_q3 = reshaped_q3[i - 1][j - 1][k - 1]; + for (int I2 = i_min; I2 <= i_max; ++I2) { + for (int J2 = j_min; J2 <= j_max; ++J2) { + for (int K2 = k_min; K2 <= k_max; ++K2) { + if (I2 == i && J2 == j && K2 == k) { + continue; + } + else { + int val_neighbor = reshaped_q3[I2 - 1][J2 - 1][K2 - 1]; + if (distCorrection) { + // Discretization length correction + GLCM[val_q3][val_neighbor] += + sqrtf(fabsf(I2 - i) + + fabsf(J2 - j) + + fabsf(K2 - k)); + } + else { + GLCM[val_q3][val_neighbor] += 1; + } + } + } + } + } + } + } + } + + // Eliminate last row and column + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + GLCMfinal[i][j] = GLCM[i][j]; + } + } +} + +__device__ float computeGLCMFeatures(float(*vol)[FILTER_SIZE][FILTER_SIZE], int feature, float max_vol, bool distCorrection) { + + float GLCM[MAX_SIZE][MAX_SIZE] = { 0.0 }; + + // Call function with specified distCorrection + getGLCMmatrix(vol, GLCM, max_vol, distCorrection); + + // Normalize GLCM + float sumGLCM = 0.0; + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + sumGLCM += GLCM[i][j]; + } + } + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + GLCM[i][j] /= sumGLCM; + } + } + + // Compute textures + // // Number of gray levels + const int Ng = MAX_SIZE; + float vectNg[Ng]; + + // fill vectNg with 1, 2, ..., Ng + for (int i = 0; i < (int)max_vol; ++i) { + vectNg[i] = i + 1; + } + + // Create meshgird of size Ng x Ng + float colGrid[Ng][Ng] = { 0.0 }; + float rowGrid[Ng][Ng] = { 0.0 }; + + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + colGrid[i][j] = vectNg[j]; + } + } + for (int j = 0; j < int(max_vol); ++j) { + for (int i = 0; i < int(max_vol); ++i) { + rowGrid[i][j] = vectNg[i]; + } + } + int step_i = 0; + int step_j = 0; + + // Joint maximum + if (feature == 0) { + float joint_max = NAN; + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + joint_max = max(joint_max, GLCM[i][j]); + } + } + return joint_max; + } + // Joint average + else if (feature == 1) { + float u = 0.0; + for (int i = 0; i < (int)max_vol; ++i) { + step_i = 0; + for (int j = 0; j < (int)max_vol; ++j) { + u += GLCM[i][j] * rowGrid[i][j]; + step_i++; + } + step_j++; + } + return u; + } + // Joint variance + else if (feature == 2) { + step_j = 0; + float var = 0.0; + float u = 0.0; + for (int i = 0; i < (int)max_vol; ++i) { + step_i = 0; + for (int j = 0; j < (int)max_vol; ++j) { + u += GLCM[i][j] * rowGrid[i][j]; + step_i++; + } + step_j++; + } + for (int i = 0; i < (int)max_vol; ++i) { + step_i = 0; + for (int j = 0; j < (int)max_vol; ++j) { + var += GLCM[i][j] * powf(rowGrid[i][j] - u, 2); + step_i++; + } + step_j++; + } + return var; + } + // Joint entropy + else if (feature == 3) { + float entropy = 0.0; + for (int i = 0; i < (int)max_vol; ++i) { + for (int j = 0; j < (int)max_vol; ++j) { + if (GLCM[i][j] > 0.0) { + entropy += GLCM[i][j] * log2f(GLCM[i][j]); + } + } + } + return -entropy; + } + // Difference average + else if (feature == 4) { + float *p_iminusj; + p_iminusj = GLCMDiagProb(GLCM, max_vol); + float diff_avg = 0.0; + float k[Ng]; + // fill k with 0, 1, ..., Ng - 1 + for (int i = 0; i < int(max_vol); ++i) { + k[i] = i; + } + for (int i = 0; i < int(max_vol); ++i) { + diff_avg += p_iminusj[i] * k[i]; + } + return diff_avg; + } + // Difference variance + else if (feature == 5) { + float* p_iminusj; + p_iminusj = GLCMDiagProb(GLCM, max_vol); + float diff_avg = 0.0; + float k[Ng]; + // fill k with 0, 1, ..., Ng - 1 + for (int i = 0; i < int(max_vol); ++i) { + k[i] = i; + } + for (int i = 0; i < int(max_vol); ++i) { + diff_avg += p_iminusj[i] * k[i]; + } + float diff_var = 0.0; + step_i = 0; + for (int i = 0; i < int(max_vol); ++i) { + diff_var += p_iminusj[i] * powf(k[i] - diff_avg, 2); + step_i++; + } + return diff_var; + } + // Difference entropy + else if (feature == 6) { + float* p_iminusj = GLCMDiagProb(GLCM, max_vol); + float diff_entropy = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + if (p_iminusj[i] > 0.0) { + diff_entropy += p_iminusj[i] * log2f(p_iminusj[i]); + } + } + return -diff_entropy; + } + // Sum average + else if (feature == 7) { + float k[2 * Ng - 1]; + // fill k with 2, 3, ..., 2 * Ng + for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { + k[i] = i + 2; + } + float sum_avg = 0.0; + float* p_iplusj = GLCMCrossDiagProb(GLCM, max_vol); + for (int i = 0; i < 2*int(max_vol) - 1; ++i) { + sum_avg += p_iplusj[i] * k[i]; + } + return sum_avg; + } + // Sum variance + else if (feature == 8) { + float k[2 * Ng - 1]; + // fill k with 2, 3, ..., 2 * Ng + for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { + k[i] = i + 2; + } + float sum_avg = 0.0; + float* p_iplusj = GLCMCrossDiagProb(GLCM, max_vol); + for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { + sum_avg += p_iplusj[i] * k[i]; + } + float sum_var = 0.0; + for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { + sum_var += p_iplusj[i] * powf(k[i] - sum_avg, 2); + } + return sum_var; + } + // Sum entropy + else if (feature == 9) { + float sum_entr = 0.0; + float* p_iplusj = GLCMCrossDiagProb(GLCM, max_vol); + for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { + if (p_iplusj[i] > 0.0) { + sum_entr += p_iplusj[i] * log2f(p_iplusj[i]); + } + } + return -sum_entr; + } + // Angular second moment (energy) + else if (feature == 10) { + float energy = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + for (int j = 0; j < int(max_vol); ++j) { + energy += powf(GLCM[i][j], 2); + } + } + return energy; + } + // Contrast + else if (feature == 11) { + float contrast = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + for (int j = 0; j < int(max_vol); ++j) { + contrast += powf(rowGrid[i][j] - colGrid[i][j], 2) * GLCM[i][j]; + } + } + return contrast; + } + // Dissimilarity + else if (feature == 12) { + float dissimilarity = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + for (int j = 0; j < int(max_vol); ++j) { + dissimilarity += fabsf(rowGrid[i][j] - colGrid[i][j]) * GLCM[i][j]; + } + } + return dissimilarity; + } + // Inverse difference + else if (feature == 13) { + float inv_diff = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + for (int j = 0; j < int(max_vol); ++j) { + inv_diff += GLCM[i][j] / (1 + fabsf(rowGrid[i][j] - colGrid[i][j])); + } + } + return inv_diff; + } + // Inverse difference normalized + else if (feature == 14) { + float invDiffNorm = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + for (int j = 0; j < int(max_vol); ++j) { + invDiffNorm += GLCM[i][j] / (1 + fabsf(rowGrid[i][j] - colGrid[i][j]) / int(max_vol)); + } + } + return invDiffNorm; + } + // Inverse difference moment + else if (feature == 15) { + float invDiffMom = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + for (int j = 0; j < int(max_vol); ++j) { + invDiffMom += GLCM[i][j] / (1 + powf((rowGrid[i][j] - colGrid[i][j]), 2)); + } + } + return invDiffMom; + } + // Inverse difference moment normalized + else if (feature == 16) { + float invDiffMomNorm = 0.0; + for (int i = 0; i < int(max_vol); ++i) { + for (int j = 0; j < int(max_vol); ++j) { + invDiffMomNorm += GLCM[i][j] / (1 + powf((rowGrid[i][j] - colGrid[i][j]), 2) / powf(int(max_vol), 2)); + } + } + return invDiffMomNorm; + } + // Inverse variance + else if (feature == 17) { + float invVar = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = i + 1; j < int(max_vol); j++) { + invVar += GLCM[i][j] / powf((i - j), 2); + } + } + return 2*invVar; + } + // Correlation + else if (feature == 18) { + float u_i = 0.0; + float u_j = 0.0; + float std_i = 0.0; + float std_j = 0.0; + float p_i[Ng] = { 0.0 }; + float p_j[Ng] = { 0.0 }; + + // sum over rows + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_i[i] += GLCM[i][j]; + } + } + // sum over columns + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_j[j] += GLCM[i][j]; + } + } + for (int i = 0; i < int(max_vol); i++) { + u_i += vectNg[i] * p_i[i]; + u_j += vectNg[i] * p_j[i]; + } + for (int i = 0; i < int(max_vol); i++) { + std_i += powf(vectNg[i] - u_i, 2) * p_i[i]; + std_j += powf(vectNg[i] - u_j, 2) * p_j[i]; + } + std_i = sqrtf(std_i); + std_j = sqrtf(std_j); + + float tempSum = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + tempSum += rowGrid[i][j] * colGrid[i][j] * GLCM[i][j]; + } + } + float correlation = (1 / (std_i * std_j)) * (-u_i * u_j + tempSum); + return correlation; + } + // Autocorrelation + else if (feature == 19) { + float u_i = 0.0; + float u_j = 0.0; + float std_i = 0.0; + float std_j = 0.0; + float p_i[Ng] = { 0.0 }; + float p_j[Ng] = { 0.0 }; + + // sum over rows + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_i[i] += GLCM[i][j]; + } + } + // sum over columns + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_j[j] += GLCM[i][j]; + } + } + for (int i = 0; i < int(max_vol); i++) { + u_i += vectNg[i] * p_i[i]; + u_j += vectNg[i] * p_j[i]; + } + for (int i = 0; i < int(max_vol); i++) { + std_i += powf(vectNg[i] - u_i, 2) * p_i[i]; + std_j += powf(vectNg[i] - u_j, 2) * p_j[i]; + } + std_i = sqrtf(std_i); + std_j = sqrtf(std_j); + + float autoCorr = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + autoCorr += rowGrid[i][j] * colGrid[i][j] * GLCM[i][j]; + } + } + + return autoCorr; + } + // Cluster tendency + else if (feature == 20) { + float u_i = 0.0; + float u_j = 0.0; + float p_i[Ng] = { 0.0 }; + float p_j[Ng] = { 0.0 }; + + // sum over rows + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_i[i] += GLCM[i][j]; + } + } + // sum over columns + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_j[j] += GLCM[i][j]; + } + } + for (int i = 0; i < int(max_vol); i++) { + u_i += vectNg[i] * p_i[i]; + u_j += vectNg[i] * p_j[i]; + } + float clusterTend = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + clusterTend += powf(rowGrid[i][j] + colGrid[i][j] - u_i - u_j, 2) * GLCM[i][j]; + } + } + return clusterTend; + } + // Cluster shade + else if (feature == 21) { + float u_i = 0.0; + float u_j = 0.0; + float p_i[Ng] = { 0.0 }; + float p_j[Ng] = { 0.0 }; + + // sum over rows + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_i[i] += GLCM[i][j]; + } + } + // sum over columns + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_j[j] += GLCM[i][j]; + } + } + for (int i = 0; i < int(max_vol); i++) { + u_i += vectNg[i] * p_i[i]; + u_j += vectNg[i] * p_j[i]; + } + float clusterShade = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + clusterShade += powf(rowGrid[i][j] + colGrid[i][j] - u_i - u_j, 3) * GLCM[i][j]; + } + } + return clusterShade; + } + // Cluster prominence + else if (feature == 22) { + float u_i = 0.0; + float u_j = 0.0; + float p_i[Ng] = { 0.0 }; + float p_j[Ng] = { 0.0 }; + + // sum over rows + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_i[i] += GLCM[i][j]; + } + } + // sum over columns + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_j[j] += GLCM[i][j]; + } + } + for (int i = 0; i < int(max_vol); i++) { + u_i += vectNg[i] * p_i[i]; + u_j += vectNg[i] * p_j[i]; + } + float clusterProm = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + clusterProm += powf(rowGrid[i][j] + colGrid[i][j] - u_i - u_j, 4) * GLCM[i][j]; + } + } + return clusterProm; + } + // First measure of information correlation + else if (feature == 23) { + float p_i[Ng] = { 0.0 }; + float p_j[Ng] = { 0.0 }; + // sum over rows + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_i[i] += GLCM[i][j]; + } + } + // sum over columns + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_j[j] += GLCM[i][j]; + } + } + + float HXY = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + if (GLCM[i][j] > 0.0) { + HXY += GLCM[i][j] * log2f(GLCM[i][j]); + } + } + } + HXY = -HXY; + + float HX = 0.0; + for (int i = 0; i < int(max_vol); i++) { + if (p_i[i] > 0.0) { + HX += p_i[i] * log2f(p_i[i]); + } + } + HX = -HX; + + // Repeat p_i and p_j Ng times + float p_i_temp[Ng][Ng]; + float p_j_temp[Ng][Ng]; + float p_temp[Ng][Ng]; + + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_i_temp[i][j] = p_i[i]; + p_j_temp[i][j] = p_j[j]; + p_temp[i][j] = p_i_temp[i][j] * p_j_temp[i][j]; + } + } + + float HXY1 = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + if (p_temp[i][j] > 0.0) { + HXY1 += GLCM[i][j] * log2f(p_temp[i][j]); + } + } + } + HXY1 = -HXY1; + + return (HXY - HXY1) / HX; + } + // Second measure of information correlation + else if (feature == 24) { + float p_i[Ng] = { 0.0 }; + float p_j[Ng] = { 0.0 }; + // sum over rows + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_i[i] += GLCM[i][j]; + } + } + // sum over columns + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_j[j] += GLCM[i][j]; + } + } + + float HXY = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + if (GLCM[i][j] > 0.0) { + HXY += GLCM[i][j] * log2f(GLCM[i][j]); + } + } + } + HXY = -HXY; + + // Repeat p_i and p_j Ng times + float p_i_temp[Ng][Ng]; + float p_j_temp[Ng][Ng]; + float p_temp[Ng][Ng]; + + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + p_i_temp[i][j] = p_i[i]; + p_j_temp[i][j] = p_j[j]; + p_temp[i][j] = p_i_temp[i][j] * p_j_temp[i][j]; + } + } + + float HXY2 = 0.0; + for (int i = 0; i < int(max_vol); i++) { + for (int j = 0; j < int(max_vol); j++) { + if (p_temp[i][j] > 0.0) { + HXY2 += p_temp[i][j] * log2f(p_temp[i][j]); + } + } + } + HXY2 = -HXY2; + if (HXY > HXY2) { + return 0.0; + } + else { + return sqrtf(1 - expf(-2 * (HXY2 - HXY))); + } + } + else { + // Print error message + printf("Error: feature %d not implemented\\n", feature); + assert(false); + } +} + +extern "C" +__global__ void glcm_filter_global( + float vol[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}], + float vol_copy[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}], + bool distCorrection = false) +{ + int i = blockIdx.x * blockDim.x + threadIdx.x; + int j = blockIdx.y * blockDim.y + threadIdx.y; + int k = blockIdx.z * blockDim.z + threadIdx.z; + + if (i < ${shape_volume_0} && j < ${shape_volume_1} && k < ${shape_volume_2} && i >= 0 && j >= 0 && k >= 0) { + // pad size + const int padd_size = (FILTER_SIZE - 1) / 2; + + // size vol + const int size_x = ${shape_volume_0}; + const int size_y = ${shape_volume_1}; + const int size_z = ${shape_volume_2}; + + // skip all calculations if vol at position i,j,k is nan + if (!isnan(vol_copy[i][j][k])) { + // get submatrix + float sub_matrix[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE] = {NAN}; + for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { + for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { + for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { + if ((i - padd_size + idx_i) >= 0 && (i - padd_size + idx_i) < size_x && + (j - padd_size + idx_j) >= 0 && (j - padd_size + idx_j) < size_y && + (k - padd_size + idx_k) >= 0 && (k - padd_size + idx_k) < size_z) { + sub_matrix[idx_i][idx_j][idx_k] = vol_copy[i - padd_size + idx_i][j - padd_size + idx_j][k - padd_size + idx_k]; + } + } + } + } + + // get the maximum value of the submatrix + float max_vol = -3.40282e+38; + for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { + for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { + for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { + max_vol = max(max_vol, sub_matrix[idx_i][idx_j][idx_k]); + } + } + } + + // get feature index + const int feature = ${feature_index}; + + // compute GLCM features + float glcm_feature = computeGLCMFeatures(sub_matrix, feature, max_vol, false); + + // Copy GLCM feature to voxels of the volume + if (i < size_x && j < size_y && k < size_z){ + vol[i][j][k] = glcm_feature; + } + } + } +} + +extern "C" +__global__ void glcm_filter_local( + float vol[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}], + float vol_copy[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}], + bool distCorrection = false) +{ + int i = blockIdx.x * blockDim.x + threadIdx.x; + int j = blockIdx.y * blockDim.y + threadIdx.y; + int k = blockIdx.z * blockDim.z + threadIdx.z; + + if (i < ${shape_volume_0} && j < ${shape_volume_1} && k < ${shape_volume_2} && i >= 0 && j >= 0 && k >= 0) { + // pad size + const int padd_size = (FILTER_SIZE - 1) / 2; + + // size vol + const int size_x = ${shape_volume_0}; + const int size_y = ${shape_volume_1}; + const int size_z = ${shape_volume_2}; + + // skip all calculations if vol at position i,j,k is nan + if (!isnan(vol_copy[i][j][k])) { + // get submatrix + float sub_matrix[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE] = {NAN}; + for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { + for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { + for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { + if ((i - padd_size + idx_i) >= 0 && (i - padd_size + idx_i) < size_x && + (j - padd_size + idx_j) >= 0 && (j - padd_size + idx_j) < size_y && + (k - padd_size + idx_k) >= 0 && (k - padd_size + idx_k) < size_z) { + sub_matrix[idx_i][idx_j][idx_k] = vol_copy[i - padd_size + idx_i][j - padd_size + idx_j][k - padd_size + idx_k]; + } + } + } + } + + // get the maximum value of the submatrix + float max_vol = -3.40282e+38; + for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { + for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { + for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { + max_vol = max(max_vol, sub_matrix[idx_i][idx_j][idx_k]); + } + } + } + // get the minimum value of the submatrix if discr_type is FBN + float min_val = 3.40282e+38; + if ("${discr_type}" == "FBN") { + for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { + for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { + for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { + min_val = min(min_val, sub_matrix[idx_i][idx_j][idx_k]); + } + } + } + discretize(sub_matrix, max_vol, min_val); + } + + // If FBS discretize the submatrix with user set minimum value + else{ + discretize(sub_matrix, max_vol); + } + + // get the maximum value of the submatrix after discretization + max_vol = -3.40282e+38; + for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { + for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { + for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { + max_vol = max(max_vol, sub_matrix[idx_i][idx_j][idx_k]); + } + } + } + + // get feature index + const int feature = ${feature_index}; + + // compute GLCM features + float glcm_feature = computeGLCMFeatures(sub_matrix, feature, max_vol, false); + + // Copy GLCM feature to voxels of the volume + if (i < size_x && j < size_y && k < size_z){ + vol[i][j][k] = glcm_feature; + } + } + } +} +""") \ No newline at end of file diff --git a/MEDiml/filters/utils.py b/MEDiml/filters/utils.py new file mode 100644 index 0000000..d25720c --- /dev/null +++ b/MEDiml/filters/utils.py @@ -0,0 +1,107 @@ +from typing import List + +import numpy as np +from scipy.signal import fftconvolve + + +def pad_imgs( + images: np.ndarray, + padding_length: List, + axis: List, + mode: str + )-> np.ndarray: + """Apply padding on a 3d images using a 2D padding pattern. + + Args: + images (ndarray): a numpy array that represent the image. + padding_length (List): The padding length that will apply on each side of each axe. + axis (List): A list of axes on which the padding will be done. + mode (str): The padding mode. Check options here: `numpy.pad + `__. + + Returns: + ndarray: A numpy array that represent the padded image. + """ + pad_tuple = () + j = 1 + + for i in range(np.ndim(images)): + if i in axis: + pad_tuple += ((padding_length[-j], padding_length[-j]),) + j += 1 + else: + pad_tuple += ((0, 0),) + + return np.pad(images, pad_tuple, mode=mode) + +def convolve( + dim: int, + kernel: np.ndarray, + images: np.ndarray, + orthogonal_rot: bool=False, + mode: str = "symmetric" + ) -> np.ndarray: + """Convolve a given n-dimensional array with the kernel to generate a filtered image. + + Args: + dim (int): The dimension of the images. + kernel (ndarray): The kernel to use for the convolution. + images (ndarray): A n-dimensional numpy array that represent a batch of images to filter. + orthogonal_rot (bool, optional): If true, the 3D images will be rotated over coronal, axial and sagittal axis. + mode (str, optional): The padding mode. Check options here: `numpy.pad + `__. + + Returns: + ndarray: The filtered image. + """ + + in_size = np.shape(images) + + # We only handle 2D or 3D images. + assert len(in_size) == 3 or len(in_size) == 4, \ + "The tensor should have the followed shape (B, H, W) or (B, D, H, W)" + + if not orthogonal_rot: + # If we have a 2D kernel but a 3D images, we squeeze the tensor + if dim < len(in_size) - 1: + images = images.reshape((in_size[0] * in_size[1], in_size[2], in_size[3])) + + # We compute the padding size along each dimension + padding = [int((kernel.shape[-1] - 1) / 2) for _ in range(dim)] + pad_axis_list = [i for i in range(1, dim+1)] + + # We pad the images and we add the channel axis. + padded_imgs = pad_imgs(images, padding, pad_axis_list, mode) + new_imgs = np.expand_dims(padded_imgs, axis=1) + + # Operate the convolution + if dim < len(in_size) - 1: + # If we have a 2D kernel but a 3D images, we convolve slice by slice + result_list = [fftconvolve(np.expand_dims(new_imgs[i], axis=0), kernel, mode='valid') for i in range(len(images))] + result = np.squeeze(np.stack(result_list), axis=2) + + else : + result = fftconvolve(new_imgs, kernel, mode='valid') + + # Reshape the data to retrieve the following format: (B, C, D, H, W) + if dim < len(in_size) - 1: + result = result.reshape(( + in_size[0], in_size[1], result.shape[1], in_size[2], in_size[3]) + ).transpose(0, 2, 1, 3, 4) + + # If we want orthogonal rotation + else: + coronal_imgs = images + axial_imgs, sagittal_imgs = np.rot90(images, 1, (1, 2)), np.rot90(images, 1, (1, 3)) + + result_coronal = convolve(dim, kernel, coronal_imgs, False, mode) + result_axial = convolve(dim, kernel, axial_imgs, False, mode) + result_sagittal = convolve(dim, kernel, sagittal_imgs, False, mode) + + # split and unflip and stack the result on a new axis + result_axial = np.rot90(result_axial, 1, (3, 2)) + result_sagittal = np.rot90(result_sagittal, 1, (4, 2)) + + result = np.stack([result_coronal, result_axial, result_sagittal]) + + return result diff --git a/MEDiml/filters/wavelet.py b/MEDiml/filters/wavelet.py new file mode 100644 index 0000000..f276663 --- /dev/null +++ b/MEDiml/filters/wavelet.py @@ -0,0 +1,237 @@ +import math +from itertools import combinations, permutations +from typing import List, Union + +import numpy as np +import pywt + +from ..MEDscan import MEDscan +from ..utils.image_volume_obj import image_volume_obj + + +class Wavelet(): + """ + The wavelet filter class. + """ + + def __init__( + self, + ndims: int, + wavelet_name="haar", + padding="symmetric", + rot_invariance=False): + """The constructor of the wavelet filter + + Args: + ndims (int): The number of dimension of the images that will be filter as int. + wavelet_name (str): The name of the wavelet kernel as string. + padding (str): The padding type that will be used to produce the convolution + rot_invariance (bool): If true, rotation invariance will be done on the images. + + Returns: + None + """ + self.dim = ndims + self.padding = padding + self.rot = rot_invariance + self.wavelet = None + self.kernel_length = None + self.create_kernel(wavelet_name) + + def create_kernel(self, + wavelet_name: str): + """Get the wavelet object and his kernel length. + + Args: + wavelet_name (str): A string that represent the wavelet name that will be use to create the kernel + + Returns: + None + """ + + self.wavelet = pywt.Wavelet(wavelet_name) + self.kernel_length = max(self.wavelet.rec_len, self.wavelet.dec_len) + + def __unpad(self, + images: np.ndarray, + padding: List) -> np.ndarray: + """Unpad a batch of images + + Args: + images: A numpy nd-array or a list that represent the batch of padded images. + The shape should be (B, H, W) or (B, H, W, D) + padding: a list of length 2*self.dim that gives the length of padding on each side of each axis. + + Returns: + ndarray: A numpy nd-array or a list that represent the batch of unpadded images + """ + + if self.dim == 2: + return images[:, padding[0]:-padding[1], padding[2]:-padding[3]] + elif self.dim == 3: + return images[:, padding[0]:-padding[1], padding[2]:-padding[3], padding[4]:-padding[5]] + else: + raise NotImplementedError + + def __get_pad_length(self, + image_shape: List, + level: int) -> np.ndarray: + """Compute the padding length needed to have a padded image where the length + along each axis is a multiple 2^level. + + Args: + image_shape (List): a list of integer that describe the length of the image along each axis. + level (int): The level of the wavelet transform + + Returns: + ndarray: An integer list of length 2*self.dim that gives the length of padding on each side of each axis. + """ + padding = [] + ker_length = self.kernel_length*level + for l in image_shape: + padded_length = math.ceil((l + 2*(ker_length-1)) / 2**level) * 2**level - l + padding.extend([math.floor(padded_length/2), math.ceil(padded_length/2)]) + + return padding + + def _pad_imgs(self, + images: np.ndarray, + padding, + axis: List): + """Apply padding on a 3d images using a 2D padding pattern (special for wavelet). + + Args: + images: a numpy array that represent the image. + padding: The padding length that will apply on each side of each axe. + axis: A list of axes on which the padding will be done. + + Returns: + ndarray: A numpy array that represent the padded image. + """ + pad_tuple = () + j = 0 + + for i in range(np.ndim(images)): + if i in axis: + pad_tuple += ((padding[j], padding[j+1]),) + j += 2 + else: + pad_tuple += ((0, 0),) + + return np.pad(images, pad_tuple, mode=self.padding) + + + def convolve(self, + images: np.ndarray, + _filter="LHL", + level=1)-> np.ndarray: + """Filter a given batch of images using pywavelet. + + Args: + images (ndarray): A n-dimensional numpy array that represent the images to filter + _filter (str): The filter to uses. + level (int): The number of decomposition steps to perform. + + Returns: + ndarray: The filtered image as numpy nd-array + """ + + # We pad the images + padding = self.__get_pad_length(np.shape(images[0]), level) + axis_list = [i for i in range(0, self.dim)] + images = np.expand_dims(self._pad_imgs(images[0], padding, axis_list), axis=0) + + # We generate the to collect the result from pywavelet dictionary + _index = str().join(['a' if _filter[i] == 'L' else 'd' for i in range(len(_filter))]) + + if self.rot: + result = [] + _index_list = np.unique([str().join(perm) for perm in permutations(_index, self.dim)]) + + # For each images, we flip each axis. + for image in images: + axis_rot = [comb for j in range(self.dim+1) for comb in combinations(np.arange(self.dim), j)] + images_rot = [np.flip(image, axis) for axis in axis_rot] + + res_rot = [] + for i in range(len(images_rot)): + filtered_image = pywt.swtn(images_rot[i], self.wavelet, level=level)[0] + res_rot.extend([np.flip(filtered_image[j], axis=axis_rot[i]) for j in _index_list]) + + result.extend([np.mean(res_rot, axis=0)]) + else: + result = [] + for i in range(len(images)): + result.extend([pywt.swtn(images[i], self.wavelet, level=level)[level-1][_index]]) + + return self.__unpad(np.array(result), padding) + +def apply_wavelet( + input_images: Union[np.ndarray, image_volume_obj], + medscan: MEDscan = None, + ndims: int = 3, + wavelet_name: str = "haar", + subband: str = "LHL", + level: int = 1, + padding: str = "symmetric", + rot_invariance: bool = False + ) -> np.ndarray: + """Apply the mean filter to the input image + + Args: + input_images (ndarray): The image to filter. + medscan (MEDscan, optional): The MEDscan object that will provide the filter parameters. + ndims (int, optional): The number of dimensions of the input image. + wavelet_name (str): The name of the wavelet kernel as string. + level (List[str], optional): The number of decompositions steps to perform. + subband (str, optional): String of the 1D wavelet kernels ("H" for high-pass + filter or "L" for low-pass filter). Must have a size of ``ndims``. + padding (str, optional): The padding type that will be used to produce the convolution. Check options + here: `numpy.pad `__. + rot_invariance (bool, optional): If true, rotation invariance will be done on the kernel. + + Returns: + ndarray: The filtered image. + """ + # Check if the input is a numpy array or a Image volume object + spatial_ref = None + if type(input_images) == image_volume_obj: + spatial_ref = input_images.spatialRef + input_images = input_images.data + + # Convert to shape : (B, W, H, D) + input_images = np.expand_dims(input_images.astype(np.float64), axis=0) + + if medscan: + # Initialize filter class instance + _filter = Wavelet( + ndims=medscan.params.filter.wavelet.ndims, + wavelet_name=medscan.params.filter.wavelet.basis_function, + rot_invariance=medscan.params.filter.wavelet.rot_invariance, + padding=medscan.params.filter.wavelet.padding + ) + # Run convolution + result = _filter.convolve( + input_images, + _filter=medscan.params.filter.wavelet.subband, + level=medscan.params.filter.wavelet.level + ) + else: + # Initialize filter class instance + _filter = Wavelet( + ndims=ndims, + wavelet_name=wavelet_name, + rot_invariance=rot_invariance, + padding=padding + ) + # Run convolution + result = _filter.convolve( + input_images, + _filter=subband, + level=level + ) + + if spatial_ref: + return image_volume_obj(np.squeeze(result), spatial_ref) + else: + return np.squeeze(result) \ No newline at end of file diff --git a/MEDiml/learning/DataCleaner.py b/MEDiml/learning/DataCleaner.py new file mode 100644 index 0000000..d3e2c92 --- /dev/null +++ b/MEDiml/learning/DataCleaner.py @@ -0,0 +1,198 @@ +import random +from typing import Dict, List + +import numpy as np +import pandas as pd + + +class DataCleaner: + """ + Class that will clean features of the csv by removing features with too many missing values, + too little variation, too many missing values per sample, too little variation per sample, + and imputing missing values. + """ + def __init__(self, df_features: pd.DataFrame, type: str = "continuous"): + """ + Constructor of the class DataCleaner + + Args: + df_features (pd.DataFrame): Table of features. + type (str): Type of variable: "continuous", "hcategorical" or "icategorical". Defaults to "continuous". + """ + self.df_features = df_features + self.type = type + + def __update_df_features(self, var_of_type: List[str], flag_var_out: List[bool]) -> List[str]: + """ + Updates the variable table by deleting the features that are not in the variable of type + + Args: + var_of_type (List[str]): List of variable names. + flag_var_out (List[bool]): List of variables to flag out. + + Returns: + List[str]: List of variable names that were not flagged out. + """ + var_to_delete = np.delete(var_of_type, [i for i, v in enumerate(flag_var_out) if not v]) + var_of_type = np.delete(var_of_type, [i for i, v in enumerate(flag_var_out) if v]) + self.df_features = self.df_features.drop(var_to_delete, axis=1) + return var_of_type + + def cut_off_missing_per_sample(self, var_of_type: List[str], missing_cutoff : float = 0.25) -> None: + """ + Removes observations/samples with more than ``missing_cutoff`` missing features. + + Args: + var_of_type (List[str]): List of variable names. + missing_cutoff (float): Maximum percentage cut-offs of missing features per sample. Defaults to 25%. + + Returns: + None. + """ + # Initialization + n_observation, n_features = self.df_features.shape + empty_vec = np.zeros(n_observation, dtype=int) + data = self.df_features[var_of_type] + empty_vec += data.isna().sum(axis=1).values + + # Gathering results + ind_obs_out = np.where(((empty_vec/n_features) > missing_cutoff) == True) + self.df_features = self.df_features.drop(self.df_features.index[ind_obs_out]) + + def cut_off_missing_per_feature(self, var_of_type: List[str], missing_cutoff : float = 0.1) -> List[str]: + """ + Removes features with more than ``missing_cutoff`` missing patients. + + Args: + var_of_type (list): List of variable names. + missing_cutoff (float): maximal percentage cut-offs of missing patient samples per variable. + + Returns: + List[str]: List of variable names that were not flagged out. + """ + flag_var_out = (((self.df_features[var_of_type].isna().sum()) / self.df_features.shape[0]) > missing_cutoff) + return self.__update_df_features(var_of_type, flag_var_out) + + def cut_off_variation(self, var_of_type: List[str], cov_cutoff : float = 0.1) -> List[str]: + """ + Removes features with a coefficient of variation (cov) less than ``cov_cutoff``. + + Args: + var_of_type (list): List of variable names. + cov_cutoff (float): minimal coefficient of variation cut-offs over samples per variable. Defaults to 10%. + + Returns: + List[str]: List of variable names that were not flagged out. + """ + eps = np.finfo(np.float32).eps + cov_df_features = (self.df_features[var_of_type].std(skipna=True) / self.df_features[var_of_type].mean(skipna=True)) + flag_var_out = cov_df_features.abs().add(eps) < cov_cutoff + return self.__update_df_features(var_of_type, flag_var_out) + + def impute_missing(self, var_of_type: List[str], imputation_method : str = "mean") -> None: + """ + Imputes missing values of the features of type. + + Args: + var_of_type (list): List of variable names. + imputation_method (str): Method of imputation. Can be "mean", "median", "mode" or "random". + For "random" imputation, a seed can be provided by adding the seed value after the method + name, for example "random42". + + Returns: + None. + """ + if self.type in ['continuous', 'hcategorical']: + # random imputation + if 'random' in imputation_method: + if len(imputation_method) > 6: + try: + seed = int(imputation_method[7:]) + random.seed(seed) + except Exception as e: + print(f"Warning: Seed must be an integer. Random seed will be set to None. str({e})") + random.seed(a=None) + else: + random.seed(a=None) + self.df_features[var_of_type] = self.df_features[var_of_type].apply(lambda x: x.fillna(random.choice(list(x.dropna(axis=0))))) + + # Imputation with median + elif 'median' in imputation_method: + self.df_features[var_of_type] = self.df_features[var_of_type].fillna(self.df_features[var_of_type].median()) + + # Imputation with mean + elif 'mean' in imputation_method: + self.df_features[var_of_type] = self.df_features[var_of_type].fillna(self.df_features[var_of_type].mean()) + + else: + raise ValueError("Imputation method for continuous and hcategorical features must be 'random', 'median' or 'mean'.") + + elif self.type in ['icategorical']: + if 'random' in imputation_method: + if len(imputation_method) > 6: + seed = int(imputation_method[7:]) + random.seed(seed) + else: + random.seed(a=None) + + self.df_features[var_of_type] = self.df_features[var_of_type].apply(lambda x: x.fillna(random.choice(list(x.dropna(axis=0))))) + + if 'mode' in imputation_method: + self.df_features[var_of_type] = self.df_features[var_of_type].fillna(self.df_features[var_of_type].mode().max()) + else: + raise ValueError("Variable type must be 'continuous', 'hcategorical' or 'icategorical'.") + + def __call__(self, cleaning_dict: Dict, imputation_method: str = "mean", + missing_cutoff_ps: float = 0.25, missing_cutoff_pf: float = 0.1, + cov_cutoff:float = 0.1) -> pd.DataFrame: + """ + Applies data cleaning to the features of type. + + Args: + cleaning_dict (dict): Dictionary of cleaning parameters (missing cutoffs and coefficient of variation cutoffs etc.). + var_of_type (list, optional): List of variable names. + imputation_method (str): Method of imputation. Can be "mean", "median", "mode" or "random". + For "random" imputation, a seed can be provided by adding the seed value after the method + name, for example "random42". + missing_cutoff_ps (float, optional): maximal percentage cut-offs of missing features per sample. + missing_cutoff_pf (float, optional): maximal percentage cut-offs of missing samples per variable. + cov_cutoff (float, optional): minimal coefficient of variation cut-offs over samples per variable. + + Returns: + pd.DataFrame: Cleaned table of features. + """ + + # Initialization + var_of_type = self.df_features.Properties['userData']['variables']['continuous'] + + # Retrieve thresholds from cleaning_dict if not None + if cleaning_dict is not None: + missing_cutoff_pf = cleaning_dict['missingCutoffpf'] + missing_cutoff_ps = cleaning_dict['missingCutoffps'] + cov_cutoff = cleaning_dict['covCutoff'] + imputation_method = cleaning_dict['imputation'] + + # Replace infinite values with NaNs + self.df_features = self.df_features.replace([np.inf, -np.inf], np.nan) + + # Remove features with more than missing_cutoff_pf missing samples (NaNs) + var_of_type = self.cut_off_missing_per_feature(var_of_type, missing_cutoff_pf) + + # Check + if len(var_of_type) == 0: + return None + + # Remove features with a coefficient of variation less than cov_cutoff + var_of_type = self.cut_off_variation(var_of_type, cov_cutoff) + + # Check + if len(var_of_type) == 0: + return None + + # Remove scans with more than missing_cutoff_ps missing features + self.cut_off_missing_per_sample(var_of_type, missing_cutoff_ps) + + # Impute missing values + self.impute_missing(var_of_type, imputation_method) + + return self.df_features diff --git a/MEDiml/learning/DesignExperiment.py b/MEDiml/learning/DesignExperiment.py new file mode 100644 index 0000000..428b56a --- /dev/null +++ b/MEDiml/learning/DesignExperiment.py @@ -0,0 +1,480 @@ +import platform +import re +from itertools import combinations, product +from pathlib import Path +from typing import Dict, List + +import pandas as pd + +from ..utils.get_institutions_from_ids import get_institutions_from_ids +from ..utils.json_utils import load_json, posix_to_string, save_json +from .ml_utils import cross_validation_split, get_stratified_splits + + +class DesignExperiment: + def __init__(self, path_study: Path, path_settings: Path, experiment_label: str) -> None: + """ + Constructor of the class DesignExperiment. + + Args: + path_study (Path): Path to the main study folder where the outcomes, + learning patients and holdout patients dictionaries are found. + path_settings (Path): Path to the settings folder. + experiment_label (str): String specifying the label to attach to a given learning experiment in + "path_experiments". This label will be attached to the ml__$experiments_label$.json file as well + as the learn__$experiment_label$ folder. This label is used to keep track of different experiments + with different settings (e.g. radiomics, scans, machine learning algorithms, etc.). + + Returns: + None + """ + self.path_study = Path(path_study) + self.path_settings = Path(path_settings) + self.experiment_label = str(experiment_label) + self.path_ml_object = None + + def __create_folder_and_content( + self, + path_learn: Path, + run_name: str, + patients_train: List, + patients_test: List, + ml_path: Path + ) -> List: + """ + Creates json files needed for a given run + + Args: + path_learn (Path): path to the main learning folder containing information about the training and test set. + run_name (str): name for a given run. + patients_train (List): list of patients in the training set. + patients_test (List): list of patients in the test set. + ml_path (Path): path to the given run. + + Returns: + List: list of paths to the given run. + """ + paths_ml = dict() + path_run = path_learn / run_name + Path.mkdir(path_run, exist_ok=True) + path_train = path_run / 'patientsTrain.json' + path_test = path_run / 'patientsTest.json' + save_json(path_train, sorted(patients_train)) + save_json(path_test, sorted(patients_test)) + paths_ml['patientsTrain'] = path_train + paths_ml['patientsTest'] = path_test + paths_ml['outcomes'] = self.path_study / 'outcomes.csv' + paths_ml['ml'] = self.path_ml_object + paths_ml['results'] = path_run / 'run_results.json' + path_file = path_run / 'paths_ml.json' + paths_ml = posix_to_string(paths_ml) + ml_path.append(path_file) + save_json(path_file, paths_ml) + + return ml_path + + def generate_learner_dict(self) -> dict: + """ + Generates a dictionary containing all the settings for the learning experiment. + + Returns: + dict: Dictionary containing all the settings for the learning experiment. + """ + ml_options = dict() + + # operating system + ml_options['os'] = platform.system() + + # design experiment settings + ml_options['design'] = self.path_settings / 'ml_design.json' + # check if file exist: + if not ml_options['design'].exists(): + raise FileNotFoundError(f"File {ml_options['design']} does not exist.") + + # ML run settings + run = dict() + ml_options['run'] = run + + # Machine learning settings + ml_options['settings'] = self.path_settings / 'ml_settings.json' + # check if file exist: + if not ml_options['settings'].exists(): + raise FileNotFoundError(f"File {ml_options['settings']} does not exist.") + + # variables settings + ml_options['variables'] = self.path_settings / 'ml_variables.json' + # check if file exist: + if not ml_options['variables'].exists(): + raise FileNotFoundError(f"File {ml_options['variables']} does not exist.") + + # ML algorithms settings + ml_options['algorithms'] = self.path_settings / 'ml_algorithms.json' + # check if file exist: + if not ml_options['algorithms'].exists(): + raise FileNotFoundError(f"File {ml_options['algorithms']} does not exist.") + + # Data cleaning settings + ml_options['datacleaning'] = self.path_settings / 'ml_datacleaning.json' + # check if file exist: + if not ml_options['datacleaning'].exists(): + raise FileNotFoundError(f"File {ml_options['datacleaning']} does not exist.") + + # Normalization settings + ml_options['normalization'] = self.path_settings / 'ml_normalization.json' + # check if file exist: + if not ml_options['normalization'].exists(): + raise FileNotFoundError(f"File {ml_options['normalization']} does not exist.") + + # Feature set reduction settings + ml_options['fSetReduction'] = self.path_settings / 'ml_fset_reduction.json' + # check if file exist: + if not ml_options['fSetReduction'].exists(): + raise FileNotFoundError(f"File {ml_options['fSetReduction']} does not exist.") + + # Experiment label check + if self.experiment_label == "": + raise ValueError("Experiment label is empty. Class was not initialized properly.") + + # save all the ml options and return the path to the saved file + name_save_options = 'ml_options_' + self.experiment_label + '.json' + path_ml_options = self.path_settings / name_save_options + ml_options = posix_to_string(ml_options) + save_json(path_ml_options, ml_options) + + return path_ml_options + + def fill_learner_dict(self, path_ml_options: Path) -> Path: + """ + Fills the main expirement dictionary from the settings in the different json files. + This main dictionary will hold all the settings for the data processing and learning experiment. + + Args: + path_ml_options (Path): Path to the ml_options json file for the experiment. + + Returns: + Path: Path to the learner object. + """ + # Initialization + all_datacleaning = list() + all_normalization = list() + all_fset_reduction = list() + + # Load ml options dict + ml_options = load_json(path_ml_options) + options = ml_options.keys() + + # Design options + ml = dict() + ml['design'] = load_json(ml_options['design']) + + # ML run options + ml['run'] = ml_options['run'] + + # Machine learning options + if 'settings' in options: + ml['settings'] = load_json(ml_options['settings']) + + # Machine learning variables + if 'variables' in options: + ml['variables'] = dict() + var_options = load_json(ml_options['variables']) + fields = list(var_options.keys()) + vars = [(idx, s) for idx, s in enumerate(fields) if re.match(r"^var[0-9]{1,}$", s)] + var_names = [var[1] for var in vars] # list of var names + + # For each variable, organize the option in the ML dictionary + for (idx, var) in vars: + vars_dict = dict() + vars_dict[var] = var_options[var] + var_struct = var_options[var] + + # Radiomics variables + if 'radiomics' in var_struct['nameType'].lower(): + # Get radiomics features in workspace + if 'settofeatures' in var_struct['path'].lower(): + name_folder = re.match(r"setTo(.*)inWorkspace", var_struct['path']).group(1) + path_features = self.path_study / name_folder + # Get radiomics features in path provided in the dictionary by the user + else: + path_features = var_struct['path'] + scans = var_struct['scans'] # list of imaging sequences + rois = var_struct['rois'] # list of roi labels + im_spaces = var_struct['imSpaces'] # list of image spaces (filterd and original) + use_combinations = var_struct['use_combinations'] # boolean to use combinations of scans and im_spaces + if use_combinations: + all_combinations = [] + scans = list(var_struct['combinations'].keys()) + for scan in scans: + im_spaces = list(var_struct['combinations'][scan]) + all_combinations += list(product([scan], rois, im_spaces)) + else: + all_combinations = list(product(scans, rois, im_spaces)) + + # Initialize dict to hold all paths to radiomics features (csv and txt files) + path = dict() + for idx, (scan, roi, im_space) in enumerate(all_combinations): + rad_tab_x = {} + name_tab = 'radTab' + str(idx+1) + radiomics_table_name = 'radiomics__' + scan + '(' + roi + ')__' + im_space + rad_tab_x['csv'] = path_features / (radiomics_table_name + '.csv') + rad_tab_x['txt'] = path_features / (radiomics_table_name + '.txt') + rad_tab_x['type'] = path_features / (scan + '(' + roi + ')__' + im_space) + + # check if file exist + if not rad_tab_x['csv'].exists(): + raise FileNotFoundError(f"File {rad_tab_x['csv']} does not exist.") + if not rad_tab_x['txt'].exists(): + raise FileNotFoundError(f"File {rad_tab_x['txt']} does not exist.") + + path[name_tab] = rad_tab_x + + # Add path to ml dict for the current variable + vars_dict[var]['path'] = path + + # Add to ml dict for the current variable + ml['variables'].update(vars_dict) + + # Clinical or other variables (For ex: Volume) + else: + # get path to csv file of features + if not var_struct['path']: + if var_options['pathCSV'] == 'setToCSVinWorkspace': + path_csv = self.path_study / 'CSV' + else: + path_csv = var_options['pathCSV'] + var_struct['path'] = path_csv / var_struct['nameFile'] + + # Add to ml dict for the current variable + ml['variables'].update(vars_dict) + + # Initialize data processing methods + if 'var_datacleaning' in var_struct.keys(): + all_datacleaning.append(var_struct['var_datacleaning']) + if 'var_normalization' in var_struct.keys(): + all_normalization.append((var_struct['var_normalization'])) + if 'var_fSetReduction' in var_struct.keys(): + all_fset_reduction.append(var_struct['var_fSetReduction']['method']) + + # Combinations of variables + if 'combinations' in var_options.keys(): + if var_options['combinations'] == ['all']: # Combine all variables + combs = [comb for i in range(len(vars)) for comb in combinations(var_names, i+1)] + combstrings = ['_'.join(elt) for elt in combs] + ml['variables']['combinations'] = combstrings + else: + ml['variables']['combinations'] = var_options['combinations'] + + # Varibles to use for ML + ml['variables']['varStudy'] = var_options['varStudy'] + + # ML algorithms + if 'algorithms' in options: + algorithm = ml['settings']['algorithm'] + algorithms = load_json(ml_options['algorithms']) + ml['algorithms'] = {} + ml['algorithms'][algorithm] = algorithms[algorithm] + + # ML data processing methods and its options + for (method, method_list) in [ + ('datacleaning', all_datacleaning), + ('normalization', all_normalization), + ('fSetReduction', all_fset_reduction) + ]: + # Skip if no method is selected + if all(v == "" for v in method_list): + continue + if method in options: + # Add algorithm specific methods + if method in ml['settings'].keys(): + method_list.append(ml['settings'][method]) + method_list = list(set(method_list)) # to only get unique values of all_datacleaning + method_options = load_json(ml_options[method]) # load json file of each method + if method == 'normalization' and 'combat' in method_list: + ml[method] = 'combat' + continue + ml[method] = dict() + for name in list(set(method_list)): + if name != "": + ml[method][name] = method_options[name] + + # Save the ML dictionary + if self.experiment_label == "": + raise ValueError("Experiment label is empty. Class was not initialized properly.") + path_ml_object = self.path_study / f'ml__{self.experiment_label}.json' + ml = posix_to_string(ml) # Convert all paths to string + save_json(path_ml_object, ml) + + # return ml + return path_ml_object + + def create_experiment(self, ml: dict = None) -> Dict: + """ + Create the machine learning experiment dictionary, organizes each test/split information in a seperate folder. + + Args: + ml (dict, optional): Dictionary containing all the machine learning settings. Defaults to None. + + Returns: + Dict: Dictionary containing all the organized machine learning settings. + """ + # Initialization + ml_path = list() + ml = load_json(self.path_ml_object) if ml is None else ml + + # Learning set + patients_learn = load_json(self.path_study / 'patientsLearn.json') + + # Outcomes table + outcomes_table = pd.read_csv(self.path_study / 'outcomes.csv', index_col=0) + + # keep only patients in learn set and outcomes table + patients_to_keep = list(filter(lambda x: x in patients_learn, outcomes_table.index.values.tolist())) + outcomes_table = outcomes_table.loc[patients_to_keep] + + # Get the "experiment label" from ml__$experiment_label$.json + if self.experiment_label: + experiment_label = self.experiment_label + else: + experiment_label = Path(self.path_ml_object).name[4:-5] + + # Create the folder for the training and testing sets (machine learning) information + name_learn = 'learn__' + experiment_label + path_learn = Path(self.path_study) / name_learn + Path.mkdir(path_learn, exist_ok=True) + + # Getting the type of test_sets + test_sets_types = ml['design']['testSets'] + + # Creating the sets for the different machine learning runs + for type_set in test_sets_types: + # Random splits + if type_set.lower() == 'random': + # Get the experiment options for the sets + random_info = ml['design'][type_set] + method = random_info['method'] + n_splits = random_info['nSplits'] + stratify_institutions = random_info['stratifyInstitutions'] + test_proportion = random_info['testProportion'] + seed = random_info['seed'] + if method == 'SubSampling': + # Get the training and testing sets + patients_train, patients_test = get_stratified_splits( + outcomes_table, n_splits, + test_proportion, seed, + stratify_institutions + ) + + # If patients are not in a list + if type(patients_train) != list and not hasattr((patients_train), "__len__"): + patients_train = [patients_train] + patients_test = [patients_test] + + for i in range(n_splits): + # Create a folder for each split/run + run_name = "test__{0:03}".format(i+1) + ml_path = self.__create_folder_and_content( + path_learn, + run_name, + patients_train[i], + patients_test[i], + ml_path + ) + # Institutions-based splits + elif type_set.lower() == 'institutions': + # Get institutions run info + patient_ids = pd.Series(outcomes_table.index) + institution_cat_vector = get_institutions_from_ids(patient_ids) + institution_cats = list(set(institution_cat_vector)) + n_institution = len(institution_cats) + # The 'Institutions' argument only make sense if n_institutions > 1 + if n_institution > 1: + for i in range(n_institution): + cat = institution_cats[i] + patients_train = [elt for elt in patient_ids if cat not in elt] + patients_test = [elt for elt in patient_ids if cat in elt] + run_name = f"test__{cat}" + # Create a folder for each split/run + ml_path = self.__create_folder_and_content( + path_learn, + run_name, + patients_train, + patients_test, + ml_path + ) + if n_institution > 2: + size_inst = list() + for i in range(n_institution): + cat = institution_cats[i] + size_inst.append(sum([1 if cat in elt else 0 for elt in institution_cat_vector])) + ind_max = size_inst.index(max(size_inst)) + str_test = list() + for i in range(n_institution): + if i != ind_max: + cat = institution_cats[i] + str_test.append(cat) + cat = institution_cats[ind_max] + patients_train = [elt for elt in patient_ids if cat in elt] + patients_test = [elt for elt in patient_ids if cat not in elt] + run_name = f"test__{'_'.join(str_test)}" + # Create a folder for each split/run + ml_path = self.__create_folder_and_content( + path_learn, + run_name, + patients_train, + patients_test, + ml_path + ) + elif type_set.lower() == 'cv': + # Get the experiment options for the sets + cv_info = ml['design'][type_set] + n_splits = cv_info['nSplits'] + seed = cv_info['seed'] + + # Get the training and testing sets + patients_train, patients_test = cross_validation_split( + outcomes_table, + n_splits, + seed=seed + ) + + # If patients are not in a list + if type(patients_train) != list and not hasattr((patients_train), "__len__"): + patients_train = [patients_train] + patients_test = [patients_test] + + for i in range(n_splits): + # Create a folder for each split/run + run_name = "test__{0:03}".format(i+1) + ml_path = self.__create_folder_and_content( + path_learn, + run_name, + patients_train[i], + patients_test[i], + ml_path + ) + else: + raise ValueError("The type of test set is not recognized. Must be 'random' or 'institutions'.") + + # Make ml_path a dictionary to easily save it in json + return {f"run{idx+1}": value for idx, value in enumerate(ml_path)} + + def generate_experiment(self): + """ + Generate the json files containing all the options the experiment. + The json files will then be used in machine learning. + """ + # Generate the ml options dictionary + path_ml_options = self.generate_learner_dict() + + # Fill the ml options dictionary + self.path_ml_object = self.fill_learner_dict(path_ml_options) + + # Generate the experiment dictionary + experiment_dict = self.create_experiment() + + # Saving the final experiment dictionary + path_file = self.path_study / f'path_file_ml_paths__{self.experiment_label}.json' + experiment_dict = posix_to_string(experiment_dict) # Convert all paths to string + save_json(path_file, experiment_dict) + + return path_file diff --git a/MEDiml/learning/FSR.py b/MEDiml/learning/FSR.py new file mode 100644 index 0000000..a3631d8 --- /dev/null +++ b/MEDiml/learning/FSR.py @@ -0,0 +1,667 @@ +from pathlib import Path +from typing import Dict, List, Tuple + +import numpy as np +import pandas as pd +from numpyencoder import NumpyEncoder + +from MEDiml.learning.ml_utils import (combine_rad_tables, finalize_rad_table, + get_stratified_splits, + intersect_var_tables) +from MEDiml.utils.get_full_rad_names import get_full_rad_names +from MEDiml.utils.json_utils import save_json + + +class FSR: + def __init__(self, method: str = 'fda') -> None: + """ + Feature set reduction class constructor. + + Args: + method (str): Method of feature set reduction. Can be "FDA", "LASSO" or "mRMR". + """ + self.method = method + + def __get_fda_corr_table( + self, + variable_table: pd.DataFrame, + outcome_table_binary: pd.DataFrame, + n_splits: int, + corr_type: str, + seed: int + ) -> pd.DataFrame: + """ + Calculates the correlation table of the FDA algorithm. + + Args: + variable_table (pd.DataFrame): variable table to check for stability. + outcome_table_binary (pd.DataFrame): outcome table with binary labels. + n_splits (int): Number of splits in the FDA algorithm (Ex: 100). + corr_type: String specifying the correlation type that we are investigating. + Must be either 'Pearson' or 'Spearman'. + seed (int): Random generator seed. + + Returns: + pd.DataFrame: Correlation table of the FDA algorithm. Rows are splits, columns are features. + """ + # Setting the seed + np.random.seed(seed) + + # Initialization + row_names = [] + corr_table = pd.DataFrame() + fraction_for_splits = 1/3 + number_of_splits = 1 + + # For each split, we calculate the correlation table + for s in range(n_splits): + row_names.append("Split_{0:03}".format(s)) + + # Keep only variables that are in both tables + _, outcome_table_binary = intersect_var_tables(variable_table, outcome_table_binary) + + # Under-sample the outcome table to equalize the number of positive and negative outcomes + #outcome_table_binary_balanced = under_sample(outcome_table_binary) + + # Get the patient teach split + patients_teach_splits = get_stratified_splits( + outcome_table_binary, + number_of_splits, + fraction_for_splits, + seed, + flag_by_cat=True + )[0] + + # Creating a table with both the variables and the outcome with + # only the patient teach splits, ranked for spearman and not for pearson + if corr_type == 'Spearman': + full_table = pd.concat([variable_table.loc[patients_teach_splits, :].rank(), + outcome_table_binary.loc[patients_teach_splits, + outcome_table_binary.columns.values[-1]]], axis=1) + + elif corr_type == 'Pearson': + # Pearson is the base method used by numpy, so we dont have to do any + # manipulations to the data like with spearman. + full_table = pd.concat([variable_table.loc[patients_teach_splits, :], + outcome_table_binary.loc[patients_teach_splits, + outcome_table_binary.columns.values[-1]]], axis=1) + else: + raise ValueError("Correlation type not recognized. Please use 'Pearson' or 'Spearman'") + + # calculate the whole correlation table for all variables. + full_table = np.corrcoef(full_table, rowvar=False)[-1][:-1].reshape((1, -1)) + corr_table = corr_table.append(pd.DataFrame(full_table)) + + # Add the metadata to the correlation table + corr_table.columns = list(variable_table.columns.values) + corr_table = corr_table.fillna(0) + corr_table.index = row_names + corr_table.Properties = {} + corr_table._metadata += ['Properties'] + corr_table.Properties['description'] = variable_table.Properties['Description'] + corr_table.Properties['userData'] = variable_table.Properties['userData'] + + return corr_table + + def __find_fda_best_mean(self, corr_tables: pd.DataFrame, min_n_feat_stable: int) -> Tuple[Dict, pd.DataFrame]: + """ + Finds the best mean correlation of all the stable variables in the table. + + Args: + corr_tables (Dict): dictionary containing the correlation tables of + dimension : [n_splits,n_features] for each table. + min_n_feat_stable (int): minimal number of stable features. + + Returns: + Tuple[Dict, pd.DataFrame]: Dict containing the name of each stable variables in every table and + pd.DataFrame containing the mean correlation of all the stable variables in the table. + """ + # Initialization + var_names_stable = {} + corr_mean_stable = corr_tables + n_features = 0 + corr_table = corr_tables + corr_table = corr_table.fillna(0) + + # Calculation of the mean correlation among the n splits (R mean) + var_names_stable = corr_table.index + + # Calculating the total number of features + n_features += var_names_stable.size + + # Getting absolute values of the mean correlation + corr_mean_stable_abs = corr_mean_stable.abs() + + # Keeping only the best features if there are more than min_n_feat_stable features + if n_features > min_n_feat_stable: + # Get min_n_feat_stable highest correlations + best_features = corr_mean_stable_abs.sort_values(ascending=False)[0:min_n_feat_stable] + var_names_stable = best_features.index.values + corr_mean_stable = best_features + + return var_names_stable, corr_mean_stable + + def __find_fda_stable(self, corr_table: pd.DataFrame, thresh_stable: float) -> Tuple[Dict, pd.DataFrame]: + """ + Finds the stable features in each correlation table + and the mean correlation of all the stable variables in the table. + + Args: + corr_tables (Dict): dictionary containing the correlation tables of + dimension : [n_splits,n_features] for each table. + thresh_stable (float): the threshold deciding if a feature is stable. + + Returns: + Tuple[Dict, pd.DataFrame]: dictionary containing the name of each stable variables in every tables + and table containing the mean correlation of all the stable variables in the table. + (The keys are the table names and the values are pd.Series). + """ + + # Initialization + corr_table.fillna(0, inplace=True) + + # Calculation of R mean + corr_mean_stable = corr_table.mean() + mean_r = corr_mean_stable + + # Calculation of min and max + min_r = corr_table.quantile(0.05) + max_r = corr_table.quantile(0.95) + + # Calculation of unstable features + unstable = (min_r < thresh_stable) & (mean_r > 0) | (max_r > -thresh_stable) & (mean_r < 0) + ind_unstable = unstable.index[unstable] + + # Stable variables + var_names_stable = unstable.index[~unstable].values + corr_mean_stable = mean_r.drop(ind_unstable) + + return var_names_stable, corr_mean_stable + + def __keep_best_text_param( + self, + corr_table: pd.DataFrame, + var_names_stable: List, + corr_mean_stable: pd.DataFrame + ) -> Tuple[List, pd.DataFrame]: + """ + Keeps the best texture features extraction parameters in the correlation tables + by dropping the variants of a given feature. + + Args: + corr_table (pd.DataFrame): Correlation table of dimension : [n_splits,n_features]. + var_names_stable (List): List of the stable variables in the table. + corr_mean_stable (pd.DataFrame): Table of the mean correlation of the stable variables in the variables table. + + Returns: + Tuple[List, pd.DataFrame]: list of the stable variables in the tables and table containing the mean + correlation of all the stable variables. + """ + + # If no stable features for the currect field, continue + if var_names_stable.size == 0: + return var_names_stable, corr_mean_stable + + # Get the actual radiomics features names from the sequential names + full_rad_names = get_full_rad_names( + corr_table.Properties['userData']['variables']['var_def'], + var_names_stable) + + # Now parsing the full names to get only the rad names and not the variant + rad_names = np.array([]) + for n in range(full_rad_names.size): + rad_names = np.append(rad_names, full_rad_names[n].split('__')[1:2]) + + # Verifying if two features are the same variant and keeping the best one + n_var = rad_names.size + var_to_drop = [] + for rad_name in rad_names: + # If all the features are unique, break + if np.unique(rad_names).size == n_var: + break + else: + ind_same = np.where(rad_names == rad_name)[0] + n_same = ind_same.size + if n_same > 1: + var_to_drop.append(list(corr_mean_stable.iloc[ind_same].sort_values().index[1:].values)) + + # Dropping the variants + if len(var_to_drop) > 0: + # convert to list of lists to list + var_to_drop = [item for sublist in var_to_drop for item in sublist] + + # From the unique values of var_to_drop, drop the variants + for var in set(var_to_drop): + var_names_stable = np.delete(var_names_stable, np.where(var_names_stable == var)) + corr_mean_stable = corr_mean_stable.drop(var) + + return var_names_stable, corr_mean_stable + + def __remove_correlated_variables( + self, + variable_table: pd.DataFrame, + rank: pd.Series, + corr_type: str, + thresh_inter_corr: float, + min_n_feat_total: int + ) -> pd.DataFrame: + """ + Removes inter-correlated variables given a certain threshold. + + Args: + variable_table (pd.DataFrame): variable table for which we want to remove intercorrelated variables. + Size: N X M (observations, features). + rank (pd.Series): Vector of correlation values per feature (of size 1 X M). + corr_type (str): String specifying the correlation type that we are investigating. + Must be 'Pearson' or 'Spearman'. + thresh_inter_corr (float): Numerical value specifying the threshold above which two variables are + considered to be correlated. + min_n_feat_total (int): Minimum number of features to keep in the table. + + Returns: + pd.DataFrame: Final variable table with the least correlated variables that are kept. + """ + # Initialization + n_features = variable_table.shape[1] + + # Compute correlation matrix + if corr_type == 'Spearman': + corr_mat = abs(np.corrcoef(variable_table.rank(), rowvar=False)) + elif corr_type == 'Pearson': + corr_mat = abs(np.corrcoef(variable_table, rowvar=False)) + else: + raise ValueError('corr_type must be either "Pearson" or "Spearman"') + + # Set diagonal elements to Nans + np.fill_diagonal(corr_mat, val=np.nan) + + # Calculate mean inter-variable correlation + mean_corr = np.nanmean(corr_mat, axis=1) + + # Looping over all features once + # rank variables once, for meaningful variable loop. + ind_loop = pd.Series(mean_corr).rank(method="first") - 1 + # Create a copy of the correlation matrix (to be modified) + corr_mat_temp = corr_mat.copy() + while True: + for f in range(n_features): + # Use index loop if not NaN + try: + i = int(ind_loop[f]) + except: + i = 0 + # Select the row of the current feature + row = corr_mat_temp[i][:] + correlated = 1*(row > thresh_inter_corr) # to turn into integers + + # While the correlations are above the threshold for the select row, we select another row + while sum(correlated) > 0 and np.isnan(row).sum != len(row): + # Find the variable with the highest correlation and drop the one with the lowest rank + ind_max = np.nanargmax(row) + ind_min = np.nanargmin(np.array([rank[i], rank[ind_max]])) + if ind_min == 0: + # Drop the current row if the current feature has the lowest correlation with outcome + corr_mat_temp[i][:] = np.nan + corr_mat_temp[:][i] = np.nan + row[:] = np.nan + else: + # Drop the feature with the highest correlation to the current feature with the lowest correlation with outcome + corr_mat_temp[ind_max][:] = np.nan + corr_mat_temp[:][ind_max] = np.nan + row[ind_max] = np.nan + + # Update the correlated vector + correlated = row > thresh_inter_corr + + # If all the rows are NaN, we keep the variable with the highest rank + if (1*np.isnan(corr_mat_temp)).sum() == corr_mat_temp.size: + ind_keep = np.nanargmax(rank) + else: + ind_keep = list() + for row in range(corr_mat_temp.shape[0]): + if 1*np.isnan(corr_mat_temp[row][:]).sum() < corr_mat_temp.shape[1]: + ind_keep.append(row) + + # if ind_keep happens to be a numpy type convert it to list for better subscripting + if isinstance(ind_keep, np.int64): + ind_keep = [ind_keep.tolist()] # work around + elif isinstance(ind_keep, np.ndarray): + ind_keep = ind_keep.tolist() + + # Update threshold if the number of variables is too small or too large + if len(ind_keep) < min_n_feat_total: + # Increase the threshold (less stringent) + thresh_inter_corr = thresh_inter_corr + 0.05 + corr_mat_temp = corr_mat.copy() # reset the correlation matrix + else: + break + + # Make sure we have the best + if len(ind_keep) != min_n_feat_total: + # Take the features with the highest rank + ind_keep = sorted(ind_keep)[:min_n_feat_total] + + # Creating new variable_table + columns = [variable_table.columns[idx] for idx in ind_keep] + variable_table = variable_table.loc[:, columns] + + return variable_table + + def apply_fda_one_space( + self, + ml: Dict, + variable_table: List, + outcome_table_binary: pd.DataFrame, + del_variants: bool = True, + logging_dict: Dict = None + ) -> List: + """ + Applies false discovery avoidance method. + + Args: + ml (dict): Machine learning dictionary containing the learning options. + variable_table (List): Table of variables. + outcome_table_binary (pd.DataFrame): Table of binary outcomes. + del_variants (bool, optional): If True, will delete the variants of the same feature. Defaults to True. + + Returns: + List: Table of variables after feature set reduction. + """ + # Initilization + n_splits = ml['fSetReduction']['FDA']['nSplits'] + corr_type = ml['fSetReduction']['FDA']['corrType'] + thresh_stable_start = ml['fSetReduction']['FDA']['threshStableStart'] + thresh_inter_corr = ml['fSetReduction']['FDA']['threshInterCorr'] + min_n_feat_stable = ml['fSetReduction']['FDA']['minNfeatStable'] + min_n_feat_total = ml['fSetReduction']['FDA']['minNfeat'] + seed = ml['fSetReduction']['FDA']['seed'] + + # Initialization - logging + if logging_dict is not None: + table_level = variable_table.Properties['Description'].split('__')[-1] + logging_dict['one_space']['unstable'][table_level] = {} + logging_dict['one_space']['inter_corr'][table_level] = {} + + # Getting the correlation table for the radiomics table + radiomics_table_temp = variable_table.copy() + outcome_table_binary_temp = outcome_table_binary.copy() + + # Get the correlation table + corr_table = self.__get_fda_corr_table( + radiomics_table_temp, + outcome_table_binary_temp, + n_splits, + corr_type, + seed + ) + + # Calculating the total numbers of features + feature_total = radiomics_table_temp.shape[1] + + # Cut unstable features (Rmin cut) + if feature_total > min_n_feat_stable: + # starting threshold (set by user) + thresh_stable = thresh_stable_start + while True: + # find which features are stable + var_names_stable, corrs_stable = self.__find_fda_stable(corr_table, thresh_stable) + + # Keep the best textural parameters per image space (deleting variants) + if del_variants: + var_names_stable, corrs_stable = self.__keep_best_text_param(corr_table, var_names_stable, corrs_stable) + + # count the number of stable features + n_stable = var_names_stable.size + + # stop if the minimum number of stable features is reached, if not, lower the threshold. + if n_stable >= min_n_feat_stable: + break + else: + thresh_stable = thresh_stable - 0.05 + + # stop if the threshold is zero or below + if thresh_stable <= 0: + break + + # take the best mean correlation + if n_stable > min_n_feat_stable: + var_names_stable, corr_mean_stable = self.__find_fda_best_mean(corrs_stable, min_n_feat_stable) + else: + # Compute mean correlation + corr_mean_stable = corr_table.mean() + + # Finalize radiomics tables before inter-correlation cut + if len(var_names_stable) > 0: + var_names = var_names_stable + if isinstance(radiomics_table_temp, pd.Series): + radiomics_table_temp = radiomics_table_temp[[var_names]] + else: + radiomics_table_temp = radiomics_table_temp[var_names] + radiomics_table_temp = finalize_rad_table(radiomics_table_temp) + else: + radiomics_table_temp = pd.DataFrame() + else: + # if there is less features than the minimal number, take them all + n_stable = feature_total + + # Compute mean correlation + corr_mean_stable = corr_table.mean() + + # Update logging + if logging_dict is not None: + logging_dict['one_space']['unstable'][table_level] = radiomics_table_temp.columns.shape[0] + + # Inter-Correlation Cut + if radiomics_table_temp.shape[1] > 1 and n_stable > min_n_feat_total: + radiomics_table_temp = self.__remove_correlated_variables( + radiomics_table_temp, + corr_mean_stable.abs(), + corr_type, + thresh_inter_corr, + min_n_feat_total + ) + + # Finalize radiomics table + radiomics_table_temp = finalize_rad_table(radiomics_table_temp) + + # Update logging + if logging_dict is not None: + logging_dict['one_space']['inter_corr'][table_level] = get_full_rad_names( + radiomics_table_temp.Properties['userData']['variables']['var_def'], + radiomics_table_temp.columns.values + ).tolist() + + return radiomics_table_temp + + def apply_fda( + self, + ml: Dict, + variable_table: List, + outcome_table_binary: pd.DataFrame, + logging: bool = True, + path_save_logging: Path = None + ) -> List: + """ + Applies false discovery avoidance method. + + Args: + ml (dict): Machine learning dictionary containing the learning options. + variable_table (List): Table of variables. + outcome_table_binary (pd.DataFrame): Table of binary outcomes. + logging (bool, optional): If True, will save a dict that tracks features selsected for each level. Defaults to True. + path_save_logging (Path, optional): Path to save the logging dict. Defaults to None. + + Returns: + List: Table of variables after feature set reduction. + """ + # Initialization + rad_tables = variable_table.copy() + n_rad_tables = len(rad_tables) + variable_tables = [] + logging_dict = {'one_space': {'unstable': {}, 'inter_corr': {}}, 'final': {}} + + # Apply FDA for each image space/radiomics table + for r in range(n_rad_tables): + if logging: + variable_tables.append(self.apply_fda_one_space(ml, rad_tables[r], outcome_table_binary, logging_dict=logging_dict)) + else: + variable_tables.append(self.apply_fda_one_space(ml, rad_tables[r], outcome_table_binary)) + + # Combine radiomics tables + variable_table = combine_rad_tables(variable_tables) + + # Apply FDA again on the combined radiomics table + variable_table = self.apply_fda_one_space(ml, variable_table, outcome_table_binary, del_variants=False) + + # Update logging dict + if logging: + logging_dict['final'] = get_full_rad_names(variable_table.Properties['userData']['variables']['var_def'], + variable_table.columns.values).tolist() + if path_save_logging is not None: + path_save_logging = Path(path_save_logging).parent / 'fda_logging_dict.json' + save_json(path_save_logging, logging_dict, cls=NumpyEncoder) + + return variable_table + + def apply_fda_balanced( + self, + ml: Dict, + variable_table: List, + outcome_table_binary: pd.DataFrame, + ) -> List: + """ + Applies false discovery avoidance method but balances the number of features on each level. + + Args: + ml (dict): Machine learning dictionary containing the learning options. + variable_table (List): Table of variables. + outcome_table_binary (pd.DataFrame): Table of binary outcomes. + logging (bool, optional): If True, will save a dict that tracks features selsected for each level. Defaults to True. + path_save_logging (Path, optional): Path to save the logging dict. Defaults to None. + + Returns: + List: Table of variables after feature set reduction. + """ + # Initilization + rad_tables = variable_table.copy() + n_rad_tables = len(rad_tables) + variable_tables_all_levels = [] + levels = [[], [], []] + + # Organize the tables by level + for r in range(n_rad_tables): + if 'morph' in rad_tables[r].Properties['Description'].lower(): + levels[0].append(rad_tables[r]) + elif 'intensity' in rad_tables[r].Properties['Description'].lower(): + levels[0].append(rad_tables[r]) + elif 'texture' in rad_tables[r].Properties['Description'].lower(): + levels[0].append(rad_tables[r]) + elif 'mean' in rad_tables[r].Properties['Description'].lower() or \ + 'laws' in rad_tables[r].Properties['Description'].lower() or \ + 'log' in rad_tables[r].Properties['Description'].lower() or \ + 'gabor' in rad_tables[r].Properties['Description'].lower() or \ + 'coif' in rad_tables[r].Properties['Description'].lower() or \ + 'wavelet' in rad_tables[r].Properties['Description'].lower(): + levels[1].append(rad_tables[r]) + elif 'glcm' in rad_tables[r].Properties['Description'].lower(): + levels[2].append(rad_tables[r]) + + # Apply FDA for each image space/radiomics table for each level + for level in levels: + variable_tables = [] + if len(level) == 0: + continue + for r in range(len(level)): + variable_tables.append(self.apply_fda_one_space(ml, level[r], outcome_table_binary)) + + # Combine radiomics tables + variable_table = combine_rad_tables(variable_tables) + + # Apply FDA again on the combined radiomics table + variable_table = self.apply_fda_one_space(ml, variable_table, outcome_table_binary, del_variants=False) + + # Add-up the tables + variable_tables_all_levels.append(variable_table) + + # Combine radiomics tables of all 3 major levels (original, linear filters and textures) + variable_table_all_levels = combine_rad_tables(variable_tables_all_levels) + + # Apply FDA again on the combined radiomics table + variable_table_all_levels = self.apply_fda_one_space(ml, variable_table_all_levels, outcome_table_binary, del_variants=False) + + return variable_table_all_levels + + def apply_random_fsr_one_space( + self, + ml: Dict, + variable_table: pd.DataFrame, + ) -> List: + seed = ml['fSetReduction']['FDA']['seed'] + + # Setting the seed + np.random.seed(seed) + + # Random select 10 columns (features) + random_df = np.random.choice(variable_table.columns.values.tolist(), 10, replace=False) + random_df = variable_table[random_df] + + return finalize_rad_table(random_df) + + def apply_random_fsr( + self, + ml: Dict, + variable_table: List, + ) -> List: + """ + Applies random feature set reduction by choosing a random number of features. + + Args: + ml (dict): Machine learning dictionary containing the learning options. + variable_table (List): Table of variables. + outcome_table_binary (pd.DataFrame): Table of binary outcomes. + + Returns: + List: Table of variables after feature set reduction. + """ + # Iinitilization + rad_tables = variable_table.copy() + n_rad_tables = len(rad_tables) + variable_tables = [] + + # Apply FDA for each image space/radiomics table + for r in range(n_rad_tables): + variable_tables.append(self.apply_random_fsr_one_space(ml, rad_tables[r])) + + # Combine radiomics tables + variable_table = combine_rad_tables(variable_tables) + + # Apply FDA again on the combined radiomics table + variable_table = self.apply_random_fsr_one_space(ml, variable_table) + + return variable_table + + def apply_fsr(self, ml: Dict, variable_table: List, outcome_table_binary: pd.DataFrame, path_save_logging: Path = None) -> List: + """ + Applies feature set reduction method. + + Args: + ml (dict): Machine learning dictionary containing the learning options. + variable_table (List): Table of variables. + outcome_table_binary (pd.DataFrame): Table of binary outcomes. + + Returns: + List: Table of variables after feature set reduction. + """ + if self.method.lower() == "fda": + variable_table = self.apply_fda(ml, variable_table, outcome_table_binary, path_save_logging=path_save_logging) + elif self.method.lower() == "fdabalanced": + variable_table = self.apply_fda_balanced(ml, variable_table, outcome_table_binary) + elif self.method.lower() == "random": + variable_table = self.apply_random_fsr(ml, variable_table) + elif self.method == "LASSO": + raise NotImplementedError("LASSO not implemented yet.") + elif self.method == "mRMR": + raise NotImplementedError("mRMR not implemented yet.") + else: + raise ValueError("FSR method is None or unknown: " + self.method) + return variable_table diff --git a/MEDiml/learning/Normalization.py b/MEDiml/learning/Normalization.py new file mode 100644 index 0000000..ea1dfc2 --- /dev/null +++ b/MEDiml/learning/Normalization.py @@ -0,0 +1,112 @@ +import numpy as np +import pandas as pd +from neuroCombat import neuroCombat + +from ..utils.get_institutions_from_ids import get_institutions_from_ids + + +class Normalization: + def __init__( + self, + method: str = 'combat', + variable_table: pd.DataFrame = None, + covariates_df: pd.DataFrame = None, + institutions: list = None + ) -> None: + """ + Constructor of the Normalization class. + """ + self.method = method + self.variable_table = variable_table + self.covariates_df = covariates_df + self.institutions = institutions + + def apply_combat( + self, + variable_table: pd.DataFrame, + covariate_df: pd.DataFrame = None, + institutions: list = None + ) -> pd.DataFrame: + """ + Applys ComBat Normalization method to the data. + More details :ref:`this link `. + + Args: + variable_table (pd.DataFrame): pandas data frame on which Combat harmonization will be applied. + This table is of size N X F (Observations X Features) and has the IDs as index. + Requirements for this table + + - Does not contain NaNs. + - No feature has 0 variance. + - All variables are continuous (For example: Radiomics variables). + covariate_df (pd.DataFrame, optional): N X M pandas data frame, where N must equal the number of + observations in variable_table. M is the number of covariates to include in the algorithm. + institutions (list, optional): List of size n_observations X 1 with the different institutions. + + Returns: + pd.DataFrame: variable_table after Combat harmonization. + """ + # Initializing the class attributes from the arguments + if variable_table is None: + if self.variable_table is None: + raise ValueError('variable_table must be given.') + else: + self.variable_table = variable_table + if covariate_df is not None: + self.covariates_df = covariate_df + if institutions: + self.institutions = institutions + + # Intializing the institutions if not given + if self.institutions is None: + patient_ids = pd.Series(self.variable_table.index) + self.institutions = get_institutions_from_ids(patient_ids) + all_institutions = self.institutions.unique() + for n in range(all_institutions.size): + self.institutions[self.institutions == all_institutions[n]] = n+1 + self.institutions = self.institutions.to_numpy(dtype=int) + self.institutions = np.reshape(self.institutions, (-1, 1)) + + # No harmonization will be applied if there is only one institution + if np.unique(self.institutions).size < 2: + return self.variable_table + + # Initializing the covariates if not given + if self.covariates_df is not None: + self.covariates_df['institution'] = self.institutions + else: + # the covars matrix is only a row with the institution + self.covariates_df = pd.DataFrame( + self.institutions, + columns=['institution'], + index=self.variable_table.index.values + ) + + # Apply combat + n_features = self.variable_table.shape[1] + batch_col = 'institution' + if n_features == 1: + # combat does not work with a single feature so a temporary one is added, + # then removed later (this has no effect on the algorithm). + self.variable_table['temp'] = pd.Series( + np.ones(self.variable_table.shape[0]), + index=self.variable_table.index + ) + data_combat = neuroCombat( + self.variable_table.transpose(), + self.covariates_df, + batch_col + ) + self.variable_table = pd.DataFrame(self.variable_table.drop('temp', axis=1)) + vt_combat = pd.DataFrame(data_combat[:][0].transpose()) + else: + data_combat = neuroCombat( + self.variable_table.transpose(), + self.covariates_df, + batch_col + ) + vt_combat = pd.DataFrame(data_combat['data']).transpose() + + self.variable_table[:] = vt_combat.values + + return self.variable_table diff --git a/MEDiml/learning/RadiomicsLearner.py b/MEDiml/learning/RadiomicsLearner.py new file mode 100644 index 0000000..a731e57 --- /dev/null +++ b/MEDiml/learning/RadiomicsLearner.py @@ -0,0 +1,714 @@ +import logging +import os +import time +from copy import deepcopy +from pathlib import Path +from typing import Dict, List, Tuple + +import numpy as np +import pandas as pd +from numpyencoder import NumpyEncoder +from pycaret.classification import * +from sklearn import metrics +from sklearn.model_selection import GridSearchCV, RandomizedSearchCV +from xgboost import XGBClassifier + +from MEDiml.learning.DataCleaner import DataCleaner +from MEDiml.learning.DesignExperiment import DesignExperiment +from MEDiml.learning.FSR import FSR +from MEDiml.learning.ml_utils import (average_results, combine_rad_tables, + feature_imporance_analysis, + finalize_rad_table, get_ml_test_table, + get_radiomics_table, intersect, + intersect_var_tables, save_model) +from MEDiml.learning.Normalization import Normalization +from MEDiml.learning.Results import Results + +from ..utils.json_utils import load_json, save_json + + +class RadiomicsLearner: + def __init__(self, path_study: Path, path_settings: Path, experiment_label: str) -> None: + """ + Constructor of the class DesignExperiment. + + Args: + path_study (Path): Path to the main study folder where the outcomes, + learning patients and holdout patients dictionaries are found. + path_settings (Path): Path to the settings folder. + experiment_label (str): String specifying the label to attach to a given learning experiment in + "path_experiments". This label will be attached to the ml__$experiments_label$.json file as well + as the learn__$experiment_label$ folder. This label is used to keep track of different experiments + with different settings (e.g. radiomics, scans, machine learning algorithms, etc.). + + Returns: + None + """ + self.path_study = Path(path_study) + self.path_settings = Path(path_settings) + self.experiment_label = experiment_label + + def __load_ml_info(self, ml_dict_paths: Dict) -> Dict: + """ + Initializes the test dictionary information (training patients, test patients, ML dict, etc). + + Args: + ml_dict_paths (Dict): Dictionary containing the paths to the different files needed + to run the machine learning experiment. + + Returns: + dict: Dictionary containing the information of the machine learning test. + """ + ml_dict = dict() + + # Training and test patients + ml_dict['patientsTrain'] = load_json(ml_dict_paths['patientsTrain']) + ml_dict['patientsTest'] = load_json(ml_dict_paths['patientsTest']) + + # Outcome table for training and test patients + outcome_table = pd.read_csv(ml_dict_paths['outcomes'], index_col=0) + ml_dict['outcome_table_binary'] = outcome_table.iloc[:, [0]] + if outcome_table.shape[1] == 2: + ml_dict['outcome_table_time'] = outcome_table.iloc[:, [1]] + + # Machine learning dictionary + ml_dict['ml'] = load_json(ml_dict_paths['ml']) + ml_dict['path_results'] = ml_dict_paths['results'] + + return ml_dict + + def __find_balanced_threshold( + self, + model: XGBClassifier, + variable_table: pd.DataFrame, + outcome_table_binary: pd.DataFrame + ) -> float: + """ + Finds the balanced threshold for the given machine learning test. + + Args: + model (XGBClassifier): Trained XGBoost classifier for the given machine learning run. + variable_table (pd.DataFrame): Radiomics table. + outcome_table_binary (pd.DataFrame): Outcome table with binary labels. + + Returns: + float: Balanced threshold for the given machine learning test. + """ + # Check is there is a feature mismatch + if model.feature_names_in_.shape[0] != variable_table.columns.shape[0]: + variable_table = variable_table.loc[:, model.feature_names_in_] + + # Getting the probability responses for each patient + prob_xgb = np.zeros((variable_table.index.shape[0], 1)) * np.nan + patient_ids = list(variable_table.index.values) + for p in range(variable_table.index.shape[0]): + prob_xgb[p] = self.predict_xgb(model, variable_table.loc[[patient_ids[p]], :]) + + # Calculating the ROC curve + fpr, tpr, thresholds = metrics.roc_curve(outcome_table_binary.iloc[:, 0], prob_xgb) + + # Calculating the optimal threshold by minizing fpr (false positive rate) and maximizing tpr (true positive rate) + minimum = np.argmin(np.power(fpr, 2) + np.power(1-tpr, 2)) + + return thresholds[minimum] + + def get_hold_out_set_table(self, ml: Dict, var_id: str, patients_id: List): + """ + Loads and pre-processes different radiomics tables then combines them to be used for hold-out testing. + + Args: + ml (Dict): The machine learning dictionary containing the information of the machine learning test. + var_id (str): String specifying the ID of the radiomics variable in ml. + --> Ex: var1 + patients_id (List): List of patients of the hold-out set. + + Returns: + pd.DataFrame: Radiomics table for the hold-out set. + """ + # Loading and pre-processing + rad_var_struct = ml['variables'][var_id] + rad_tables_holdout = list() + for item in rad_var_struct['path'].values(): + # Reading the table + path_radiomics_csv = item['csv'] + path_radiomics_txt = item['txt'] + image_type = item['type'] + rad_table_holdout = get_radiomics_table(path_radiomics_csv, path_radiomics_txt, image_type, patients_id) + rad_tables_holdout.append(rad_table_holdout) + + # Combine the tables + rad_tables_holdout = combine_rad_tables(rad_tables_holdout) + rad_tables_holdout.Properties['userData']['flags_processing'] = {} + + return rad_tables_holdout + + def pre_process_variables(self, ml: Dict, outcome_table_binary: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]: + """ + Loads and pre-processes different radiomics tables from different variable types + found in the ml dict. + + Note: + only patients of the training/learning set should be found in this outcome table. + + Args: + ml (Dict): The machine learning dictionary containing the information of the machine learning test. + outcome_table_binary (pd.DataFrame): outcome table with binary labels. This table may be used to + pre-process some variables with the "FDA" feature set reduction algorithm. + + Returns: + Tuple: Two dict of processed radiomics tables, one dict for training and one for + testing (no feature set reduction). + """ + # Get a list of unique variables found in the ml variables combinations dict + variables_id = [s.split('_') for s in ml['variables']['combinations']] + variables_id = list(set([x for sublist in variables_id for x in sublist])) + + # For each variable, load the corresponding radiomics table and pre-process it + processed_var_tables, processed_var_tables_test = {var_id : self.pre_process_radiomics_table( + ml, + var_id, + outcome_table_binary + ) for var_id in variables_id} + + return processed_var_tables, processed_var_tables_test + + def pre_process_radiomics_table( + self, + ml: Dict, + var_id: str, + outcome_table_binary: pd.DataFrame, + patients_train: list + ) -> Tuple[pd.DataFrame, pd.DataFrame]: + """ + For the given variable, this function loads the corresponding radiomics tables and pre-processes them + (cleaning, normalization and feature set reduction). + + Note: + Only patients of the training/learning set should be found in the given outcome table. + + Args: + ml (Dict): The machine learning dictionary containing the information of the machine learning test + (parameters, options, etc.). + var_id (str): String specifying the ID of the radiomics variable in ml. For example: 'var1'. + outcome_table_binary (pd.DataFrame): outcome table with binary labels. This table may + be used to pre-process some variables with the "FDA" feature set reduction algorithm. + + patients_train (list): List of patients to use for training. + + Returns: + Tuple[pd.DataFrame, pd.DataFrame]: Two dataframes of processed radiomics tables, one for training + and one for testing (no feature set reduction). + """ + # Initialization + patient_ids = list(outcome_table_binary.index) + outcome_table_binary_training = outcome_table_binary.loc[patients_train] + var_names = ['var_datacleaning', 'var_normalization', 'var_fSetReduction'] + flags_preprocessing = {key: key in ml['variables'][var_id].keys() for key in var_names} + flags_preprocessing_test = flags_preprocessing.copy() + flags_preprocessing_test['var_fSetReduction'] = False + + # Pre-processing + rad_var_struct = ml['variables'][var_id] + rad_tables_learning = list() + for item in rad_var_struct['path'].values(): + # Loading the table + path_radiomics_csv = item['csv'] + path_radiomics_txt = item['txt'] + image_type = item['type'] + rad_table_learning = get_radiomics_table(path_radiomics_csv, path_radiomics_txt, image_type, patient_ids) + + # Data cleaning + if flags_preprocessing['var_datacleaning']: + cleaning_dict = ml['datacleaning'][ml['variables'][var_id]['var_datacleaning']]['feature']['continuous'] + data_cleaner = DataCleaner(rad_table_learning) + rad_table_learning = data_cleaner(cleaning_dict) + if rad_table_learning is None: + continue + + # Normalization (ComBat) + if flags_preprocessing['var_normalization']: + normalization_method = ml['variables'][var_id]['var_normalization'] + # Some information must be stored to re-apply combat for testing data + if 'combat' in normalization_method.lower(): + # Training data + rad_table_learning.Properties['userData']['normalization'] = dict() + rad_table_learning.Properties['userData']['normalization']['original_data'] = dict() + rad_table_learning.Properties['userData']['normalization']['original_data']['path_radiomics_csv'] = path_radiomics_csv + rad_table_learning.Properties['userData']['normalization']['original_data']['path_radiomics_txt'] = path_radiomics_txt + rad_table_learning.Properties['userData']['normalization']['original_data']['image_type'] = image_type + rad_table_learning.Properties['userData']['normalization']['original_data']['patient_ids'] = patient_ids + if flags_preprocessing['var_datacleaning']: + data_cln_method = ml['variables'][var_id]['var_datacleaning'] + rad_table_learning.Properties['userData']['normalization']['original_data']['datacleaning_method'] = data_cln_method + + # Apply ComBat + normalization = Normalization('combat') + rad_table_learning = normalization.apply_combat(variable_table=rad_table_learning) # Training data + else: + raise NotImplementedError(f'Normalization method: {normalization_method} not recognized.') + + # Save the table + rad_tables_learning.append(rad_table_learning) + + # Seperate training and testing data before feature set reduction + rad_tables_testing = deepcopy(rad_tables_learning) + rad_tables_training = [] + for rad_tab in rad_tables_learning: + patients_ids = intersect(patients_train, list(rad_tab.index)) + rad_tables_training.append(deepcopy(rad_tab.loc[patients_ids])) + + # Deepcopy properties + temp_properties = list() + for rad_tab in rad_tables_testing: + temp_properties.append(deepcopy(rad_tab.Properties)) + + # Feature set reduction (for training data only) + if flags_preprocessing['var_fSetReduction']: + f_set_reduction_method = ml['variables'][var_id]['var_fSetReduction']['method'] + fsr = FSR(f_set_reduction_method) + + # Apply FDA + rad_tables_training = fsr.apply_fsr( + ml, + rad_tables_training, + outcome_table_binary_training, + path_save_logging=ml['path_results'] + ) + + # Re-assign properties + for i in range(len(rad_tables_testing)): + rad_tables_testing[i].Properties = temp_properties[i] + del temp_properties + + # Finalization steps + rad_tables_training.Properties['userData']['flags_preprocessing'] = flags_preprocessing + rad_tables_testing = combine_rad_tables(rad_tables_testing) + rad_tables_testing.Properties['userData']['flags_processing'] = flags_preprocessing_test + + return rad_tables_training, rad_tables_testing + + def train_xgboost_model( + self, + var_table_train: pd.DataFrame, + outcome_table_binary_train: pd.DataFrame, + var_importance_threshold: float = 0.05, + optimal_threshold: float = None, + optimization_metric: str = 'MCC', + method : str = "pycaret", + use_gpu: bool = False, + seed: int = None, + ) -> Dict: + """ + Trains an XGBoost model for the given machine learning test. + + Args: + var_table_train (pd.DataFrame): Radiomics table for the training/learning set. + outcome_table_binary_train (pd.DataFrame): Outcome table with binary labels for the training/learning set. + var_importance_threshold (float): Threshold for the variable importance. Variables with importance below + this threshold will be removed from the model. + optimal_threshold (float, optional): Optimal threshold for the XGBoost model. If not given, it will be + computed using the training set. + optimization_metric (str, optional): String specifying the metric to use to optimize the ml model. + method (str, optional): String specifying the method to use to train the XGBoost model. + - "pycaret": Use PyCaret to train the model (automatic). + - "grid_search": Grid search with cross-validation to find the best parameters. + - "random_search": Random search with cross-validation to find the best parameters. + use_gpu (bool, optional): Boolean specifying if the GPU should be used to train the model. Default is True. + seed (int, optional): Integer specifying the seed to use for the random number generator. + + Returns: + Dict: Dictionary containing info about the trained XGBoost model. + """ + + # Safety check (make sure that the outcome table and the variable table have the same patients) + var_table_train, outcome_table_binary_train = intersect_var_tables(var_table_train, outcome_table_binary_train) + + # Finalize the new radiomics table with the remaining variables + var_table_train = finalize_rad_table(var_table_train) + + if method.lower() == "pycaret": + # Set up data for PyCaret + temp_data = pd.merge(var_table_train, outcome_table_binary_train, left_index=True, right_index=True) + + # PyCaret setup + setup( + data=temp_data, + feature_selection=True, + n_features_to_select=1-var_importance_threshold, + fold=5, + target=temp_data.columns[-1], + use_gpu=use_gpu, + feature_selection_estimator="xgboost", + session_id=seed + ) + + # Set seed + if seed is not None: + set_config('seed', seed) + + # Creating XGBoost model using PyCaret + classifier = create_model('xgboost', verbose=False) + + # Tuning XGBoost model using PyCaret + classifier = tune_model(classifier, optimize=optimization_metric) + + else: + # Initial training to filter features using variable importance + # XGB Classifier + classifier = XGBClassifier() + classifier.fit(var_table_train, outcome_table_binary_train) + var_importance = classifier.feature_importances_ + + # Normalize var_importance if necessary + if np.sum(var_importance) != 1: + var_importance_threshold = var_importance_threshold / np.sum(var_importance) + var_importance = var_importance / np.sum(var_importance) + + # Filter variables + var_table_train = var_table_train.iloc[:, var_importance >= var_importance_threshold] + + # Check if variable table is empty after filtering + if var_table_train.shape[1] == 0: + raise ValueError('Variable table is empty after variable importance filtering. Use a smaller threshold.') + + # Suggested scale_pos_weight + scale_pos_weight = 1 - (outcome_table_binary_train == 0).sum().values[0] \ + / (outcome_table_binary_train == 1).sum().values[0] + + # XGB Classifier + classifier = XGBClassifier(scale_pos_weight=scale_pos_weight) + + # Tune XGBoost parameters + params = { + 'max_depth': [3, 4, 5], + 'learning_rate': [0.1 , 0.01, 0.001], + 'n_estimators': [50, 100, 200] + } + + if method.lower() == "grid_search": + # Set up grid search with cross-validation + grid_search = GridSearchCV( + estimator=classifier, + param_grid=params, + cv=5, + n_jobs=-1, + verbose=3, + scoring='matthews_corrcoef' + ) + elif method.lower() == "random_search": + # Set up random search with cross-validation + grid_search = RandomizedSearchCV( + estimator=classifier, + param_distributions=params, + cv=5, + n_jobs=-1, + verbose=3, + scoring='matthews_corrcoef' + ) + else: + raise NotImplementedError(f'Method: {method} not recognized. Use "grid_search", "random_search", "auto" or "pycaret".') + + # Fit the grid search + grid_search.fit(var_table_train, outcome_table_binary_train) + + # Get the best parameters + best_params = grid_search.best_params_ + + # Fit the XGB Classifier with the best parameters + classifier = XGBClassifier(**best_params) + classifier.fit(var_table_train, outcome_table_binary_train) + + # Saving the information of the model in a dictionary + model_xgb = dict() + model_xgb['algo'] = 'xgb' + model_xgb['type'] = 'binary' + model_xgb['method'] = method + if optimal_threshold: + model_xgb['threshold'] = optimal_threshold + else: + try: + model_xgb['threshold'] = self.__find_balanced_threshold(classifier, var_table_train, outcome_table_binary_train) + except Exception as e: + print('Error in finding optimal threshold, it will be set to 0.5:' + str(e)) + model_xgb['threshold'] = 0.5 + model_xgb['model'] = classifier + model_xgb['var_names'] = list(classifier.feature_names_in_) + model_xgb['var_info'] = deepcopy(var_table_train.Properties['userData']) + if method == "auto": + model_xgb['optimization'] = "auto" + elif method == "pycaret": + model_xgb['optimization'] = classifier.get_params() + else: + model_xgb['optimization'] = best_params + + return model_xgb + + def test_xgb_model(self, model_dict: Dict, variable_table: pd.DataFrame, patient_list: List) -> List: + """ + Tests the XGBoost model for the given dataset patients. + + Args: + model_dict (Dict): Dictionary containing info about the trained XGBoost model. + variable_table (pd.DataFrame): Radiomics table for the test set (should not be normalized). + patient_list (List): List of patients to test. + + Returns: + List: List the model response for the training and test sets. + """ + # Initialization + n_test = len(patient_list) + var_names = model_dict['var_names'] + var_def = model_dict['var_info']['variables']['var_def'] + model_response = list() + + # Preparing the variable table + variable_table = get_ml_test_table(variable_table, var_names, var_def) + + # Test the model + for i in range(n_test): + # Get the patient IDs + patient_ids = patient_list[i] + + # Getting predictions for each patient + n_patients = len(patient_ids) + varargout = np.zeros((n_patients, 1)) * np.nan # NaN if the computation fails + for p in range(n_patients): + try: + varargout[p] = self.predict_xgb(model_dict['model'], variable_table.loc[[patient_ids[p]], :]) + except Exception as e: + print('Error in computing prediction for patient ' + str(patient_ids[p]) + ': ' + str(e)) + varargout[p] = np.nan + + # Save the predictions + model_response.append(varargout) + + return model_response + + def predict_xgb(self, xgb_model: XGBClassifier, variable_table: pd.DataFrame) -> float: + """ + Computes the prediction of the XGBoost model for the given variable table. + + Args: + xgb_model (XGBClassifier): XGBClassifier model. + variable_table (pd.DataFrame): Variable table for the prediction. + + Returns: + float: Prediction of the XGBoost model. + """ + + # Predictions + predictions = xgb_model.predict_proba(variable_table) + + # Get the probability of the positive class + predictions = predictions[:, 1][0] + + return predictions + + def ml_run(self, path_ml: Path, holdout_test: bool = True, method: str = 'auto') -> None: + """ + This function runs the machine learning test for the ceated experiment. + + Args: + path_ml (Path): Path to the main dictionary containing info about the ml current experiment. + holdout_test (bool, optional): Boolean specifying if the hold-out test should be performed. + + Returns: + None. + """ + # Set up logging file for the batch + log_file = os.path.dirname(path_ml) + '/batch.log' + logging.basicConfig(filename=log_file, level=logging.INFO, format='%(message)s', filemode='w') + + # Start the timer + batch_start = time.time() + + logging.info("\n\n********************MACHINE LEARNING RUN********************\n\n") + + # --> A. Initialization phase + # Load the test dictionary and machine learning information + ml_dict_paths = load_json(path_ml) # Test information dictionary + ml_info_dict = self.__load_ml_info(ml_dict_paths) # Machine learning information dictionary + + # Machine learning assets + patients_train = ml_info_dict['patientsTrain'] + patients_test = ml_info_dict['patientsTest'] + patients_holdout = load_json(self.path_study / 'patientsHoldOut.json') if holdout_test else None + outcome_table_binary = ml_info_dict['outcome_table_binary'] + ml = ml_info_dict['ml'] + path_results = ml_info_dict['path_results'] + ml['path_results'] = path_results + + # --> B. Machine Learning phase + # B.1. Pre-processing features + start = time.time() + logging.info("\n\n--> PRE-PROCESSING TRAINING VARIABLES") + + # Not all variables will be used to train the model, only the user-selected variable + var_id = str(ml['variables']['varStudy']) + + # Pre-processing of the radiomics tables/variables + processed_training_table, processed_testing_table = self.pre_process_radiomics_table( + ml, + var_id, + outcome_table_binary.copy(), + patients_train + ) + logging.info(f"...Done in {time.time()-start} s") + + # B.2. Pre-learning initialization + # Patient definitions (training and test sets) + patient_ids = list(outcome_table_binary.index) + patients_train = intersect(intersect(patient_ids, patients_train), processed_training_table.index) + patients_test = intersect(intersect(patient_ids, patients_test), processed_testing_table.index) + patients_holdout = intersect(patient_ids, patients_holdout) if holdout_test else None + + # Initializing outcome tables for training and test sets + outcome_table_binary_train = outcome_table_binary.loc[patients_train, :] + outcome_table_binary_test = outcome_table_binary.loc[patients_test, :] + outcome_table_binary_holdout = outcome_table_binary.loc[patients_holdout, :] if holdout_test else None + + # Serperate variable table for training sets (repetitive but double-checking) + var_table_train = processed_training_table.loc[patients_train, :] + + # Initializing XGBoost model settings + var_importance_threshold = ml['algorithms']['XGBoost']['varImportanceThreshold'] + optimal_threshold = ml['algorithms']['XGBoost']['optimalThreshold'] + optimization_metric = ml['algorithms']['XGBoost']['optimizationMetric'] + method = ml['algorithms']['XGBoost']['method'] if 'method' in ml['algorithms']['XGBoost'].keys() else method + use_gpu = ml['algorithms']['XGBoost']['useGPU'] if 'useGPU' in ml['algorithms']['XGBoost'].keys() else True + seed = ml['algorithms']['XGBoost']['seed'] if 'seed' in ml['algorithms']['XGBoost'].keys() else None + + # B.2. Training the XGBoost model + tstart = time.time() + logging.info(f"\n\n--> TRAINING XGBOOST MODEL FOR VARIABLE {var_id}") + + # Training the model + model = self.train_xgboost_model( + var_table_train, + outcome_table_binary_train, + var_importance_threshold, + optimal_threshold, + method=method, + use_gpu=use_gpu, + optimization_metric=optimization_metric, + seed=seed + ) + + # Saving the trained model using pickle + name_save_model = ml['algorithms']['XGBoost']['nameSave'] + model_id = name_save_model + '_' + str(ml['variables']['varStudy']) + path_model = os.path.dirname(path_results) + '/' + (model_id + '.pickle') + model_dict = save_model(model, str(ml['variables']['varStudy']), path_model, ml=ml) + + logging.info("{}--> DONE. TOTAL TIME OF LEARNING PROCESS: {:.2f} min".format(" " * 4, (time.time()-tstart) / 60)) + + # --> C. Testing phase + # C.1. Testing the XGBoost model and computing model response + tstart = time.time() + logging.info(f"\n\n--> TESTING XGBOOST MODEL FOR VARIABLE {var_id}") + + response_train, response_test = self.test_xgb_model( + model, + processed_testing_table, + [patients_train, patients_test] + ) + + logging.info('{}--> DONE. TOTAL TIME OF LEARNING PROCESS: {:.2f}'.format(" " * 4, (time.time() - tstart)/60)) + + if holdout_test: + # --> D. Holdoutset testing phase + # D.1. Prepare holdout test data + var_table_all_holdout = self.get_hold_out_set_table(ml, var_id, patients_holdout) + + # D.2. Testing the XGBoost model and computing model response on the holdout set + tstart = time.time() + logging.info(f"\n\n--> TESTING XGBOOST MODEL FOR VARIABLE {var_id} ON THE HOLDOUT SET") + + response_holdout = self.test_xgb_model(model, var_table_all_holdout, [patients_holdout])[0] + + logging.info('{}--> DONE. TOTAL TIME OF LEARNING PROCESS: {:.2f}'.format(" " * 4, (time.time() - tstart)/60)) + + # E. Computing performance metrics + tstart = time.time() + + # Initialize the Results class + result = Results(model_dict, model_id) + if holdout_test: + run_results = result.to_json( + response_train=response_train, + response_test=response_test, + response_holdout=response_holdout, + patients_train=patients_train, + patients_test=patients_test, + patients_holdout=patients_holdout + ) + else: + run_results = result.to_json( + response_train=response_train, + response_test=response_test, + response_holdout=None, + patients_train=patients_train, + patients_test=patients_test, + patients_holdout=None + ) + + # Calculating performance metrics for training phase and saving the ROC curve + run_results[model_id]['train']['metrics'] = result.get_model_performance( + response_train, + outcome_table_binary_train, + ) + + # Calculating performance metrics for testing phase and saving the ROC curve + run_results[model_id]['test']['metrics'] = result.get_model_performance( + response_test, + outcome_table_binary_test, + ) + + if holdout_test: + # Calculating performance metrics for holdout phase and saving the ROC curve + run_results[model_id]['holdout']['metrics'] = result.get_model_performance( + response_holdout, + outcome_table_binary_holdout, + ) + + logging.info('\n\n--> COMPUTING PERFORMANCE METRICS ... Done in {:.2f} sec'.format(time.time()-tstart)) + + # F. Saving the results dictionary + save_json(path_results, run_results, cls=NumpyEncoder) + + # Total computing time + logging.info("\n\n*********************************************************************") + logging.info('{} TOTAL COMPUTATION TIME: {:.2f} hours'.format(" " * 13, (time.time()-batch_start)/3600)) + logging.info("*********************************************************************") + + def run_experiment(self, holdout_test: bool = True, method: str = "pycaret") -> None: + """ + Run the machine learning experiment for each split/run + + Args: + holdout_test (bool, optional): Boolean specifying if the hold-out test should be performed. + method (str, optional): String specifying the method to use to train the XGBoost model. + - "pycaret": Use PyCaret to train the model (automatic). + - "grid_search": Grid search with cross-validation to find the best parameters. + - "random_search": Random search with cross-validation to find the best parameters. + + Returns: + None + """ + # Initialize the DesignExperiment class + experiment = DesignExperiment(self.path_study, self.path_settings, self.experiment_label) + + # Generate the machine learning experiment + path_file_ml_paths = experiment.generate_experiment() + + # Run the different machine learning tests for the experiment + tests_dict = load_json(path_file_ml_paths) # Tests dictionary + for run in tests_dict.keys(): + self.ml_run(tests_dict[run], holdout_test, method) + + # Average results of the different splits/runs + average_results(self.path_study / f'learn__{self.experiment_label}', save=True) + + # Analyze the features importance for all the runs + feature_imporance_analysis(self.path_study / f'learn__{self.experiment_label}') + \ No newline at end of file diff --git a/MEDiml/learning/Results.py b/MEDiml/learning/Results.py new file mode 100644 index 0000000..839b728 --- /dev/null +++ b/MEDiml/learning/Results.py @@ -0,0 +1,2237 @@ +# Description: Class Results to store and analyze the results of experiments. + +import os +from pathlib import Path +from typing import List + +import matplotlib.patches as mpatches +import matplotlib.pyplot as plt +import networkx as nx +import numpy as np +import pandas as pd +import seaborn as sns +from matplotlib import pyplot as plt +from matplotlib.colors import to_rgba +from matplotlib.lines import Line2D +from networkx.drawing.nx_pydot import graphviz_layout +from numpyencoder import NumpyEncoder +from sklearn import metrics + +from MEDiml.learning.ml_utils import feature_imporance_analysis, list_metrics +from MEDiml.learning.Stats import Stats +from MEDiml.utils.json_utils import load_json, save_json +from MEDiml.utils.texture_features_names import * + + +class Results: + """ + A class to analyze the results of a given machine learning experiment, including the assessment of the model's performance, + + Args: + model_dict (dict, optional): Dictionary containing the model's parameters. Defaults to {}. + model_id (str, optional): ID of the model. Defaults to "". + + Attributes: + model_dict (dict): Dictionary containing the model's parameters. + model_id (str): ID of the model. + results_dict (dict): Dictionary containing the results of the model's performance. + """ + def __init__(self, model_dict: dict = {}, model_id: str = "") -> None: + """ + Constructor of the class Results + """ + self.model_dict = model_dict + self.model_id = model_id + self.results_dict = {} + + def __calculate_performance( + self, + response: list, + labels: pd.DataFrame, + thresh: float + ) -> dict: + """ + Computes performance metrics of given a model's response, outcome and threshold. + + Args: + response (list): List of the probabilities of class "1" for all instances (prediction) + labels (pd.Dataframe): Column vector specifying the outcome status (1 or 0) for all instances. + thresh (float): Optimal threshold selected from the ROC curve. + + Returns: + Dict: Dictionary containing the performance metrics. + """ + # Recording results + results_dict = dict() + + # Removing Nans + df = labels.copy() + outcome_name = labels.columns.values[0] + df['response'] = response + df.dropna(axis=0, how='any', inplace=True) + + # Confusion matrix elements: + results_dict['TP'] = ((df['response'] >= thresh) & (df[outcome_name] == 1)).sum() + results_dict['TN'] = ((df['response'] < thresh) & (df[outcome_name] == 0)).sum() + results_dict['FP'] = ((df['response'] >= thresh) & (df[outcome_name] == 0)).sum() + results_dict['FN'] = ((df['response'] < thresh) & (df[outcome_name] == 1)).sum() + + # Copying confusion matrix elements + TP = results_dict['TP'] + TN = results_dict['TN'] + FP = results_dict['FP'] + FN = results_dict['FN'] + + # AUC + results_dict['AUC'] = metrics.roc_auc_score(df[outcome_name], df['response']) + + # AUPRC + results_dict['AUPRC'] = metrics.average_precision_score(df[outcome_name], df['response']) + + # Sensitivity + try: + results_dict['Sensitivity'] = TP / (TP + FN) + except: + print('TP + FN = 0, Division by 0, replacing sensitivity by 0.0') + results_dict['Sensitivity'] = 0.0 + + # Specificity + try: + results_dict['Specificity'] = TN / (TN + FP) + except: + print('TN + FP= 0, Division by 0, replacing specificity by 0.0') + results_dict['Specificity'] = 0.0 + + # Balanced accuracy + results_dict['BAC'] = (results_dict['Sensitivity'] + results_dict['Specificity']) / 2 + + # Precision + results_dict['Precision'] = TP / (TP + FP) + + # NPV (Negative Predictive Value) + results_dict['NPV'] = TN / (TN + FN) + + # Accuracy + results_dict['Accuracy'] = (TP + TN) / (TP + TN + FP + FN) + + # F1 score + results_dict['F1_score'] = 2 * TP / (2 * TP + FP + FN) + + # mcc (mathews correlation coefficient) + results_dict['MCC'] = (TP * TN - FP * FN) / np.sqrt((TP + FP) * (TP + FN) * (TN + FP) * (TN + FN)) + + return results_dict + + def __get_metrics_failure_dict( + self, + metrics: list = list_metrics + ) -> dict: + """ + This function fills the metrics with NaNs in case of failure. + + Args: + metrics (list, optional): List of metrics to be filled with NaNs. + Defaults to ['AUC', 'Sensitivity', 'Specificity', 'BAC', + 'AUPRC', 'Precision', 'NPV', 'Accuracy', 'F1_score', 'MCC' + 'TP', 'TN', 'FP', 'FN']. + + Returns: + Dict: Dictionary with the metrics filled with NaNs. + """ + failure_struct = dict() + failure_struct = {metric: np.nan for metric in metrics} + + return failure_struct + + def __count_percentage_levels(self, features_dict: dict, fda: bool = False) -> list: + """ + Counts the percentage of each radiomics level in a given features dictionary. + + Args: + features_dict (dict): Dictionary of features. + fda (bool, optional): If True, meaning the features are from the FDA logging dict and will be + treated differently. Defaults to False. + + Returns: + list: List of percentages of features in each complexity levels. + """ + # Intialization + perc_levels = [0] * 7 # 4 levels and two variants for the filters + + # List all features in dict + if fda: + list_features = [feature.split('/')[-1] for feature in features_dict['final']] + else: + list_features = list(features_dict.keys()) + + # Count the percentage of levels + for feature in list_features: + level_name = feature.split('__')[1].lower() + feature_name = feature.split('__')[2].lower() + # Morph + if level_name.startswith('morph'): + perc_levels[0] += 1 + # Intensity + elif level_name.startswith('intensity'): + perc_levels[1] += 1 + # Texture + elif level_name.startswith('texture'): + perc_levels[2] += 1 + # Linear filters + elif level_name.startswith('mean') \ + or level_name.startswith('log') \ + or level_name.startswith('laws') \ + or level_name.startswith('gabor') \ + or level_name.startswith('wavelet') \ + or level_name.startswith('coif'): + # seperate intensity and texture + if feature_name.startswith('_int'): + perc_levels[3] += 1 + elif feature_name.startswith(tuple(['_glcm', '_gldzm', '_glrlm', '_glszm', '_ngtdm', '_ngldm'])): + perc_levels[4] += 1 + # Textural filters + elif level_name.startswith('glcm'): + # seperate intensity and texture + if feature_name.startswith('_int'): + perc_levels[5] += 1 + elif feature_name.startswith(tuple(['_glcm', '_gldzm', '_glrlm', '_glszm', '_ngtdm', '_ngldm'])): + perc_levels[6] += 1 + + return perc_levels / np.sum(perc_levels, axis=0) * 100 + + def __count_percentage_radiomics(self, results_dict: dict) -> list: + """ + Counts the percentage of radiomics levels for all features used for the experiment. + + Args: + results_dict (dict): Dictionary of final run results. + + Returns: + list: List of percentages of features used for the model sorted by complexity levels. + """ + # Intialization + perc_levels = [0] * 5 # 5 levels: morph, intensity, texture, linear filters, textural filters + model_name = list(results_dict.keys())[0] + radiomics_tables_dict = results_dict[model_name]['var_info']['normalization'] + + # Count the percentage of levels + for key in list(radiomics_tables_dict.keys()): + if key.lower().startswith('radtab'): + table_path = radiomics_tables_dict[key]['original_data']['path_radiomics_csv'] + table_name = table_path.split('/')[-1] + table = pd.read_csv(table_path, index_col=0) + # Morph + if 'morph' in table_name.lower(): + perc_levels[0] += table.columns.shape[0] + # Intensity + elif 'intensity' in table_name.lower(): + perc_levels[1] += table.columns.shape[0] + # Texture + elif 'texture' in table_name.lower(): + perc_levels[2] += table.columns.shape[0] + # Linear filters + elif 'mean' in table_name.lower() \ + or 'log' in table_name.lower() \ + or 'laws' in table_name.lower() \ + or 'gabor' in table_name.lower() \ + or 'wavelet' in table_name.lower() \ + or 'coif' in table_name.lower(): + perc_levels[3] += table.columns.shape[0] + # Textural filters + elif 'glcm' in table_name.lower(): + perc_levels[4] += table.columns.shape[0] + + return perc_levels / np.sum(perc_levels, axis=0) * 100 + + def __count_stable_fda(self, features_dict: dict) -> list: + """ + Counts the percentage of levels in the features dictionary. + + Args: + features_dict (dict): Dictionary of features. + + Returns: + list: List of percentages of features in each complexity levels. + """ + # Intialization + count_levels = [0] * 5 # 5 levels and two variants for the filters + + # List all features in dict + features_dict = features_dict["one_space"]["unstable"] + list_features = list(features_dict.keys()) + + # Count the percentage of levels + for feature_name in list_features: + # Morph + if feature_name.lower().startswith('morph'): + count_levels[0] += features_dict[feature_name] + # Intensity + elif feature_name.lower().startswith('intensity'): + count_levels[1] += features_dict[feature_name] + # Texture + elif feature_name.lower().startswith('texture'): + count_levels[2] += features_dict[feature_name] + # Linear filters + elif feature_name.lower().startswith('mean') \ + or feature_name.lower().startswith('log') \ + or feature_name.lower().startswith('laws') \ + or feature_name.lower().startswith('gabor') \ + or feature_name.lower().startswith('wavelet') \ + or feature_name.lower().startswith('coif'): + count_levels[3] += features_dict[feature_name] + # Textural filters + elif feature_name.lower().startswith('glcm'): + count_levels[4] += features_dict[feature_name] + + return count_levels + + def __count_patients(self, path_results: Path) -> dict: + """ + Counts the number of patients used in learning, testing and holdout. + + Args: + path_results(Path): path to the folder containing the results of the experiment. + + Returns: + Dict: Dictionary with the number of patients used in learning, testing and holdout. + """ + # Get all tests paths + list_path_tests = [path for path in path_results.iterdir() if path.is_dir()] + + # Initialize dictionaries + patients_count = { + 'train': {}, + 'test': {}, + 'holdout': {} + } + + # Process metrics + for dataset in ['train', 'test', 'holdout']: + for path_test in list_path_tests: + results_dict = load_json(path_test / 'run_results.json') + if dataset in results_dict[list(results_dict.keys())[0]].keys(): + if 'patients' in results_dict[list(results_dict.keys())[0]][dataset].keys(): + if results_dict[list(results_dict.keys())[0]][dataset]['patients']: + patients_count[dataset] = len(results_dict[list(results_dict.keys())[0]][dataset]['patients']) + else: + continue + else: + continue + break # The number of patients is the same for all the runs + + return patients_count + + def average_results(self, path_results: Path, save: bool = False) -> None: + """ + Averages the results (AUC, BAC, Sensitivity and Specifity) of all the runs of the same experiment, + for training, testing and holdout sets. + + Args: + path_results(Path): path to the folder containing the results of the experiment. + save (bool, optional): If True, saves the results in the same folder as the model. + + Returns: + None. + """ + # Get all tests paths + list_path_tests = [path for path in path_results.iterdir() if path.is_dir()] + + # Initialize dictionaries + results_avg = { + 'train': {}, + 'test': {}, + 'holdout': {} + } + + # Retrieve metrics + for dataset in ['train', 'test', 'holdout']: + dataset_dict = results_avg[dataset] + for metric in list_metrics: + metric_values = [] + for path_test in list_path_tests: + results_dict = load_json(path_test / 'run_results.json') + if dataset in results_dict[list(results_dict.keys())[0]].keys(): + if 'metrics' in results_dict[list(results_dict.keys())[0]][dataset].keys(): + metric_values.append(results_dict[list(results_dict.keys())[0]][dataset]['metrics'][metric]) + else: + continue + else: + continue + + # Fill the dictionary + if metric_values: + dataset_dict[f'{metric}_mean'] = np.nanmean(metric_values) + dataset_dict[f'{metric}_std'] = np.nanstd(metric_values) + dataset_dict[f'{metric}_max'] = np.nanmax(metric_values) + dataset_dict[f'{metric}_min'] = np.nanmin(metric_values) + dataset_dict[f'{metric}_2.5%'] = np.nanpercentile(metric_values, 2.5) + dataset_dict[f'{metric}_97.5%'] = np.nanpercentile(metric_values, 97.5) + + # Save the results + if save: + save_json(path_results / 'results_avg.json', results_avg, cls=NumpyEncoder) + return path_results / 'results_avg.json' + + return results_avg + + def get_model_performance( + self, + response: list, + outcome_table: pd.DataFrame + ) -> None: + """ + Calculates the performance of the model + Args: + response (list): List of machine learning model predictions. + outcome_table (pd.DataFrame): Outcome table with binary labels. + + Returns: + None: Updates the ``run_results`` attribute. + """ + # Calculating performance metrics for the training set + try: + # Convert list of model response to a table to facilitate the process + results_dict = dict() + patient_ids = list(outcome_table.index) + response_table = pd.DataFrame(response) + response_table.index = patient_ids + response_table._metadata += ['Properties'] + response_table.Properties = dict() + response_table.Properties['RowNames'] = patient_ids + + # Make sure the outcome table and the response table have the same patients + outcome_binary = outcome_table.loc[patient_ids, :] + outcome_binary = outcome_binary.iloc[:, 0] + response = response_table.loc[patient_ids, :] + response = response.iloc[:, 0] + + # Calculating performance + results_dict = self.__calculate_performance(response, outcome_binary.to_frame(), self.model_dict['threshold']) + + return results_dict + + except Exception as e: + print(f"Error: ", e, "filling metrics with nan...") + return self.__get_metrics_failure_dict() + + def get_optimal_level( + self, + path_experiments: Path, + experiments_labels: List[str], + metric: str = 'AUC_mean', + p_value_test: str = 'wilcoxon', + aggregate: bool = False, + ) -> None: + """ + This function plots a heatmap of the metrics values for the performance of the models in the given experiment. + + Args: + path_experiments (Path): Path to the folder containing the experiments. + experiments_labels (List): List of experiments labels to use for the plot. including variants is possible. For + example: ['experiment1_morph_CT', ['experiment1_intensity5_CT', 'experiment1_intensity10_CT'], 'experiment1_texture_CT']. + metric (str, optional): Metric to plot. Defaults to 'AUC_mean'. + p_value_test (str, optional): Method to use to calculate the p-value. Defaults to 'wilcoxon'. + Available options: + + - 'delong': Delong test. + - 'ttest': T-test. + - 'wilcoxon': Wilcoxon signed rank test. + - 'bengio': Bengio and Nadeau corrected t-test. + aggregate (bool, optional): If True, aggregates the results of all the splits and computes one final p-value. + Only valid for the Delong test when cross-validation is used. Defaults to False. + + Returns: + None. + """ + assert metric.split('_')[0] in list_metrics, f'Given metric {list_metrics} is not in the list of metrics. Please choose from {list_metrics}' + + # Extract modalities and initialize the dictionary + if type(experiments_labels[0]) == str: + experiment = '_'.join(experiments_labels[0].split('_')[:-2]) + elif type(experiments_labels[0]) == list: + experiment = '_'.join(experiments_labels[0][0].split('_')[:-2]) + + modalities = set() + for exp_label in experiments_labels: + if isinstance(exp_label, str): + modalities.add(exp_label.split("_")[-1]) + elif isinstance(exp_label, list): + for sub_exp_label in exp_label: + modalities.add(sub_exp_label.split("_")[-1]) + else: + raise ValueError(f'experiments_labels must be a list of strings or a list of list of strings, given: {type(exp_label)}') + + levels_dict = {modality: [] for modality in modalities} + optimal_lvls = [""] * len(modalities) + + # Populate the dictionary + variants = [] + for label in experiments_labels: + if isinstance(label, str): + modality = label.split("_")[-1] + levels_dict[modality].append(label.split("_")[-2]) + elif isinstance(label, list): + modality = label[0].split("_")[-1] + variants = [] + for sub_label in label: + variants.append(sub_label.split("_")[-2]) + levels_dict[modality] += [variants] + + # Prepare the data for the heatmap + for idx_m, modality in enumerate(modalities): + best_levels = [] + results_dict_best = dict() + results_dicts = [] + best_exp = "" + levels = levels_dict[modality] + + # Loop over the levels and find the best variant for each level + for level in levels: + metric_compare = -1.0 + if type(level) != list: + level = [level] + for variant in level: + exp_full_name = 'learn__' + experiment + '_' + variant + '_' + modality + if 'results_avg.json' in os.listdir(path_experiments / exp_full_name): + results_dict = load_json(path_experiments / exp_full_name / 'results_avg.json') + else: + results_dict = self.average_results(path_experiments / exp_full_name) + if metric_compare < results_dict['test'][metric]: + metric_compare = results_dict['test'][metric] + results_dict_best = results_dict + best_exp = variant + best_levels.append(best_exp) + results_dicts.append(results_dict_best) + + # Create the heatmap data using the metric of interest + heatmap_data = np.zeros((2, len(best_levels))) + + # Fill the heatmap data + for j in range(len(best_levels)): + # Get metrics and p-values + results_dict = results_dicts[j] + if aggregate and 'delong' in p_value_test: + metric_stat = round(Stats.get_aggregated_metric( + path_experiments, + experiment, + best_levels[j], + modality, + metric.split('_')[0] if '_' in metric else metric + ), 2) + else: + metric_stat = round(results_dict['test'][metric], 2) + heatmap_data[0, j] = metric_stat + + # Statistical analysis + # Initializations + optimal_lvls[idx_m] = experiment + "_" + best_levels[0] + "_" + modality + init_metric = heatmap_data[0][0] + idx_d = 0 + start_level = 0 + + # Get p-values for all the levels + while idx_d < len(best_levels) - 1: + metric_val = heatmap_data[0][idx_d+1] + # Get p-value only if the metric is improving + if metric_val > init_metric: + # Instantiate the Stats class + stats = Stats( + path_experiments, + experiment, + [best_levels[start_level], best_levels[idx_d+1]], + [modality] + ) + + # Get p-value + p_value = stats.get_p_value( + p_value_test, + metric=metric if '_' not in metric else metric.split('_')[0], + aggregate=aggregate + ) + + # If p-value is less than 0.05, change starting level + if p_value <= 0.05: + optimal_lvls[idx_m] = experiment + "_" + best_levels[idx_d+1] + "_" + modality + init_metric = metric_val + start_level = idx_d + 1 + + # Go to next column + idx_d += 1 + + return optimal_lvls + + def plot_features_importance_histogram( + self, + path_experiments: Path, + experiment: str, + level: str, + modalities: List, + sort_option: str = 'importance', + title: str = None, + save: bool = True, + figsize: tuple = (12, 12) + ) -> None: + """ + Plots a histogram of the features importance for the given experiment. + + Args: + path_experiments (Path): Path to the folder containing the experiments. + experiment (str): Name of the experiment to plot. Will be used to find the results. + level (str): Radiomics level to plot. For example: 'morph'. + modalities (List): List of imaging modalities to use for the plot. A plot for each modality. + sort_option (str, optional): Option used to sort the features. Available options: + - 'importance': Sorts the features by importance. + - 'times_selected': Sorts the features by the number of times they were selected across the different splits. + - 'both': Sorts the features by importance and then by the number of times they were selected. + title (str, optional): Title of the plot. Defaults to None. + save (bool, optional): Whether to save the plot. Defaults to True. + figsize (tuple, optional): Size of the figure. Defaults to (12, 12). + + Returns: + None. Plots the figure or saves it. + """ + + # checks + assert sort_option in ['importance', 'times_selected', 'both'], \ + f'sort_option must be either "importance", "times_selected" or "both". Given: {sort_option}' + + # For each modality, load features importance dict + for modality in modalities: + exp_full_name = 'learn__' + experiment + '_' + level + '_' + modality + + # Load features importance dict + if 'feature_importance_analysis.json' in os.listdir(path_experiments / exp_full_name): + feat_imp_dict = load_json(path_experiments / exp_full_name / 'feature_importance_analysis.json') + else: + raise FileNotFoundError(f'feature_importance_analysis.json not found in {path_experiments / exp_full_name}') + + # Organize the data in a dataframe + keys = list(feat_imp_dict.keys()) + mean_importances = [] + times_selected = [] + for key in keys: + times_selected + mean_importances.append(feat_imp_dict[key]['importance_mean']) + times_selected.append(feat_imp_dict[key]['times_selected']) + df = pd.DataFrame({'feature': keys, 'importance': mean_importances, 'times_selected': times_selected}) + df = df.sort_values(by=[sort_option], ascending=True) + + # Plot the histogram + plt.rcParams["font.weight"] = "bold" + plt.rcParams["axes.labelweight"] = "bold" + if sort_option == 'importance': + color = 'deepskyblue' + else: + color = 'darkorange' + plt.figure(figsize=figsize) + plt.xlabel(sort_option) + plt.ylabel('Features') + plt.barh(df['feature'], df[sort_option], color=color) + + # Add title + if title: + plt.title(title, weight='bold') + else: + plt.title(f'Features importance histogram \n {experiment} - {level} - {modality}', weight='bold') + plt.tight_layout() + + # Save the plot + if save: + plt.savefig(path_experiments / f'features_importance_histogram_{level}_{modality}_{sort_option}.png') + else: + plt.show() + + def plot_heatmap( + self, + path_experiments: Path, + experiments_labels: List[str], + metric: str = 'AUC_mean', + stat_extra: list = [], + plot_p_values: bool = True, + p_value_test: str = 'wilcoxon', + aggregate: bool = False, + title: str = None, + save: bool = False, + figsize: tuple = (8, 8) + ) -> None: + """ + This function plots a heatmap of the metrics values for the performance of the models in the given experiment. + + Args: + path_experiments (Path): Path to the folder containing the experiments. + experiments_labels (List): List of experiments labels to use for the plot. including variants is possible. For + example: ['experiment1_morph_CT', ['experiment1_intensity5_CT', 'experiment1_intensity10_CT'], 'experiment1_texture_CT']. + metric (str, optional): Metric to plot. Defaults to 'AUC_mean'. + stat_extra (list, optional): List of extra statistics to include in the plot. Defaults to []. + plot_p_values (bool, optional): If True plots the p-value of the choosen test. Defaults to True. + p_value_test (str, optional): Method to use to calculate the p-value. Defaults to 'wilcoxon'. Available options: + + - 'delong': Delong test. + - 'ttest': T-test. + - 'wilcoxon': Wilcoxon signed rank test. + - 'bengio': Bengio and Nadeau corrected t-test. + aggregate (bool, optional): If True, aggregates the results of all the splits and computes one final p-value. + Only valid for the Delong test when cross-validation is used. Defaults to False. + extra_xlabels (List, optional): List of extra x-axis labels. Defaults to []. + title (str, optional): Title of the plot. Defaults to None. + save (bool, optional): Whether to save the plot. Defaults to False. + figsize (tuple, optional): Size of the figure. Defaults to (8, 8). + + Returns: + None. + """ + assert metric.split('_')[0] in list_metrics, f'Given metric {list_metrics} is not in the list of metrics. Please choose from {list_metrics}' + + # Extract modalities and initialize the dictionary + if type(experiments_labels[0]) == str: + experiment = '_'.join(experiments_labels[0].split('_')[:-2]) + elif type(experiments_labels[0]) == list: + experiment = '_'.join(experiments_labels[0][0].split('_')[:-2]) + + modalities = set() + for exp_label in experiments_labels: + if isinstance(exp_label, str): + modalities.add(exp_label.split("_")[-1]) + elif isinstance(exp_label, list): + for sub_exp_label in exp_label: + modalities.add(sub_exp_label.split("_")[-1]) + else: + raise ValueError(f'experiments_labels must be a list of strings or a list of list of strings, given: {type(exp_label)}') + + levels_dict = {modality: [] for modality in modalities} + + # Populate the dictionary + variants = [] + for label in experiments_labels: + if isinstance(label, str): + modality = label.split("_")[-1] + levels_dict[modality].append(label.split("_")[-2]) + elif isinstance(label, list): + modality = label[0].split("_")[-1] + variants = [] + for sub_label in label: + variants.append(sub_label.split("_")[-2]) + levels_dict[modality] += [variants] + + # Prepare the data for the heatmap + fig, axs = plt.subplots(len(modalities), figsize=figsize) + + # Heatmap conception + for idx_m, modality in enumerate(modalities): + # Initializations + best_levels = [] + results_dict_best = dict() + results_dicts = [] + best_exp = "" + patients_count = dict.fromkeys([modality]) + levels = levels_dict[modality] + + # Loop over the levels and find the best variant for each level + for level in levels: + metric_compare = -1.0 + if type(level) != list: + level = [level] + for idx, variant in enumerate(level): + exp_full_name = 'learn__' + experiment + '_' + variant + '_' + modality + if 'results_avg.json' in os.listdir(path_experiments / exp_full_name): + results_dict = load_json(path_experiments / exp_full_name / 'results_avg.json') + else: + results_dict = self.average_results(path_experiments / exp_full_name) + if metric_compare < results_dict['test'][metric]: + metric_compare = results_dict['test'][metric] + results_dict_best = results_dict + best_exp = variant + best_levels.append(best_exp) + results_dicts.append(results_dict_best) + + # Patient count + patients_count[modality] = self.__count_patients(path_experiments / exp_full_name) + + # Create the heatmap data using the metric of interest + if plot_p_values: + heatmap_data = np.zeros((2, len(best_levels))) + else: + heatmap_data = np.zeros((1, len(best_levels))) + + # Fill the heatmap data + labels = heatmap_data.tolist() + labels_draw = heatmap_data.tolist() + heatmap_data_draw = heatmap_data.tolist() + for j in range(len(best_levels)): + # Get metrics and p-values + results_dict = results_dicts[j] + if aggregate and 'delong' in p_value_test: + metric_stat = round(Stats.get_aggregated_metric( + path_experiments, + experiment, + best_levels[j], + modality, + metric.split('_')[0] if '_' in metric else metric + ), 2) + else: + metric_stat = round(results_dict['test'][metric], 2) + if plot_p_values: + heatmap_data[0, j] = metric_stat + else: + heatmap_data[1, j] = metric_stat + + # Extra statistics + if stat_extra: + if plot_p_values: + labels[0][j] = f'{metric_stat}' + if j < len(best_levels) - 1: + labels[1][j+1] = f'{round(heatmap_data[1, j+1], 5)}' + labels[1][0] = '-' + for extra_stat in stat_extra: + if aggregate and ('sensitivity' in extra_stat.lower() or 'specificity' in extra_stat.lower()): + extra_metric_stat = round(Stats.get_aggregated_metric( + path_experiments, + experiment, + best_levels[j], + modality, + extra_stat.split('_')[0] + ), 2) + extra_stat = extra_stat.split('_')[0] + '_agg' if '_' in extra_stat else extra_stat + labels[0][j] += f'\n{extra_stat}: {extra_metric_stat}' + else: + extra_metric_stat = round(results_dict['test'][extra_stat], 2) + labels[0][j] += f'\n{extra_stat}: {extra_metric_stat}' + else: + labels[0][j] = f'{metric_stat}' + for extra_stat in stat_extra: + extra_metric_stat = round(results_dict['test'][extra_stat], 2) + labels[0][j] += f'\n{extra_stat}: {extra_metric_stat}' + else: + labels = np.array(heatmap_data).round(4).tolist() + + # Update modality name to include the number of patients for training and testing + modalities_label = [modality + f' ({patients_count[modality]["train"]} train, {patients_count[modality]["test"]} test)'] + + # Data to draw + heatmap_data_draw = heatmap_data.copy() + labels_draw = labels.copy() + labels_draw[1] = [''] * len(labels[1]) + heatmap_data_draw[1] = np.array([-1] * heatmap_data[1].shape[0]) if 'MCC' in metric else np.array([0] * heatmap_data[1].shape[0]) + + # Set up the rows (modalities and p-values) + if plot_p_values: + modalities_temp = modalities_label.copy() + modalities_label = ['p-values'] * len(modalities_temp) * 2 + for idx in range(len(modalities_label)): + if idx % 2 == 0: + modalities_label[idx] = modalities_temp[idx // 2] + + # Convert the numpy array to a DataFrame for Seaborn + df = pd.DataFrame(heatmap_data_draw, columns=best_levels, index=modalities_label) + + # To avoid bugs, convert axs to list if only one modality is used + if len(modalities) == 1: + axs = [axs] + + # Create the heatmap using seaborn + sns.heatmap( + df, + annot=labels_draw, + ax=axs[idx_m], + fmt="", + cmap="Blues", + cbar=True, + linewidths=0.5, + vmin=-1 if 'MCC' in metric else 0, + vmax=1, + annot_kws={"weight": "bold", "fontsize": 8} + ) + + # Plot p-values + if plot_p_values: + # Initializations + extent_x = axs[idx_m].get_xlim() + step_x = 1 + start_x = extent_x[0] + 0.5 + end_x = start_x + step_x + step_y = 1 / extent_x[1] + start_y = 1 + endpoints_x = [] + endpoints_y = [] + init_metric = heatmap_data[0][0] + idx_d = 0 + start_level = 0 + + # p-values for all levels + while idx_d < len(best_levels) - 1: + # Retrieve the metric value + metric_val = heatmap_data[0][idx_d+1] + + # Instantiate the Stats class + stats = Stats( + path_experiments, + experiment, + [best_levels[start_level], best_levels[idx_d+1]], + [modality] + ) + + # Get p-value only if the metric is improving + if metric_val > init_metric: + p_value = stats.get_p_value( + p_value_test, + metric=metric if '_' not in metric else metric.split('_')[0], + aggregate=aggregate + ) + + # round the pvalue + p_value = round(p_value, 3) + + # Set color, red if p-value > 0.05, green otherwise + color = 'r' if p_value > 0.05 else 'g' + + # Plot the p-value (line and value) + axs[idx_m].axhline(start_y + step_y, xmin=start_x/extent_x[1], xmax=end_x/extent_x[1], color=color) + axs[idx_m].text(start_x + step_x/2, start_y + step_y, p_value, va='center', color=color, ha='center', backgroundcolor='w') + + # Plot endpoints + endpoints_x = [start_x, end_x] + endpoints_y = [start_y + step_y, start_y + step_y] + axs[idx_m].scatter(endpoints_x, endpoints_y, color=color) + + # Move to next line + step_y += 1 / extent_x[1] + + # If p-value is less than 0.05, change starting level + if p_value <= 0.05: + init_metric = metric_val + start_x = end_x + start_level = idx_d + 1 + + # Go to next column + end_x += step_x + idx_d += 1 + + # Rotate xticks + axs[idx_m].set_xticks(axs[idx_m].get_xticks(), best_levels, rotation=45) + + # Set title + if title: + fig.suptitle(title) + else: + fig.suptitle(f'{metric} heatmap') + + # Tight layout + fig.tight_layout() + + # Save the heatmap + if save: + if title: + fig.savefig(path_experiments / f'{title}.png') + else: + fig.savefig(path_experiments / f'{metric}_heatmap.png') + else: + fig.show() + + def plot_radiomics_starting_percentage( + self, + path_experiments: Path, + experiment: str, + levels: List, + modalities: List, + title: str = None, + figsize: tuple = (15, 10), + save: bool = False + ) -> None: + """ + This function plots a pie chart of the percentage of features used in experiment per radiomics level. + + Args: + path_experiments (Path): Path to the folder containing the experiments. + experiment (str): Name of the experiment to plot. Will be used to find the results. + levels (List): List of radiomics levels to include in the plot. + modalities (List): List of imaging modalities to include in the plot. + title(str, optional): Title and name used to save the plot. Defaults to None. + figsize(tuple, optional): Size of the figure. Defaults to (15, 10). + save (bool, optional): Whether to save the plot. Defaults to False. + + Returns: + None. + """ + # Levels names + levels_names = [ + 'Morphology', + 'Intensity', + 'Texture', + 'Linear filters', + 'Textural filters' + ] + + # Initialization + colors_sns = sns.color_palette("pastel", n_colors=5) + + # Create mutliple plots for the pie charts + fig, axes = plt.subplots(len(modalities), len(levels), figsize=figsize) + + # Load the models resutls + for i, modality in enumerate(modalities): + for j, level in enumerate(levels): + exp_full_name = 'learn__' + experiment + '_' + level + '_' + modality + # Use the first test folder to get the results dict + if 'test__001' in os.listdir(path_experiments / exp_full_name): + run_results_dict = load_json(path_experiments / exp_full_name / 'test__001' / 'run_results.json') + else: + raise FileNotFoundError(f'no test file named test__001 in {path_experiments / exp_full_name}') + + # Extract percentage of features per level + perc_levels = np.round(self.__count_percentage_radiomics(run_results_dict), 2) + + # Plot the pie chart of the percentages + if len(modalities) > 1: + axes[i, j].pie( + perc_levels, + autopct= lambda p: '{:.1f}%'.format(p) if p > 0 else '', + pctdistance=0.8, + startangle=120, + rotatelabels=True, + textprops={'fontsize': 14, 'weight': 'bold'}, + colors=colors_sns) + axes[i, j].set_title(f'{level} - {modality}', fontsize=15) + else: + axes[j].pie( + perc_levels, + autopct= lambda p: '{:.1f}%'.format(p) if p > 0 else '', + pctdistance=0.8, + startangle=120, + rotatelabels=True, + textprops={'fontsize': 14, 'weight': 'bold'}, + colors=colors_sns) + axes[j].set_title(f'{level} - {modality}', fontsize=15) + + # Add legend + plt.legend(levels_names, loc='center left', bbox_to_anchor=(1, 0.5), prop={'size': 15}) + fig.tight_layout() + + if title: + fig.suptitle(title, fontsize=20) + else: + fig.suptitle(f'{experiment}: % of starting features per level', fontsize=20) + + # Save the heatmap + if save: + if title: + plt.savefig(path_experiments / f'{title}.png') + else: + plt.savefig(path_experiments / f'{experiment}_percentage_starting_features.png') + else: + plt.show() + + def plot_fda_analysis_heatmap( + self, + path_experiments: Path, + experiment: str, + levels: List, + modalities: List, + title: str = None, + save: bool = False + ) -> None: + """ + This function plots a heatmap of the percentage of stable features and final features selected by FDA for a given experiment. + + Args: + path_experiments (Path): Path to the folder containing the experiments. + experiment (str): Name of the experiment to plot. Will be used to find the results. + levels (List): List of radiomics levels to include in plot. For example: ['morph', 'intensity']. + modalities (List): List of imaging modalities to include in the plot. + title(str, optional): Title and name used to save the plot. Defaults to None. + save (bool, optional): Whether to save the plot. Defaults to False. + + Returns: + None. + """ + # Initialization - Levels names + levels_names = [ + 'Morphology', + 'Intensity', + 'Texture', + 'LF - Intensity', + 'LF - Texture', + 'TF - Intensity', + 'TF - Texture' + ] + level_names_stable = [ + 'Morphology', + 'Intensity', + 'Texture', + 'LF', + 'TF' + ] + + # Initialization - Colors + colors_sns = sns.color_palette("pastel", n_colors=5) + colors_sns_stable = sns.color_palette("pastel", n_colors=5) + colors_sns.insert(3, colors_sns[3]) + colors_sns.insert(5, colors_sns[-1]) + hatch = ['', '', '', '..', '//', '..', '//'] + + # Set hatches color + plt.rcParams['hatch.color'] = 'white' + + # Create mutliple plots for the pie charts + fig, axes = plt.subplots(len(modalities) * 2, len(levels), figsize=(18, 10)) + + # Load the models resutls + for i, modality in enumerate(modalities): + for j, level in enumerate(levels): + perc_levels_stable = [] + perc_levels_final = [] + exp_full_name = 'learn__' + experiment + '_' + level + '_' + modality + for folder in os.listdir(path_experiments / exp_full_name): + if folder.lower().startswith('test__'): + if 'fda_logging_dict.json' in os.listdir(path_experiments / exp_full_name / folder): + fda_dict = load_json(path_experiments / exp_full_name / folder / 'fda_logging_dict.json') + perc_levels_stable.append(self.__count_stable_fda(fda_dict)) + perc_levels_final.append(self.__count_percentage_levels(fda_dict, fda=True)) + else: + raise FileNotFoundError(f'no fda_logging_dict.json file in {path_experiments / exp_full_name / folder}') + + # Average the results + perc_levels_stable = np.mean(perc_levels_stable, axis=0).astype(int) + perc_levels_final = np.mean(perc_levels_final, axis=0).astype(int) + + # Plot pie chart of stable features + axes[i*2, j].pie( + perc_levels_stable, + pctdistance=0.6, + startangle=120, + radius=1.1, + rotatelabels=True, + textprops={'fontsize': 14, 'weight': 'bold'}, + colors=colors_sns_stable + ) + + # Title + axes[i*2, j].set_title(f'{level} - {modality} - Stable', fontsize=15) + + # Legends + legends = [f'{level} - {perc_levels_stable[idx]}' for idx, level in enumerate(level_names_stable)] + axes[i*2, j].legend(legends, loc='center left', bbox_to_anchor=(1, 0.5), prop={'size': 13}) + + # Plot pie chart of the final features selected + axes[i*2+1, j].pie( + perc_levels_final, + autopct= lambda p: '{:.1f}%'.format(p) if p > 0 else '', + pctdistance=0.6, + startangle=120, + radius=1.1, + rotatelabels=True, + textprops={'fontsize': 14, 'weight': 'bold'}, + colors=colors_sns, + hatch=hatch) + + # Title + axes[i*2+1, j].set_title(f'{level} - {modality} - Fianl 10', fontsize=15) + + # Legend + axes[i*2+1, j].legend(levels_names, loc='center left', bbox_to_anchor=(1, 0.5), prop={'size': 13}) + + # Add legend + plt.tight_layout() + plt.subplots_adjust(top=0.9) + + if title: + fig.suptitle(title, fontsize=20) + else: + fig.suptitle(f'{experiment}: FDA breakdown per level', fontsize=20) + + # Save the heatmap + if save: + if title: + plt.savefig(path_experiments / f'{title}.png') + else: + plt.savefig(path_experiments / f'{experiment}_fda_features.png') + else: + plt.show() + + def plot_feature_analysis( + self, + path_experiments: Path, + experiment: str, + levels: List, + modalities: List = [], + title: str = None, + save: bool = False + ) -> None: + """ + This function plots a pie chart of the percentage of the final features used to train the model per radiomics level. + + Args: + path_experiments (Path): Path to the folder containing the experiments. + experiment (str): Name of the experiment to plot. Will be used to find the results. + levels (List): List of radiomics levels to include in plot. For example: ['morph', 'intensity']. + modalities (List, optional): List of imaging modalities to include in the plot. Defaults to []. + title(str, optional): Title and name used to save the plot. Defaults to None. + save (bool, optional): Whether to save the plot. Defaults to False. + + Returns: + None. + """ + # Levels names + levels_names = [ + 'Morphology', + 'Intensity', + 'Texture', + 'Linear filters - Intensity', + 'Linear filters - Texture', + 'Textural filters - Intensity', + 'Textural filters - Texture' + ] + + # Initialization + colors_sns = sns.color_palette("pastel", n_colors=5) + colors_sns.insert(3, colors_sns[3]) + colors_sns.insert(5, colors_sns[-1]) + hatch = ['', '', '', '..', '//', '..', '//'] + + # Set hatches color + plt.rcParams['hatch.color'] = 'white' + + # Create mutliple plots for the pie charts + fig, axes = plt.subplots(len(modalities), len(levels), figsize=(15, 10)) + + # Load the models resutls + for i, modality in enumerate(modalities): + for j, level in enumerate(levels): + exp_full_name = 'learn__' + experiment + '_' + level + '_' + modality + if 'feature_importance_analysis.json' in os.listdir(path_experiments / exp_full_name): + fa_dict = load_json(path_experiments / exp_full_name / 'feature_importance_analysis.json') + else: + fa_dict = feature_imporance_analysis(path_experiments / exp_full_name) + + # Extract percentage of features per level + perc_levels = np.round(self.__count_percentage_levels(fa_dict), 2) + + # Plot the pie chart of percentages for the final features + if len(modalities) > 1: + axes[i, j].pie( + perc_levels, + autopct= lambda p: '{:.1f}%'.format(p) if p > 0 else '', + pctdistance=0.8, + startangle=120, + radius=1.3, + rotatelabels=True, + textprops={'fontsize': 14, 'weight': 'bold'}, + colors=colors_sns, + hatch=hatch) + axes[i, j].set_title(f'{level} - {modality}', fontsize=15) + else: + axes[j].pie( + perc_levels, + autopct= lambda p: '{:.1f}%'.format(p) if p > 0 else '', + pctdistance=0.8, + startangle=120, + radius=1.3, + rotatelabels=True, + textprops={'fontsize': 14, 'weight': 'bold'}, + colors=colors_sns, + hatch=hatch) + axes[j].set_title(f'{level} - {modality}', fontsize=15) + + # Add legend + plt.legend(levels_names, loc='center left', bbox_to_anchor=(1, 0.5), prop={'size': 15}) + plt.tight_layout() + + # Add title + if title: + fig.suptitle(title, fontsize=20) + else: + fig.suptitle(f'{experiment}: % of selected features per level', fontsize=20) + + # Save the heatmap + if save: + if title: + plt.savefig(path_experiments / f'{title}.png') + else: + plt.savefig(path_experiments / f'{experiment}_percentage_features.png') + else: + plt.show() + + def plot_original_level_tree( + self, + path_experiments: Path, + experiment: str, + level: str, + modalities: list, + initial_width: float = 4, + lines_weight: float = 1, + title: str = None, + figsize: tuple = (12,10), + ) -> None: + """ + Plots a tree explaining the impact of features in the original radiomics complexity level. + + Args: + path_experiments (Path): Path to the folder containing the experiments. + experiment (str): Name of the experiment to plot. Will be used to find the results. + level (List): Radiomics complexity level to use for the plot. + modalities (List, optional): List of imaging modalities to include in the plot. Defaults to []. + initial_width (float, optional): Initial width of the lines. Defaults to 1. For aesthetic purposes. + lines_weight (float, optional): Weight applied to the lines of the tree. Defaults to 2. For aesthetic purposes. + title(str, optional): Title and name used to save the plot. Defaults to None. + figsize(tuple, optional): Size of the figure. Defaults to (20, 10). + + Returns: + None. + """ + # Fill tree data for each modality + for modality in modalities: + # Initialization + selected_feat_color = 'limegreen' + optimal_lvl_color = 'darkorange' + + # Initialization - outcome - levels + styles_outcome_levels = ["dashed"] * 3 + colors_outcome_levels = ["black"] * 3 + width_outcome_levels = [initial_width] * 3 + + # Initialization - original - sublevels + styles_original_levels = ["dashed"] * 3 + colors_original_levels = ["black"] * 3 + width_original_levels = [initial_width] * 3 + + # Initialization - texture-families + styles_texture_families = ["dashed"] * 6 + colors_texture_families = ["black"] * 6 + width_texture_families = [initial_width] * 6 + families_names = ["glcm", "ngtdm", "ngldm", "glrlm", "gldzm", "glszm"] + + # Get feature importance dict + exp_full_name = 'learn__' + experiment + '_' + level + '_' + modality + if 'feature_importance_analysis.json' in os.listdir(path_experiments / exp_full_name): + fa_dict = load_json(path_experiments / exp_full_name / 'feature_importance_analysis.json') + else: + fa_dict = feature_imporance_analysis(path_experiments / exp_full_name) + + # Organize data + feature_data = { + 'features': list(fa_dict.keys()), + 'mean_importance': [fa_dict[feature]['importance_mean'] for feature in fa_dict.keys()], + } + + # Convert sample to df + df = pd.DataFrame(feature_data) + + # Apply weight to the lines + df['final_coefficient'] = df['mean_importance'] + + # Normalize the final coefficients between 0 and 1 + df['final_coefficient'] = (df['final_coefficient'] - df['final_coefficient'].min()) \ + / (df['final_coefficient'].max() - df['final_coefficient'].min()) + + # Applying the lines weight + df['final_coefficient'] *= lines_weight + + # Assign complexity level to each feature + for i, row in df['features'].items(): + level_name = row.split('__')[1].lower() + family_name = row.split('__')[2].lower() + + # Morph + if level_name.startswith('morph'): + # Update outcome-original connection + styles_outcome_levels[0] = "solid" + colors_outcome_levels[0] = selected_feat_color + width_outcome_levels[0] += df['final_coefficient'][i] + + # Update original-morph connection + styles_original_levels[0] = "solid" + colors_original_levels[0] = selected_feat_color + width_original_levels[0] += df['final_coefficient'][i] + + # Intensity + elif level_name.startswith('intensity'): + # Update outcome-original connection + styles_outcome_levels[0] = "solid" + colors_outcome_levels[0] = selected_feat_color + width_outcome_levels[0] += df['final_coefficient'][i] + + # Update original-int connection + styles_original_levels[1] = "solid" + colors_original_levels[1] = selected_feat_color + width_original_levels[1] += df['final_coefficient'][i] + + # Texture + elif level_name.startswith('texture'): + # Update outcome-original connection + styles_outcome_levels[0] = "solid" + colors_outcome_levels[0] = selected_feat_color + width_outcome_levels[0] += df['final_coefficient'][i] + + # Update original-texture connection + styles_original_levels[2] = "solid" + colors_original_levels[2] = selected_feat_color + width_original_levels[2] += df['final_coefficient'][i] + + # Determine the most important level + index_best_level = np.argmax(width_outcome_levels) + colors_outcome_levels[index_best_level] = optimal_lvl_color + + # Update color for the best sub-level + colors_original_levels[np.argmax(width_original_levels)] = optimal_lvl_color + + # If texture features are the optimal + if np.argmax(width_original_levels) == 2: + for i, row in df['features'].items(): + level_name = row.split('__')[1].lower() + family_name = row.split('__')[2].lower() + + # Update texture-families connection + if level_name.startswith('texture'): + if family_name.startswith('_glcm'): + styles_texture_families[0] = "solid" + colors_texture_families[0] = selected_feat_color + width_texture_families[0] += df['final_coefficient'][i] + elif family_name.startswith('_ngtdm'): + styles_texture_families[1] = "solid" + colors_texture_families[1] = selected_feat_color + width_texture_families[1] += df['final_coefficient'][i] + elif family_name.startswith('_ngldm'): + styles_texture_families[2] = "solid" + colors_texture_families[2] = selected_feat_color + width_texture_families[2] += df['final_coefficient'][i] + elif family_name.startswith('_glrlm'): + styles_texture_families[3] = "solid" + colors_texture_families[3] = selected_feat_color + width_texture_families[3] += df['final_coefficient'][i] + elif family_name.startswith('_gldzm'): + styles_texture_families[4] = "solid" + colors_texture_families[4] = selected_feat_color + width_texture_families[4] += df['final_coefficient'][i] + elif family_name.startswith('_glszm'): + styles_texture_families[5] = "solid" + colors_texture_families[5] = selected_feat_color + width_texture_families[5] += df['final_coefficient'][i] + else: + raise ValueError(f'Family of the feature {family_name} not recognized') + + # Update color + colors_texture_families[np.argmax(width_texture_families)] = optimal_lvl_color + + # Find best texture family to continue path + best_family_name = "" + index_best_family = np.argmax(width_texture_families) + best_family_name = families_names[index_best_family] + features_names = texture_features_all[index_best_family] + + # Update texture-families-features connection + width_texture_families_feature = [initial_width] * len(features_names) + colors_texture_families_feature = ["black"] * len(features_names) + styles_texture_families_feature = ["dashed"] * len(features_names) + for i, row in df['features'].items(): + level_name = row.split('__')[1].lower() + family_name = row.split('__')[2].lower() + feature_name = row.split('__') + if level_name.startswith('texture') and family_name.startswith('_' + best_family_name): + for feature in features_names: + if feature in feature_name: + colors_texture_families_feature[features_names.index(feature)] = selected_feat_color + styles_texture_families_feature[features_names.index(feature)] = "solid" + width_texture_families_feature[features_names.index(feature)] += df['final_coefficient'][i] + break + + # Update color for the best texture family + colors_texture_families_feature[np.argmax(width_texture_families_feature)] = optimal_lvl_color + + # For esthetic purposes + experiment_sep = experiment.replace('_', '\n') + + # Design the graph + G = nx.Graph() + + # Original level + G.add_edge(experiment_sep, 'Original', color=optimal_lvl_color, width=np.sum(width_original_levels), style="solid") + if styles_original_levels[0] == "solid": + G.add_edge('Original', 'Morph', color=colors_original_levels[0], width=width_original_levels[0], style=styles_original_levels[0]) + if styles_original_levels[1] == "solid": + G.add_edge('Original', 'Int', color=colors_original_levels[1], width=width_original_levels[1], style=styles_original_levels[1]) + if styles_original_levels[2] == "solid": + G.add_edge('Original', 'Text', color=colors_original_levels[2], width=width_original_levels[2], style=styles_original_levels[2]) + + # Continue path to the textural features if they are the optimal level + if np.argmax(width_original_levels) == 2: + # Put best level index in the middle + nodes_order = [0, 1, 2, 3, 4, 5] + nodes_order.insert(3, nodes_order.pop(nodes_order.index(np.argmax(width_texture_families)))) + + # Reorder nodes names + nodes_names = ['GLCM', 'NGTDM', 'NGLDM', 'GLRLM', 'GLDZM', 'GLSZM'] + nodes_names = [nodes_names[i] for i in nodes_order] + colors_texture_families = [colors_texture_families[i] for i in nodes_order] + width_texture_families = [width_texture_families[i] for i in nodes_order] + styles_texture_families = [styles_texture_families[i] for i in nodes_order] + + # Add texture features families nodes + for idx, node_name in enumerate(nodes_names): + G.add_edge( + 'Text', + node_name, + color=colors_texture_families[idx], + width=width_texture_families[idx], + style=styles_texture_families[idx] + ) + + # Continue path to the textural features + best_node_name = best_family_name.upper() + for idx, feature in enumerate(features_names): + G.add_edge( + best_node_name, + feature.replace('_', '\n'), + color=colors_texture_families_feature[idx], + width=width_texture_families_feature[idx], + style=styles_texture_families_feature[idx] + ) + + # Graph layout + pos = graphviz_layout(G, root=experiment_sep, prog="dot") + + # Create the plot: figure and axis + fig = plt.figure(figsize=figsize, dpi=300) + ax = fig.add_subplot(1, 1, 1) + + # Get the attributes of the edges + colors = nx.get_edge_attributes(G,'color').values() + widths = nx.get_edge_attributes(G,'width').values() + style = nx.get_edge_attributes(G,'style').values() + + # Draw the graph + cmap = [to_rgba('b')] * len(pos) + nx.draw( + G, + pos=pos, + ax=ax, + edge_color=colors, + width=list(widths), + with_labels=True, + node_color=cmap, + node_size=1700, + font_size=8, + font_color='white', + font_weight='bold', + node_shape='o', + style=style + ) + + # Create custom legend + custom_legends = [ + Line2D([0], [0], color=selected_feat_color, lw=4, linestyle='solid', label=f'Selected (thickness reflects impact)'), + Line2D([0], [0], color='black', lw=4, linestyle='dashed', label='Not selected'), + Line2D([0], [0], color=optimal_lvl_color, lw=4, linestyle='solid', label='Path with highest impact') + ] + + # Update keys according to the optimal level + figure_keys = [] + if styles_original_levels[0] == "solid": + figure_keys.append(mpatches.Patch(color='none', label='Morph: Morphological')) + if styles_original_levels[1] == "solid": + figure_keys.append(mpatches.Patch(color='none', label='Int: Intensity')) + if styles_original_levels[2] == "solid": + figure_keys.append(mpatches.Patch(color='none', label='Text: Textural')) + + # Set title + if title: + ax.set_title(title, fontsize=20) + else: + ax.set_title( + f'Radiomics explanation tree - Original level:'\ + + f'\nExperiment: {experiment}'\ + + f'\nLevel: {level}'\ + + f'\nModality: {modality}', fontsize=20 + ) + + # Apply the custom legend + legend = plt.legend(handles=custom_legends, loc='upper right', fontsize=15, frameon=True, title = "Legend") + legend.get_frame().set_edgecolor('black') + legend.get_frame().set_linewidth(2.0) + + # Abbrevations legend + legend_keys = plt.legend(handles=figure_keys, loc='center right', fontsize=15, frameon=True, title = "Abbreviations", handlelength=0) + legend_keys.get_frame().set_edgecolor('black') + legend_keys.get_frame().set_linewidth(2.0) + + # Options legend + plt.gca().add_artist(legend_keys) + plt.gca().add_artist(legend) + + # Tight layout + fig.tight_layout() + + # Save the plot (Mandatory, since the plot is not well displayed on matplotlib) + fig.savefig(path_experiments / f'Original_level_{experiment}_{level}_{modality}_explanation_tree.png', dpi=300) + + def plot_lf_level_tree( + self, + path_experiments: Path, + experiment: str, + level: str, + modalities: list, + initial_width: float = 4, + lines_weight: float = 1, + title: str = None, + figsize: tuple = (12,10), + ) -> None: + """ + Plots a tree explaining the impact of features in the linear filters radiomics complexity level. + + Args: + path_experiments (Path): Path to the folder containing the experiments. + experiment (str): Name of the experiment to plot. Will be used to find the results. + level (List): Radiomics complexity level to use for the plot. + modalities (List, optional): List of imaging modalities to include in the plot. Defaults to []. + initial_width (float, optional): Initial width of the lines. Defaults to 1. For aesthetic purposes. + lines_weight (float, optional): Weight applied to the lines of the tree. Defaults to 2. For aesthetic purposes. + title(str, optional): Title and name used to save the plot. Defaults to None. + figsize(tuple, optional): Size of the figure. Defaults to (20, 10). + + Returns: + None. + """ + # Fill tree data + for modality in modalities: + # Initialization + selected_feat_color = 'limegreen' + optimal_lvl_color = 'darkorange' + + # Initialization - outcome - levels + styles_outcome_levels = ["dashed"] * 3 + colors_outcome_levels = ["black"] * 3 + width_outcome_levels = [initial_width] * 3 + + # Initialization - lf - sublevels + filters_names = ['mean', 'log', 'laws', 'gabor', 'coif'] + styles_lf_levels = ["dashed"] * 2 + colors_lf_levels = ["black"] * 2 + width_lf_levels = [initial_width] * 2 + + # Initialization - texture-families + styles_texture_families = ["dashed"] * 6 + colors_texture_families = ["black"] * 6 + width_texture_families = [initial_width] * 6 + families_names = ["glcm", "ngtdm", "ngldm", "glrlm", "gldzm", "glszm"] + + # Get feature importance dict + exp_full_name = 'learn__' + experiment + '_' + level + '_' + modality + if 'feature_importance_analysis.json' in os.listdir(path_experiments / exp_full_name): + fa_dict = load_json(path_experiments / exp_full_name / 'feature_importance_analysis.json') + else: + fa_dict = feature_imporance_analysis(path_experiments / exp_full_name) + + # Organize data + feature_data = { + 'features': list(fa_dict.keys()), + 'mean_importance': [fa_dict[feature]['importance_mean'] for feature in fa_dict.keys()], + } + + # Convert sample to df + df = pd.DataFrame(feature_data) + + # Apply weight to the lines + df['final_coefficient'] = df['mean_importance'] + + # Normalize the final coefficients between 0 and 1 + df['final_coefficient'] = (df['final_coefficient'] - df['final_coefficient'].min()) \ + / (df['final_coefficient'].max() - df['final_coefficient'].min()) + + # Applying the lines weight + df['final_coefficient'] *= lines_weight + + # Finding linear filters features and updating the connections + for i, row in df['features'].items(): + level_name = row.split('__')[1].lower() + family_name = row.split('__')[2].lower() + + # Linear filters + if level_name.startswith('mean') \ + or level_name.startswith('log') \ + or level_name.startswith('laws') \ + or level_name.startswith('gabor') \ + or level_name.startswith('wavelet') \ + or level_name.startswith('coif'): + + # Update outcome-original connection + styles_outcome_levels[1] = "solid" + colors_outcome_levels[1] = selected_feat_color + width_outcome_levels[1] += df['final_coefficient'][i] + + # Find the best performing filter + width_lf_filters = [initial_width] * 5 + for i, row in df['features'].items(): + level_name = row.split('__')[1].lower() + family_name = row.split('__')[2].lower() + if level_name.startswith('mean'): + width_lf_filters[0] += df['final_coefficient'][i] + elif level_name.startswith('log'): + width_lf_filters[1] += df['final_coefficient'][i] + elif level_name.startswith('laws'): + width_lf_filters[2] += df['final_coefficient'][i] + elif level_name.startswith('gabor'): + width_lf_filters[3] += df['final_coefficient'][i] + elif level_name.startswith('wavelet'): + width_lf_filters[4] += df['final_coefficient'][i] + elif level_name.startswith('coif'): + width_lf_filters[4] += df['final_coefficient'][i] + + # Get best filter + index_best_filter = np.argmax(width_lf_filters) + best_filter = filters_names[index_best_filter] + + # Seperate intensity and texture then update the connections + for i, row in df['features'].items(): + level_name = row.split('__')[1].lower() + family_name = row.split('__')[2].lower() + if level_name.startswith(best_filter): + if family_name.startswith('_int'): + width_lf_levels[0] += df['final_coefficient'][i] + elif family_name.startswith(tuple(['_glcm', '_gldzm', '_glrlm', '_glszm', '_ngtdm', '_ngldm'])): + width_lf_levels[1] += df['final_coefficient'][i] + + # If Texture features are more impacful, update the connections + if width_lf_levels[1] > width_lf_levels[0]: + colors_lf_levels[1] = optimal_lvl_color + styles_lf_levels[1] = "solid" + + # Update lf-texture-families connection + for i, row in df['features'].items(): + level_name = row.split('__')[1].lower() + family_name = row.split('__')[2].lower() + if not family_name.startswith('_int') and level_name.startswith(best_filter): + if family_name.startswith('_glcm'): + styles_texture_families[0] = "solid" + colors_texture_families[0] = selected_feat_color + width_texture_families[0] += df['final_coefficient'][i] + elif family_name.startswith('_ngtdm'): + styles_texture_families[1] = "solid" + colors_texture_families[1] = selected_feat_color + width_texture_families[1] += df['final_coefficient'][i] + elif family_name.startswith('_ngldm'): + styles_texture_families[2] = "solid" + colors_texture_families[2] = selected_feat_color + width_texture_families[2] += df['final_coefficient'][i] + elif family_name.startswith('_glrlm'): + styles_texture_families[3] = "solid" + colors_texture_families[3] = selected_feat_color + width_texture_families[3] += df['final_coefficient'][i] + elif family_name.startswith('_gldzm'): + styles_texture_families[4] = "solid" + colors_texture_families[4] = selected_feat_color + width_texture_families[4] += df['final_coefficient'][i] + elif family_name.startswith('_glszm'): + styles_texture_families[5] = "solid" + colors_texture_families[5] = selected_feat_color + width_texture_families[5] += df['final_coefficient'][i] + else: + raise ValueError(f'Family of the feature {family_name} not recognized') + + # Update color + colors_texture_families[np.argmax(width_texture_families)] = optimal_lvl_color + + else: + colors_lf_levels[0] = optimal_lvl_color + styles_lf_levels[0] = "solid" + + # If texture features are the optimal level, continue path + if width_lf_levels[1] > width_lf_levels[0]: + + # Get best texture family + best_family_name = "" + index_best_family = np.argmax(width_texture_families) + best_family_name = families_names[index_best_family] + features_names = texture_features_all[index_best_family] + + # Update texture-families-features connection + width_texture_families_feature = [initial_width] * len(features_names) + colors_texture_families_feature = ["black"] * len(features_names) + styles_texture_families_feature = ["dashed"] * len(features_names) + for i, row in df['features'].items(): + level_name = row.split('__')[1].lower() + family_name = row.split('__')[2].lower() + feature_name = row.split('__') + if family_name.startswith('_' + best_family_name) and level_name.startswith(best_filter): + for feature in features_names: + if feature in feature_name: + colors_texture_families_feature[features_names.index(feature)] = selected_feat_color + styles_texture_families_feature[features_names.index(feature)] = "solid" + width_texture_families_feature[features_names.index(feature)] += df['final_coefficient'][i] + break + + # Update color for the best texture family + colors_texture_families_feature[np.argmax(width_texture_families_feature)] = optimal_lvl_color + + # For esthetic purposes + experiment_sep = experiment.replace('_', '\n') + + # Design the graph + G = nx.Graph() + + # Linear filters level + G.add_edge(experiment_sep, 'LF', color=optimal_lvl_color, width=np.sum(width_lf_filters), style=styles_outcome_levels[1]) + + # Add best filter + best_filter = best_filter.replace('_', '\n') + G.add_edge('LF', best_filter.upper(), color=optimal_lvl_color, width=width_lf_filters[index_best_filter], style="solid") + + # Int or Text + if width_lf_levels[1] <= width_lf_levels[0]: + G.add_edge(best_filter.upper(), 'LF\nInt', color=colors_lf_levels[0], width=width_lf_levels[0], style=styles_lf_levels[0]) + else: + G.add_edge(best_filter.upper(), 'LF\nText', color=colors_lf_levels[1], width=width_lf_levels[1], style=styles_lf_levels[1]) + + # Put best level index in the middle + nodes_order = [0, 1, 2, 3, 4, 5] + nodes_order.insert(3, nodes_order.pop(nodes_order.index(np.argmax(width_texture_families)))) + + # Reorder nodes names + nodes_names = ['LF\nGLCM', 'LF\nNGTDM', 'LF\nNGLDM', 'LF\nGLRLM', 'LF\nGLDZM', 'LF\nGLSZM'] + nodes_names = [nodes_names[i] for i in nodes_order] + colors_texture_families = [colors_texture_families[i] for i in nodes_order] + width_texture_families = [width_texture_families[i] for i in nodes_order] + styles_texture_families = [styles_texture_families[i] for i in nodes_order] + + # Add texture features families nodes + for idx, node_name in enumerate(nodes_names): + G.add_edge( + 'LF\nText', + node_name, + color=colors_texture_families[idx], + width=width_texture_families[idx], + style=styles_texture_families[idx] + ) + + # Continue path to the textural features + best_node_name = f'LF\n{best_family_name.upper()}' + for idx, feature in enumerate(features_names): + G.add_edge( + best_node_name, + feature.replace('_', '\n'), + color=colors_texture_families_feature[idx], + width=width_texture_families_feature[idx], + style=styles_texture_families_feature[idx] + ) + + # Graph layout + pos = graphviz_layout(G, root=experiment_sep, prog="dot") + + # Create the plot: figure and axis + fig = plt.figure(figsize=figsize, dpi=300) + ax = fig.add_subplot(1, 1, 1) + + # Get the attributes of the edges + colors = nx.get_edge_attributes(G,'color').values() + widths = nx.get_edge_attributes(G,'width').values() + style = nx.get_edge_attributes(G,'style').values() + + # Draw the graph + cmap = [to_rgba('b')] * len(pos) + nx.draw( + G, + pos=pos, + ax=ax, + edge_color=colors, + width=list(widths), + with_labels=True, + node_color=cmap, + node_size=1700, + font_size=8, + font_color='white', + font_weight='bold', + node_shape='o', + style=style + ) + + # Create custom legend + custom_legends = [ + Line2D([0], [0], color=selected_feat_color, lw=4, linestyle='solid', label=f'Selected (thickness reflects impact)'), + Line2D([0], [0], color='black', lw=4, linestyle='dashed', label='Not selected'), + Line2D([0], [0], color=optimal_lvl_color, lw=4, linestyle='solid', label='Path with highest impact') + ] + + # Update keys according to the optimal level + figure_keys = [] + figure_keys.append(mpatches.Patch(color='none', label='LF: Linear Filters')) + if width_lf_levels[1] > width_lf_levels[0]: + figure_keys.append(mpatches.Patch(color='none', label='Text: Textural')) + else: + figure_keys.append(mpatches.Patch(color='none', label='Int: Intensity')) + + # Set title + if title: + ax.set_title(title, fontsize=20) + else: + ax.set_title( + f'Radiomics explanation tree:'\ + + f'\nExperiment: {experiment}'\ + + f'\nLevel: {level}'\ + + f'\nModality: {modality}', fontsize=20 + ) + + # Apply the custom legend + legend = plt.legend(handles=custom_legends, loc='upper right', fontsize=15, frameon=True, title = "Legend") + legend.get_frame().set_edgecolor('black') + legend.get_frame().set_linewidth(2.0) + + # Abbrevations legend + legend_keys = plt.legend(handles=figure_keys, loc='center right', fontsize=15, frameon=True, title = "Abbreviations", handlelength=0) + legend_keys.get_frame().set_edgecolor('black') + legend_keys.get_frame().set_linewidth(2.0) + + # Options legend + plt.gca().add_artist(legend_keys) + plt.gca().add_artist(legend) + + # Tight layout + fig.tight_layout() + + # Save the plot (Mandatory, since the plot is not well displayed on matplotlib) + fig.savefig(path_experiments / f'LF_level_{experiment}_{level}_{modality}_explanation_tree.png', dpi=300) + + def plot_tf_level_tree( + self, + path_experiments: Path, + experiment: str, + level: str, + modalities: list, + initial_width: float = 4, + lines_weight: float = 1, + title: str = None, + figsize: tuple = (12,10), + ) -> None: + """ + Plots a tree explaining the impact of features in the textural filters radiomics complexity level. + + Args: + path_experiments (Path): Path to the folder containing the experiments. + experiment (str): Name of the experiment to plot. Will be used to find the results. + level (List): Radiomics complexity level to use for the plot. + modalities (List, optional): List of imaging modalities to include in the plot. Defaults to []. + initial_width (float, optional): Initial width of the lines. Defaults to 1. For aesthetic purposes. + lines_weight (float, optional): Weight applied to the lines of the tree. Defaults to 2. For aesthetic purposes. + title(str, optional): Title and name used to save the plot. Defaults to None. + figsize(tuple, optional): Size of the figure. Defaults to (20, 10). + + Returns: + None. + """ + # Fill tree data + for modality in modalities: + # Initialization + selected_feat_color = 'limegreen' + optimal_lvl_color = 'darkorange' + + # Initialization - outcome - levels + styles_outcome_levels = ["dashed"] * 3 + colors_outcome_levels = ["black"] * 3 + width_outcome_levels = [initial_width] * 3 + + # Initialization - tf - sublevels + styles_tf_levels = ["dashed"] * 2 + colors_tf_levels = ["black"] * 2 + width_tf_levels = [initial_width] * 2 + + # Initialization - tf - best filter + width_tf_filters = [initial_width] * len(glcm_features_names) + + # Initialization - texture-families + styles_texture_families = ["dashed"] * 6 + colors_texture_families = ["black"] * 6 + width_texture_families = [initial_width] * 6 + families_names = ["glcm", "ngtdm", "ngldm", "glrlm", "gldzm", "glszm"] + + # Get feature importance dict + exp_full_name = 'learn__' + experiment + '_' + level + '_' + modality + if 'feature_importance_analysis.json' in os.listdir(path_experiments / exp_full_name): + fa_dict = load_json(path_experiments / exp_full_name / 'feature_importance_analysis.json') + else: + fa_dict = feature_imporance_analysis(path_experiments / exp_full_name) + + # Organize data + feature_data = { + 'features': list(fa_dict.keys()), + 'mean_importance': [fa_dict[feature]['importance_mean'] for feature in fa_dict.keys()], + } + + # Convert sample to df + df = pd.DataFrame(feature_data) + + # Apply weight to the lines + df['final_coefficient'] = df['mean_importance'] + + # Normalize the final coefficients between 0 and 1 + df['final_coefficient'] = (df['final_coefficient'] - df['final_coefficient'].min()) \ + / (df['final_coefficient'].max() - df['final_coefficient'].min()) + + # Applying the lines weight + df['final_coefficient'] *= lines_weight + + # Filling the lines data for textural filters features and updating the connections + for i, row in df['features'].items(): + level_name = row.split('__')[1].lower() + family_name = row.split('__')[2].lower() + + # Textural filters + if level_name.startswith('glcm'): + # Update outcome-original connection + styles_outcome_levels[2] = "solid" + colors_outcome_levels[2] = optimal_lvl_color + width_outcome_levels[2] += df['final_coefficient'][i] + + # Update tf-best filter connection + for feature in glcm_features_names: + if feature + '__' in row: + width_tf_filters[glcm_features_names.index(feature)] += df['final_coefficient'][i] + break + + # Get best filter + index_best_filter = np.argmax(width_tf_filters) + best_filter = glcm_features_names[index_best_filter] + + # Seperate intensity and texture then update the connections + for i, row in df['features'].items(): + level_name = row.split('__')[1].lower() + family_name = row.split('__')[2].lower() + if level_name.startswith('glcm') and best_filter + '__' in row: + if family_name.startswith('_int'): + width_tf_levels[0] += df['final_coefficient'][i] + elif family_name.startswith(tuple(['_glcm', '_gldzm', '_glrlm', '_glszm', '_ngtdm', '_ngldm'])): + width_tf_levels[1] += df['final_coefficient'][i] + + # If Texture features are more impacful, update the connections + if width_tf_levels[1] > width_tf_levels[0]: + colors_tf_levels[1] = optimal_lvl_color + styles_tf_levels[1] = "solid" + + # Update tf-texture-families connection + for i, row in df['features'].items(): + level_name = row.split('__')[1].lower() + family_name = row.split('__')[2].lower() + if level_name.startswith('glcm') and best_filter + '__' in row: + if family_name.startswith('_glcm'): + styles_texture_families[0] = "solid" + colors_texture_families[0] = selected_feat_color + width_texture_families[0] += df['final_coefficient'][i] + elif family_name.startswith('_ngtdm'): + styles_texture_families[1] = "solid" + colors_texture_families[1] = selected_feat_color + width_texture_families[1] += df['final_coefficient'][i] + elif family_name.startswith('_ngldm'): + styles_texture_families[2] = "solid" + colors_texture_families[2] = selected_feat_color + width_texture_families[2] += df['final_coefficient'][i] + elif family_name.startswith('_glrlm'): + styles_texture_families[3] = "solid" + colors_texture_families[3] = selected_feat_color + width_texture_families[3] += df['final_coefficient'][i] + elif family_name.startswith('_gldzm'): + styles_texture_families[4] = "solid" + colors_texture_families[4] = selected_feat_color + width_texture_families[4] += df['final_coefficient'][i] + elif family_name.startswith('_glszm'): + styles_texture_families[5] = "solid" + colors_texture_families[5] = selected_feat_color + width_texture_families[5] += df['final_coefficient'][i] + + # Get best texture family + best_family_name = "" + index_best_family = np.argmax(width_texture_families) + best_family_name = families_names[index_best_family] + features_names = texture_features_all[index_best_family] + + # Update texture-families-features connection + width_texture_families_feature = [initial_width] * len(features_names) + colors_texture_families_feature = ["black"] * len(features_names) + styles_texture_families_feature = ["dashed"] * len(features_names) + for i, row in df['features'].items(): + level_name = row.split('__')[1].lower() + family_name = row.split('__')[2].lower() + feature_name = row.split('__') + if level_name.startswith('glcm') and family_name.startswith('_' + best_family_name) and best_filter + '__' in row: + for feature in features_names: + if feature in feature_name: + colors_texture_families_feature[features_names.index(feature)] = selected_feat_color + styles_texture_families_feature[features_names.index(feature)] = "solid" + width_texture_families_feature[features_names.index(feature)] += df['final_coefficient'][i] + break + + # Update color for the best texture family + colors_texture_families_feature[np.argmax(width_texture_families_feature)] = optimal_lvl_color + + # Update color + colors_texture_families[np.argmax(width_texture_families)] = optimal_lvl_color + else: + colors_tf_levels[0] = optimal_lvl_color + styles_tf_levels[0] = "solid" + + # For esthetic purposes + experiment_sep = experiment.replace('_', '\n') + + # Design the graph + G = nx.Graph() + G.add_edge(experiment_sep, 'TF', color=colors_outcome_levels[2], width=width_outcome_levels[2], style=styles_outcome_levels[2]) + + # Add best filter + best_filter = best_filter.replace('_', '\n') + G.add_edge('TF', best_filter.upper(), color=optimal_lvl_color, width=width_tf_filters[index_best_filter], style="solid") + + # Check which level is the best (intensity or texture) + if width_tf_levels[1] <= width_tf_levels[0]: + G.add_edge(best_filter.upper(), 'TF\nInt', color=colors_tf_levels[0], width=width_tf_levels[0], style=styles_tf_levels[0]) + else: + G.add_edge(best_filter.upper(), 'TF\nText', color=colors_tf_levels[1], width=width_tf_levels[1], style=styles_tf_levels[1]) + + # Put best level index in the middle + nodes_order = [0, 1, 2, 3, 4, 5] + nodes_order.insert(3, nodes_order.pop(nodes_order.index(np.argmax(width_texture_families)))) + + # Reorder nodes names + nodes_names = ['TF\nGLCM', 'TF\nNGTDM', 'TF\nNGLDM', 'TF\nGLRLM', 'TF\nGLDZM', 'TF\nGLSZM'] + nodes_names = [nodes_names[i] for i in nodes_order] + colors_texture_families = [colors_texture_families[i] for i in nodes_order] + width_texture_families = [width_texture_families[i] for i in nodes_order] + styles_texture_families = [styles_texture_families[i] for i in nodes_order] + + # Add texture features families nodes + for idx, node_names in enumerate(nodes_names): + G.add_edge( + 'TF\nText', + node_names, + color=colors_texture_families[idx], + width=width_texture_families[idx], + style=styles_texture_families[idx] + ) + + # Continue path to the textural features + best_node_name = f'TF\n{best_family_name.upper()}' + for idx, feature in enumerate(features_names): + G.add_edge( + best_node_name, + feature.replace('_', '\n'), + color=colors_texture_families_feature[idx], + width=width_texture_families_feature[idx], + style=styles_texture_families_feature[idx] + ) + + # Graph layout + pos = graphviz_layout(G, root=experiment_sep, prog="dot") + + # Create the plot: figure and axis + fig = plt.figure(figsize=figsize, dpi=300) + ax = fig.add_subplot(1, 1, 1) + + # Get the attributes of the edges + colors = nx.get_edge_attributes(G,'color').values() + widths = nx.get_edge_attributes(G,'width').values() + style = nx.get_edge_attributes(G,'style').values() + + # Draw the graph + cmap = [to_rgba('b')] * len(pos) + nx.draw( + G, + pos=pos, + ax=ax, + edge_color=colors, + width=list(widths), + with_labels=True, + node_color=cmap, + node_size=1700, + font_size=8, + font_color='white', + font_weight='bold', + node_shape='o', + style=style + ) + + # Create custom legend + custom_legends = [ + Line2D([0], [0], color=selected_feat_color, lw=4, linestyle='solid', label=f'Selected (thickness reflects impact)'), + Line2D([0], [0], color='black', lw=4, linestyle='dashed', label='Not selected') + ] + figure_keys = [] + + # Update keys according to the optimal level + figure_keys = [mpatches.Patch(color='none', label='TF: Linear Filters')] + if width_tf_levels[1] > width_tf_levels[0]: + figure_keys.append(mpatches.Patch(color='none', label='Text: Textural')) + else: + figure_keys.append(mpatches.Patch(color='none', label='Int: Intensity')) + + custom_legends.append( + Line2D([0], [0], color=optimal_lvl_color, lw=4, linestyle='solid', label='Path with highest impact') + ) + + # Set title + if title: + ax.set_title(title, fontsize=20) + else: + ax.set_title( + f'Radiomics explanation tree:'\ + + f'\nExperiment: {experiment}'\ + + f'\nLevel: {level}'\ + + f'\nModality: {modality}', fontsize=20 + ) + + # Apply the custom legend + legend = plt.legend(handles=custom_legends, loc='upper right', fontsize=15, frameon=True, title = "Legend") + legend.get_frame().set_edgecolor('black') + legend.get_frame().set_linewidth(2.0) + + # Abbrevations legend + legend_keys = plt.legend(handles=figure_keys, loc='center right', fontsize=15, frameon=True, title = "Abbreviations", handlelength=0) + legend_keys.get_frame().set_edgecolor('black') + legend_keys.get_frame().set_linewidth(2.0) + + # Options legend + plt.gca().add_artist(legend_keys) + plt.gca().add_artist(legend) + + # Tight layout + fig.tight_layout() + + # Save the plot (Mandatory, since the plot is not well displayed on matplotlib) + fig.savefig(path_experiments / f'TF_{experiment}_{level}_{modality}_explanation_tree.png', dpi=300) + + def to_json( + self, + response_train: list = None, + response_test: list = None, + response_holdout: list = None, + patients_train: list = None, + patients_test: list = None, + patients_holdout: list = None + ) -> dict: + """ + Creates a dictionary with the results of the model using the class attributes. + + Args: + response_train (list): List of machine learning model predictions for the training set. + response_test (list): List of machine learning model predictions for the test set. + patients_train (list): List of patients in the training set. + patients_test (list): List of patients in the test set. + patients_holdout (list): List of patients in the holdout set. + + Returns: + Dict: Dictionary with the the responses of the model and the patients used for training, testing and holdout. + """ + run_results = dict() + run_results[self.model_id] = self.model_dict + + # Training results info + run_results[self.model_id]['train'] = dict() + run_results[self.model_id]['train']['patients'] = patients_train + run_results[self.model_id]['train']['response'] = response_train.tolist() if response_train is not None else [] + + # Testing results info + run_results[self.model_id]['test'] = dict() + run_results[self.model_id]['test']['patients'] = patients_test + run_results[self.model_id]['test']['response'] = response_test.tolist() if response_test is not None else [] + + # Holdout results info + run_results[self.model_id]['holdout'] = dict() + run_results[self.model_id]['holdout']['patients'] = patients_holdout + run_results[self.model_id]['holdout']['response'] = response_holdout.tolist() if response_holdout is not None else [] + + # keep a copy of the results + self.results_dict = run_results + + return run_results diff --git a/MEDiml/learning/Stats.py b/MEDiml/learning/Stats.py new file mode 100644 index 0000000..5d5be3f --- /dev/null +++ b/MEDiml/learning/Stats.py @@ -0,0 +1,694 @@ +# Description: All the functions related to statistics (p-values, metrics, etc.) + +import os +from pathlib import Path +from typing import List, Tuple +import warnings + +import numpy as np +import pandas as pd +import scipy +from sklearn import metrics + +from MEDiml.utils.json_utils import load_json + + +class Stats: + """ + A class to perform statistical analysis on experiment results. + + This class provides methods to retrieve patient IDs, predictions, and metrics from experiment data, + as well as compute the p-values for model comparison using various methods. + + Args: + path_experiment (Path): Path to the folder containing the experiment data. + experiment (str): Name of the experiment. + levels (List): List of radiomics levels to analyze. + modalities (List): List of modalities to analyze. + + Attributes: + path_experiment (Path): Path to the folder containing the experiment data. + experiment (str): Name of the experiment. + levels (List): List of radiomics levels to analyze. + modalities (List): List of modalities to analyze. + """ + def __init__(self, path_experiment: Path, experiment: str = "", levels: List = [], modalities: List = []): + # Initialization + self.path_experiment = path_experiment + self.experiment = experiment + self.levels = levels + self.modalities = modalities + + # Safety assertion + self.__safety_assertion() + + def __get_models_dicts(self, split_idx: int) -> Path: + """ + Retrieves the models dictionaries for a given split. + + Args: + split_idx (int): Index of the split. + + Returns: + List: List of paths to the models dictionaries. + """ + # Get level and modality + if len(self.modalities) == 1: + # Load ground truths and predictions + path_json_1 = self.__get_path_json(self.levels[0], self.modalities[0], split_idx) + path_json_2 = self.__get_path_json(self.levels[1], self.modalities[0], split_idx) + else: + # Load ground truths and predictions + path_json_1 = self.__get_path_json(self.levels[0], self.modalities[0], split_idx) + path_json_2 = self.__get_path_json(self.levels[0], self.modalities[1], split_idx) + return path_json_1, path_json_2 + + def __safety_assertion(self): + """ + Asserts that the input parameters are correct. + """ + if len(self.modalities) == 1: + assert len(self.levels) == 2, \ + "For statistical analysis, the number of levels must be 2 for a single modality, or 1 for two modalities" + elif len(self.modalities) == 2: + assert len(self.levels) == 1, \ + "For statistical analysis, the number of levels must be 1 for two modalities, or 2 for a single modality" + else: + raise ValueError("The number of modalities must be 1 or 2") + + def __get_path_json(self, level: str, modality: str, split_idx: int) -> Path: + """ + Retrieves the path to the models dictionary for a given split. + + Args: + level (str): Radiomics level. + modality (str): Modality. + split_idx (int): Index of the split. + + Returns: + Path: Path to the models dictionary. + """ + return self.path_experiment / f'learn__{self.experiment}_{level}_{modality}' / f'test__{split_idx:03d}' / 'run_results.json' + + def __get_patients_and_predictions( + self, + split_idx: int + ) -> tuple: + """ + Retrieves patient IDs, predictions of both models for a given split. + + Args: + split_idx (int): Index of the split. + + Returns: + tuple: Tuple containing the patient IDs, predictions of the first model and predictions of the second model. + """ + # Get models dicts + path_json_1, path_json_2 = self.__get_models_dicts(split_idx) + + # Load models dicts + model_one = load_json(path_json_1) + model_two = load_json(path_json_2) + + # Get name models + name_model_one = list(model_one.keys())[0] + name_model_two = list(model_two.keys())[0] + + # Get predictions + predictions_one = np.array(model_one[name_model_one]['test']['response']) + predictions_one = np.reshape(predictions_one, (predictions_one.shape[0])).tolist() + predictions_two = np.array(model_two[name_model_two]['test']['response']) + predictions_two = np.reshape(predictions_two, (predictions_two.shape[0])).tolist() + + # Get patients ids + patients_ids_one = model_one[name_model_one]['test']['patients'] + patients_ids_two = model_two[name_model_two]['test']['patients'] + + # Check if the number of patients is the same + patients_delete = [] + if len(patients_ids_one) > len(patients_ids_two): + # Warn the user + warnings.warn("The number of patients is different for both models. Patients will be deleted to match the number of patients.") + + # Delete patients + for patient_id in patients_ids_one: + if patient_id not in patients_ids_two: + patients_delete.append(patient_id) + predictions_one.pop(patients_ids_one.index(patient_id)) + for patient in patients_delete: + patients_ids_one.remove(patient) + elif len(patients_ids_one) < len(patients_ids_two): + # Warn the user + warnings.warn("The number of patients is different for both models. Patients will be deleted to match the number of patients.") + + # Delete patients + for patient_id in patients_ids_two: + if patient_id not in patients_ids_one: + patients_delete.append(patient_id) + predictions_two.pop(patients_ids_two.index(patient_id)) + for patient in patients_delete: + patients_ids_two.remove(patient) + + # Check if the patient IDs are the same + if patients_ids_one != patients_ids_two: + raise ValueError("The patient IDs must be the same for both models") + + # Check if the number of predictions is the same + if len(predictions_one) != len(predictions_two): + raise ValueError("The number of predictions must be the same for both models") + + return patients_ids_one, predictions_one, predictions_two + + def __calc_pvalue(self, aucs: np.array, sigma: float) -> float: + """ + Computes p-values of the AUCs distribution. + + Args: + aucs(np.array): 1D array of AUCs. + sigma (flaot): AUC DeLong covariances + + Returns: + flaot: p-value of the AUCs. + """ + l = np.array([[1, -1]]) + z = np.abs(np.diff(aucs)) / np.sqrt(np.dot(np.dot(l, sigma), l.T)) + p_value = 2 * scipy.stats.norm.sf(z, loc=0, scale=1) + return p_value + + def __corrected_std(self, differences: np.array, n_train: int, n_test: int) -> float: + """ + Corrects standard deviation using Nadeau and Bengio's approach. + + Args: + differences (np.array): Vector containing the differences in the score metrics of two models. + n_train (int): Number of samples in the training set. + n_test (int): Number of samples in the testing set. + + Returns: + float: Variance-corrected standard deviation of the set of differences. + + Reference: + `Statistical comparison of models + .` + """ + # kr = k times r, r times repeated k-fold crossvalidation, + # kr equals the number of times the model was evaluated + kr = len(differences) + corrected_var = np.var(differences, ddof=1) * (1 / kr + n_test / n_train) + corrected_std = np.sqrt(corrected_var) + return corrected_std + + def __compute_midrank(self, x: np.array) -> np.array: + """ + Computes midranks for Delong p-value. + Args: + x(np.array): 1D array of probabilities. + + Returns: + np.array: Midranks. + """ + J = np.argsort(x) + Z = x[J] + N = len(x) + T = np.zeros(N, dtype=np.float) + i = 0 + while i < N: + j = i + while j < N and Z[j] == Z[i]: + j += 1 + T[i:j] = 0.5*(i + j - 1) + i = j + T2 = np.empty(N, dtype=np.float) + # Note(kazeevn) +1 is due to Python using 0-based indexing + # instead of 1-based in the AUC formula in the paper + T2[J] = T + 1 + return T2 + + def __fast_delong(self, predictions_sorted_transposed: np.array, label_1_count: int) -> Tuple[float, float]: + """ + Computes the empricial AUC and its covariance using the fast version of DeLong's method. + + Args: + predictions_sorted_transposed (np.array): a 2D numpy.array[n_classifiers, n_examples] + sorted such as the examples with label "1" are first. + label_1_count (int): number of examples with label "1". + + Returns: + Tuple(float, float): (AUC value, DeLong covariance) + + Reference: + `Python fast delong implementation .` + @article{sun2014fast, + title={Fast Implementation of DeLong's Algorithm for + Comparing the Areas Under Correlated Receiver Operating Characteristic Curves}, + author={Xu Sun and Weichao Xu}, + journal={IEEE Signal Processing Letters}, + volume={21}, + number={11}, + pages={1389--1393}, + year={2014}, + publisher={IEEE} + } + """ + # Short variables are named as they are in the paper + m = label_1_count + n = predictions_sorted_transposed.shape[1] - m + positive_examples = predictions_sorted_transposed[:, :m] + negative_examples = predictions_sorted_transposed[:, m:] + k = predictions_sorted_transposed.shape[0] + + tx = np.empty([k, m], dtype=np.float) + ty = np.empty([k, n], dtype=np.float) + tz = np.empty([k, m + n], dtype=np.float) + for r in range(k): + tx[r, :] = self.__compute_midrank(positive_examples[r, :]) + ty[r, :] = self.__compute_midrank(negative_examples[r, :]) + tz[r, :] = self.__compute_midrank(predictions_sorted_transposed[r, :]) + aucs = tz[:, :m].sum(axis=1) / m / n - float(m + 1.0) / 2.0 / n + v01 = (tz[:, :m] - tx[:, :]) / n + v10 = 1.0 - (tz[:, m:] - ty[:, :]) / m + sx = np.cov(v01) + sy = np.cov(v10) + delongcov = sx / m + sy / n + + return aucs, delongcov + + def __compute_ground_truth_statistics(self, ground_truth: np.array) -> Tuple[np.array, int]: + """ + Computes the order of the ground truth and the number of positive examples. + + Args: + ground_truth(np.array): np.array of 0 and 1. + + Returns: + Tuple[np.array, int]: ground truth ordered and the number of positive examples. + """ + assert np.array_equal(np.unique(ground_truth), [0, 1]) + order = (-ground_truth).argsort() + label_1_count = int(ground_truth.sum()) + return order, label_1_count + + def __get_metrics(self, metric: str, split_idx: int) -> tuple: + """ + Initializes the p-value information that will be used to compute the p-values across all different methods. + + Args: + metric (str): Metric to retrieve. + split_idx (int): Index of the split. + + Returns: + tuple: Tuple containing the metrics of the first model and metrics of the second model. + """ + # Get models dicts + path_json_1, path_json_2 = self.__get_models_dicts(split_idx) + + # Load models dicts + model_one = load_json(path_json_1) + model_two = load_json(path_json_2) + + # Get name models + name_model_one = list(model_one.keys())[0] + name_model_two = list(model_two.keys())[0] + + # Get predictions + metric_one = model_one[name_model_one]['test']['metrics'][metric] + metric_two = model_two[name_model_two]['test']['metrics'][metric] + + return metric_one, metric_two + + def __delong_roc_test(self, ground_truth: np.array, predictions_one: list, predictions_two: list) -> float: + """ + Computes log(p-value) for hypothesis that two ROC AUCs are different + + Args: + ground_truth(np.array): np.array of 0 and 1 + predictions_one(np.array): np.array of floats of the probability of being class 1 for the first model. + predictions_two(np.array): np.array of floats of the probability of being class 1 for the second model. + + Returns: + flaot: p-value of the AUCs. + """ + order, label_1_count = self.__compute_ground_truth_statistics(ground_truth) + predictions_sorted_transposed = np.vstack((predictions_one, predictions_two))[:, order] + aucs, delongcov = self.__fast_delong(predictions_sorted_transposed, label_1_count) + return self.__calc_pvalue(aucs, delongcov) + + @staticmethod + def get_aggregated_metric( + path_experiment: Path, + experiment: str, + level: str, + modality: str, + metric: str + ) -> float: + """ + Calculates the p-value of the Delong test for the given experiment. + + Args: + path_experiment (Path): Path to the folder containing the experiment. + experiment (str): Name of the experiment. + level (str): Radiomics level. For example: 'morph'. + modality (str): Modality to analyze. + metric (str): Metric to analyze. + + Returns: + float: p-value of the Delong test. + """ + + # Load outcomes dataframe + try: + outcomes = pd.read_csv(path_experiment / "outcomes.csv", sep=',') + except: + outcomes = pd.read_csv(path_experiment.parent / "outcomes.csv", sep=',') + + # Initialization + predictions_all = list() + patients_ids_all = list() + nb_split = len([x[0] for x in os.walk(path_experiment / f'learn__{experiment}_{level}_{modality}')]) - 1 + + # For each split + for i in range(1, nb_split + 1): + # Load ground truths and predictions + path_json = path_experiment / f'learn__{experiment}_{level}_{modality}' / f'test__{i:03d}' / 'run_results.json' + + # Load models dicts + model = load_json(path_json) + + # Get name models + name_model = list(model.keys())[0] + + # Get Model's threshold + thresh = model[name_model]['threshold'] + + # Get predictions + predictions = np.array(model[name_model]['test']['response']) + predictions = np.reshape(predictions, (predictions.shape[0])).tolist() + + # Bring all predictions to 0.5 + predictions = [prediction - thresh + 0.5 if thresh >= 0.5 else prediction + 0.5 - thresh for prediction in predictions] + predictions_all.extend(predictions) + + # Get patients ids + patients_ids = model[name_model]['test']['patients'] + + # After verification, add-up patients IDs + patients_ids_all.extend(patients_ids) + + # Get ground truth for selected patients + ground_truth = [] + for patient in patients_ids_all: + ground_truth.append(outcomes[outcomes['PatientID'] == patient][outcomes.columns[-1]].values[0]) + + # to numpy array + ground_truth = np.array(ground_truth) + + # Get aggregated metric + # AUC + if metric == 'AUC': + auc = metrics.roc_auc_score(ground_truth, predictions_all) + return auc + + # AUPRC + elif metric == 'AUPRC': + auc = metrics.average_precision_score(ground_truth, predictions_all) + + # Confusion matrix-based metrics + else: + TP = ((np.array(predictions_all) >= 0.5) & (ground_truth == 1)).sum() + TN = ((np.array(predictions_all) < 0.5) & (ground_truth == 0)).sum() + FP = ((np.array(predictions_all) >= 0.5) & (ground_truth == 0)).sum() + FN = ((np.array(predictions_all) < 0.5) & (ground_truth == 1)).sum() + + # Asserts + assert TP + FN != 0, "TP + FN = 0, Division by 0" + assert TN + FP != 0, "TN + FP = 0, Division by 0" + + # Sensitivity + if metric == 'Sensitivity': + sensitivity = TP / (TP + FN) + return sensitivity + + # Specificity + elif metric == 'Specificity': + specificity = TN / (TN + FP) + return specificity + + else: + raise ValueError(f"Metric {metric} not supported. Supported metrics: AUC, AUPRC, Sensitivity, Specificity.\ + Update file Stats.py to add the new metric.") + + def get_aggregated_delong_p_value(self) -> float: + """ + Calculates the p-value of the Delong test for the given experiment. + + Returns: + float: p-value of the Delong test. + """ + + # Load outcomes dataframe + try: + outcomes = pd.read_csv(self.path_experiment / "outcomes.csv", sep=',') + except: + outcomes = pd.read_csv(self.path_experiment.parent / "outcomes.csv", sep=',') + + # Initialization + predictions_one_all = list() + predictions_two_all = list() + patients_ids_all = list() + nb_split = len([x[0] for x in os.walk(self.path_experiment / f'learn__{self.experiment}_{self.levels[0]}_{self.modalities[0]}')]) - 1 + + # For each split + for i in range(1, nb_split + 1): + # Get predictions and patients ids + patients_ids, predictions_one, predictions_two = self.__get_patients_and_predictions(i) + + # Add-up all information + predictions_one_all.extend(predictions_one) + predictions_two_all.extend(predictions_two) + patients_ids_all.extend(patients_ids) + + # Get ground truth for selected patients + ground_truth = [] + for patient in patients_ids_all: + ground_truth.append(outcomes[outcomes['PatientID'] == patient][outcomes.columns[-1]].values[0]) + + # to numpy array + ground_truth = np.array(ground_truth) + + # Get p-value + pvalue = self.__delong_roc_test(ground_truth, predictions_one_all, predictions_two_all).item() + + # Compute the median p-value of all splits + return pvalue + + def get_bengio_p_value(self) -> float: + """ + Computes Bengio's right-tailed paired t-test with corrected variance. + + Returns: + float: p-value of the Bengio test. + """ + + # Initialization + metrics_one_all = list() + metrics_two_all = list() + nb_split = len([x[0] for x in os.walk(self.path_experiment / f'learn__{self.experiment}_{self.levels[0]}_{self.modalities[0]}')]) - 1 + + # For each split + for i in range(1, nb_split + 1): + # Get models dicts + path_json_1, path_json_2 = self.__get_models_dicts(i) + + # Load patients train and test lists + patients_train = load_json(path_json_1.parent / 'patientsTrain.json') + patients_test = load_json(path_json_1.parent / 'patientsTest.json') + n_train = len(patients_train) + n_test = len(patients_test) + + # Load models dicts + model_one = load_json(path_json_1) + model_two = load_json(path_json_2) + + # Get name models + name_model_one = list(model_one.keys())[0] + name_model_two = list(model_two.keys())[0] + + # Get predictions + metric_one = model_one[name_model_one]['test']['metrics']['AUC'] + metric_two = model_two[name_model_two]['test']['metrics']['AUC'] + + # Add-up all information + metrics_one_all.append(metric_one) + metrics_two_all.append(metric_two) + + # Check if the number of predictions is the same + if len(metrics_one_all) != len(metrics_two_all): + raise ValueError("The number of metrics must be the same for both models") + + # Get differences + differences = np.array(metrics_one_all) - np.array(metrics_two_all) + df = differences.shape[0] - 1 + + # Get corrected std + mean = np.mean(differences) + std = self.__corrected_std(differences, n_train, n_test) + + # Get p-value + t_stat = mean / std + p_val = scipy.stats.t.sf(np.abs(t_stat), df) # right-tailed t-test + + return p_val + + def get_delong_p_value( + self, + aggregate: bool = False, + ) -> float: + """ + Calculates the p-value of the Delong test for the given experiment. + + Args: + aggregate (bool, optional): If True, aggregates the results of all the splits and computes one final p-value. + + Returns: + float: p-value of the Delong test. + """ + + # Check if aggregation is needed + if aggregate: + return self.get_aggregated_delong_p_value() + + # Load outcomes dataframe + try: + outcomes = pd.read_csv(self.path_experiment / "outcomes.csv", sep=',') + except: + outcomes = pd.read_csv(self.path_experiment.parent / "outcomes.csv", sep=',') + + # Initialization + nb_split = len([x[0] for x in os.walk(self.path_experiment / f'learn__{self.experiment}_{self.levels[0]}_{self.modalities[0]}')]) - 1 + list_p_values_temp = list() + + # For each split + for i in range(1, nb_split + 1): + # Get predictions and patients ids + patients_ids, predictions_one, predictions_two = self.__get_patients_and_predictions(i) + + # Get ground truth for selected patients + ground_truth = [] + for patient in patients_ids: + ground_truth.append(outcomes[outcomes['PatientID'] == patient][outcomes.columns[-1]].values[0]) + + # to numpy array + ground_truth = np.array(ground_truth) + + # Get p-value + pvalue = self.__delong_roc_test(ground_truth, predictions_one, predictions_two).item() + + list_p_values_temp.append(pvalue) + + # Compute the median p-value of all splits + return np.median(list_p_values_temp) + + def get_ttest_p_value(self, metric: str = 'AUC',) -> float: + """ + Calculates the p-value using the t-test for two related samples of scores. + + Args: + metric (str, optional): Metric to use for comparison. Defaults to 'AUC'. + + Returns: + float: p-value of the Delong test. + """ + + # Initialization + metric = metric.split('_')[0] if '_' in metric else metric + metrics_one_all = list() + metrics_two_all = list() + nb_split = len([x[0] for x in os.walk(self.path_experiment / f'learn__{self.experiment}_{self.levels[0]}_{self.modalities[0]}')]) - 1 + + # For each split + for i in range(1, nb_split + 1): + # Get metrics of the first and second model + metric_one, metric_two = self.__get_metrics(metric, i) + + # Add-up all information + metrics_one_all.append(metric_one) + metrics_two_all.append(metric_two) + + # Check if the number of predictions is the same + if len(metrics_one_all) != len(metrics_two_all): + raise ValueError("The number of metrics must be the same for both models") + + # Compute p-value by performing paired t-test + _, p_value = scipy.stats.ttest_rel(metrics_one_all, metrics_two_all) + + return p_value + + def get_wilcoxin_p_value(self, metric: str = 'AUC',) -> float: + """ + Calculates the p-value using the t-test for two related samples of scores. + + Args: + metric (str, optional): Metric to analyze. Defaults to 'AUC'. + + Returns: + float: p-value of the Delong test. + """ + + # Initialization + metric = metric.split('_')[0] if '_' in metric else metric + metrics_one_all = list() + metrics_two_all = list() + nb_split = len([x[0] for x in os.walk(self.path_experiment / f'learn__{self.experiment}_{self.levels[0]}_{self.modalities[0]}')]) - 1 + + # For each split + for i in range(1, nb_split + 1): + # Get metrics of the first and second model + metric_one, metric_two = self.__get_metrics(metric, i) + + # Add-up all information + metrics_one_all.append(metric_one) + metrics_two_all.append(metric_two) + + # Check if the number of predictions is the same + if len(metrics_one_all) != len(metrics_two_all): + raise ValueError("The number of metrics must be the same for both models") + + # Compute p-value by performing wilcoxon signed rank test + _, p_value = scipy.stats.wilcoxon(metrics_one_all, metrics_two_all) + + return p_value + + def get_p_value( + self, + method: str, + metric: str = 'AUC', + aggregate: bool = False + ) -> float: + """ + Calculates the p-value of the given method. + + Args: + method (str): Method to use to calculate the p-value. Available options: + - 'delong': Delong test. + - 'ttest': T-test. + - 'wilcoxon': Wilcoxon signed rank test. + - 'bengio': Bengio and Nadeau corrected t-test. + metric (str, optional): Metric to analyze. Defaults to 'AUC'. + aggregate (bool, optional): If True, aggregates the results of all the splits and computes one final p-value. + + Returns: + float: p-value of the Delong test. + """ + # Assertions + assert method in ['delong', 'ttest', 'wilcoxon', 'bengio'], \ + f'method must be either "delong", "ttest", "wilcoxon" or "bengio". Given: {method}' + + # Get p-value + if method == 'delong': + return self.get_delong_p_value(aggregate) + elif method == 'ttest': + return self.get_ttest_p_value(metric) + elif method == 'wilcoxon': + return self.get_wilcoxin_p_value(metric) + elif method == 'bengio': + return self.get_bengio_p_value() diff --git a/MEDiml/learning/__init__.py b/MEDiml/learning/__init__.py new file mode 100644 index 0000000..9c355f8 --- /dev/null +++ b/MEDiml/learning/__init__.py @@ -0,0 +1,10 @@ +from . import * +from .cleaning_utils import * +from .DataCleaner import DataCleaner +from .DesignExperiment import DesignExperiment +from .FSR import FSR +from .ml_utils import * +from .Normalization import Normalization +from .RadiomicsLearner import RadiomicsLearner +from .Results import Results +from .Stats import Stats diff --git a/MEDiml/learning/cleaning_utils.py b/MEDiml/learning/cleaning_utils.py new file mode 100644 index 0000000..58d4708 --- /dev/null +++ b/MEDiml/learning/cleaning_utils.py @@ -0,0 +1,107 @@ +import random +from typing import List + +import numpy as np +import pandas as pd + + +def check_min_n_per_cat( + variable_table: pd.DataFrame, + var_names: List[str], + min_n_per_cat: float, + type: str) -> pd.DataFrame: + """ + This Function is different from matlab, it takes the whole variable_table + and the name of the var_of_type to fit the way pandas works + + Args: + variable_table (pd.DataFrame): Table of variables. + var_names (list): List of variable names. + min_n_per_cat (float): Minimum number of observations per category. + type (str): Type of variable. + + Returns: + pd.DataFrame: Table of variables with categories under ``min_n_per_cat``. + """ + + for name in var_names: + table = variable_table[var_names] + cats = pd.Categorical(table[name]).categories + for cat in cats: + flag_cat = (table == cat) + if sum(flag_cat[name]) < min_n_per_cat: + if type == 'hcategorical': + table.mask(flag_cat, np.nan) + if type == 'icategorical': + table.mask(flag_cat, '') + variable_table[var_names] = table + + return variable_table + +def check_max_percent_cat(variable_table, var_names, max_percent_cat) -> pd.Series: + """ + This Function is different from matlab, it takes the whole variable_table + and the name of the var_of_type to fit the way pandas works + + Args: + variable_table (pd.DataFrame): Table of variables. + var_names (list): List of variable names. + max_percent_cat (float): Maximum number of observations per category. + + Returns: + pd.DataFrame: Table of variables with categories over ``max_percent_cat``. + """ + + n_observation = variable_table.shape[0] + flag_var_out = pd.Series(np.zeros(var_names.size, dtype=bool)) + n = 0 + for name in var_names: + cats = pd.Categorical(variable_table[name]).categories + for cat in cats: + if (variable_table[name] == cat).sum()/n_observation > max_percent_cat: + flag_var_out[n] = True + break + n += 1 + return flag_var_out + +def one_hot_encode_table(variable_table: pd.DataFrame) -> pd.DataFrame: + """ + Converts a table of categorical variables into a table of one-hot encoded variables. + + Args: + variable_table (pd.DataFrame): Table of variables. + + Returns: + variable_table (pd.DataFrame): Table of variables with one-hot encoded variables. + """ + + #INITIALIZATION + var_icat = variable_table.Properties['userData']['variables']['icategorical'] + n_var_icat = var_icat.size + if n_var_icat == 0: + return variable_table + + # ONE-HOT ENCODING + for var_name in var_icat: + categories = variable_table[var_name].unique() + categories = np.asarray(list(filter(lambda v: v == v, categories))) # get rid of nan + categories.sort() + n_categories = categories.size + name_encoded = [] + position_to_add = variable_table.columns.get_loc(var_name)+1 + if n_categories == 2: + n_categories = 1 + for c in range(n_categories): + cat = categories[c] + new_name = f"{var_name}__{cat}" + data_to_add = (variable_table[var_name] == cat).astype(int) + variable_table.insert(loc=position_to_add, column=new_name, value=data_to_add) + name_encoded.append(new_name) + variable_table.Properties['userData']['variables']["one_hot"] = dict() + variable_table.Properties['userData']['variables']["one_hot"][var_name] = name_encoded + variable_table = variable_table.drop(var_name, axis=1) + + # UPDATING THE VARIABLE TYPES + variable_table.Properties['userData']['variables']["icategorical"] = np.array([]) + variable_table.Properties['userData']['variables']["hcategorical"] = np.append([], name_encoded) + return variable_table diff --git a/MEDiml/learning/ml_utils.py b/MEDiml/learning/ml_utils.py new file mode 100644 index 0000000..cca73e0 --- /dev/null +++ b/MEDiml/learning/ml_utils.py @@ -0,0 +1,1015 @@ +import csv +import json +import os +import pickle +import re +import string +from copy import deepcopy +from pathlib import Path +from typing import Dict, List, Tuple, Union + +import matplotlib.pyplot as plt +import numpy as np +import pandas +import pandas as pd +import seaborn as sns +from numpyencoder import NumpyEncoder +from sklearn.model_selection import StratifiedKFold + +from MEDiml.utils import get_institutions_from_ids +from MEDiml.utils.get_full_rad_names import get_full_rad_names +from MEDiml.utils.json_utils import load_json, save_json + + +# Define useful constants +# Metrics to process +list_metrics = [ + 'AUC', 'AUPRC', 'BAC', 'Sensitivity', 'Specificity', + 'Precision', 'NPV', 'F1_score', 'Accuracy', 'MCC', + 'TN', 'FP', 'FN', 'TP' +] + +def average_results(path_results: Path, save: bool = False) -> None: + """ + Averages the results (AUC, BAC, Sensitivity and Specifity) of all the runs of the same experiment, + for training, testing and holdout sets. + + Args: + path_results(Path): path to the folder containing the results of the experiment. + save (bool, optional): If True, saves the results in the same folder as the model. + + Returns: + None. + """ + # Get all tests paths + list_path_tests = [path for path in path_results.iterdir() if path.is_dir()] + + # Initialize dictionaries + results_avg = { + 'train': {}, + 'test': {}, + 'holdout': {} + } + + # Metrics to process + metrics = ['AUC', 'AUPRC', 'BAC', 'Sensitivity', 'Specificity', + 'Precision', 'NPV', 'F1_score', 'Accuracy', 'MCC', + 'TN', 'FP', 'FN', 'TP'] + + # Process metrics + for dataset in ['train', 'test', 'holdout']: + dataset_dict = results_avg[dataset] + for metric in metrics: + metric_values = [] + for path_test in list_path_tests: + results_dict = load_json(path_test / 'run_results.json') + if dataset in results_dict[list(results_dict.keys())[0]].keys(): + if 'metrics' in results_dict[list(results_dict.keys())[0]][dataset].keys(): + metric_values.append(results_dict[list(results_dict.keys())[0]][dataset]['metrics'][metric]) + else: + continue + else: + continue + + # Fill the dictionary + if metric_values: + dataset_dict[f'{metric}_mean'] = np.nanmean(metric_values) + dataset_dict[f'{metric}_std'] = np.nanstd(metric_values) + dataset_dict[f'{metric}_max'] = np.nanmax(metric_values) + dataset_dict[f'{metric}_min'] = np.nanmin(metric_values) + dataset_dict[f'{metric}_2.5%'] = np.nanpercentile(metric_values, 2.5) + dataset_dict[f'{metric}_97.5%'] = np.nanpercentile(metric_values, 97.5) + + # Save the results + if save: + save_json(path_results / 'results_avg.json', results_avg, cls=NumpyEncoder) + return path_results / 'results_avg.json' + + return results_avg + +def combine_rad_tables(rad_tables: List) -> pd.DataFrame: + """ + Combines a list of radiomics tables into one single table. + + Args: + rad_tables (List): List of radiomics tables. + + Returns: + pd.DataFrame: Single combined radiomics table. + """ + # Initialization + n_tables = len(rad_tables) + + base_idx = 0 + for idx, table in enumerate(rad_tables): + if not table.empty: + base_idx = idx + break + # Finding patient intersection + for t in range(n_tables): + if rad_tables[t].shape[1] > 0 and t != base_idx: + rad_tables[base_idx], rad_tables[t] = intersect_var_tables(rad_tables[base_idx], rad_tables[t]) + + # Check for NaNs + '''for table in rad_tables: + assert(table.isna().sum().sum() == 0)''' + + # Initializing the radiomics table template + radiomics_table = pd.DataFrame() + radiomics_table.Properties = {} + radiomics_table._metadata += ['Properties'] + radiomics_table.Properties['userData'] = {} + radiomics_table.Properties['VariableNames'] = [] + radiomics_table.Properties['userData']['normalization'] = {} + + # Combining radiomics table one by one + count = 0 + continuous = [] + str_names = '||' + for t in range(n_tables): + rad_table_id = 'radTab' + str(t+1) + if rad_tables[t].shape[1] > 0 and rad_tables[t].shape[0] > 0: + features = rad_tables[t].columns.values + description = rad_tables[t].Properties['Description'] + full_rad_names = get_full_rad_names(rad_tables[t].Properties['userData']['variables']['var_def'], + features) + if 'normalization' in rad_tables[t].Properties['userData']: + radiomics_table.Properties['userData']['normalization'][rad_table_id] = rad_tables[t].Properties[ + 'userData']['normalization'] + for f, feature in enumerate(features): + count += 1 + var_name = 'radVar' + str(count) + radiomics_table[var_name] = rad_tables[t][feature] + radiomics_table.Properties['VariableNames'].append(var_name) + continuous.append(var_name) + if description: + str_names += 'radVar' + str(count) + ':' + description + '___' + full_rad_names[f] + '||' + else: + str_names += 'radVar' + str(count) + ':' + full_rad_names[f] + '||' + + # Updating the radiomics table properties + radiomics_table.Properties['Description'] = '' + radiomics_table.Properties['DimensionNames'] = ['PatientID'] + radiomics_table.Properties['userData']['variables'] = {} + radiomics_table.Properties['userData']['variables']['var_def'] = str_names + radiomics_table.Properties['userData']['variables']['continuous'] = continuous + + return radiomics_table + +def combine_tables_from_list(var_list: List, combination: List) -> pd.DataFrame: + """ + Concatenates all variable tables in ``var_list`` according to ``var_ids``. + + Unlike ``combine_rad_tables`` This method concatenates variable tables instead of creating a new table from + the intersection of the tables. + + Args: + var_list (List): List of tables. Each key is a given var_id and holds a radiomic table. + --> Ex: .var1: variable table 1 + .var2: variable table 2 + .var3: variable table 3 + combination (list): List of strings to identify the table to combine in var_list. + --> Ex: {'var1','var3'} + + Returns: + pd.DataFrame: variable_table: Combined radiomics table. + """ + def concatenate_varid(var_names, var_id): + return np.asarray([var_id + "__" + var_name for var_name in var_names.tolist()]) + + # Initialization + variables = dict() + variables['continuous'] = np.array([]) + variable_tables = list() + + # Using the first table as template + var_id = combination[0] + variable_table = deepcopy(var_list[var_id]) # first table from the list + variable_table.Properties = deepcopy(var_list[var_id].Properties) + new_columns = [var_id + '__' + col for col in variable_table.columns] + variable_table.columns = new_columns + variable_table.Properties['VariableNames'] = new_columns + variable_table.Properties['userData'] = dict() # Re-Initializing + variable_table.Properties['userData'][var_id] = deepcopy(var_list[var_id].Properties['userData']) + variables['continuous'] = np.concatenate((variables['continuous'], var_list[var_id].Properties[ + 'userData']['variables']['continuous'])) + variable_tables.append(variable_table) + + # Concatenating all other tables + for var_id in combination[1:]: + variable_table.Properties['userData'][var_id] = var_list[var_id].Properties['userData'] + patient_ids = intersect(list(variable_table.index), (var_list[var_id].index)) + var_list[var_id] = var_list[var_id].loc[patient_ids] + variable_table = variable_table.loc[patient_ids] + old_columns = list(variable_table.columns) + old_properties = deepcopy(variable_table.Properties) # for unknown reason Properties are erased after concat + variable_table = pd.concat([variable_table, var_list[var_id]], axis=1) + variable_table.columns = old_columns + [var_id + "__" + col for col in var_list[var_id].columns] + variable_table.Properties = old_properties + variable_table.Properties['VariableNames'] = list(variable_table.columns) + variables['continuous'] = np.concatenate((variables['continuous'], var_list[var_id].Properties['userData']['variables']['continuous'])) + + # Updating the radiomics table properties + variable_table.Properties['Description'] = "Data table" + variables['continuous'] = concatenate_varid(variables['continuous'], var_id) + variable_table.Properties['userData']['variables'] = variables + + return variable_table + +def convert_comibnations_to_list(combinations_string: str) -> Tuple[List, List]: + """ + Converts a cell of strings specifying variable ids combinations to + a cell of cells of strings. + + Args: + combinations_string (str): Cell of strings specifying var_ids combinations + separated by underscores. + --> Ex: {'var1_var2';'var2_var3';'var1_var2_var3'} + + Rerturs: + - List: List of strings of the seperated var_ids. + --> Ex: {{'var1','var2'};{'var2','var3'};{'var1','var2','var3'}} + - List: List of strings specifying the "alphabetical" IDs of combined variables + in ``combinations``. var1 --> A, var2 -> B, etc. + --> Ex: {'model_AB';'model_BC';'model_ABC'} + """ + # Building combinations + combinations = [s.split('_') for s in combinations_string] + + # Building model_ids + alphabet = string.ascii_uppercase + model_ids = list() + for combination in combinations: + model_ids.append('model_' + ''.join([alphabet[int(var[3:])-1] for var in combination])) + + return combinations, model_ids + +def count_class_imbalance(path_csv_outcomes: Path) -> Dict: + """ + Counts the class imbalance in a given outcome table. + + Args: + path_csv_outcomes (Path): Path to the outcome table. + + Returns: + Dict: Dictionary containing the count of each class. + """ + # Initialization + outcomes = pandas.read_csv(path_csv_outcomes, sep=',') + outcomes.dropna(inplace=True) + outcomes.reset_index(inplace=True, drop=True) + name_outcome = outcomes.columns[-1] + + # Counting the percentage of each class + class_0_perc = np.sum(outcomes[name_outcome] == 0) / len(outcomes) + class_1_perc = np.sum(outcomes[name_outcome] == 1) / len(outcomes) + + return {'class_0': class_0_perc, 'class_1': class_1_perc} + +def create_experiment_folder(path_outcome_folder: str, method: str = 'Random') -> str: + """ + Creates the experiment folder where the hold-out splits will be saved and returns the path + to the folder. + + Args: + path_outcome_folder (str): Full path to the outcome folder (folder containing the outcome table etc). + method (str): String specifying the split type. Default is 'Random'. + + Returns: + str: Full path to the experiment folder. + """ + + # Creating the outcome folder if it does not exist + if not os.path.isdir(path_outcome_folder): + os.makedirs(path_outcome_folder) + + # Creating the experiment folder if it does not exist + list_outcome = os.listdir(path_outcome_folder) + if not list_outcome: + flag_exist_split = False + else: + n_exist = 0 + flag_exist_split = False + for i in range(len(list_outcome)): + if 'holdOut__' + method + '__' in list_outcome[i]: + n_exist = n_exist + 1 + flag_exist_split = True + + # If path experiment folder exists already, create a new one (sequentially) + if not flag_exist_split: + path_split = str(path_outcome_folder) + '/holdOut__' + method + '__001' + else: + path_split = str(path_outcome_folder) + '/holdOut__' + method + '__' + \ + str(n_exist+1).zfill(3) + + os.mkdir(path_split) + return path_split + +def create_holdout_set( + path_outcome_file: Union[str, Path], + outcome_name: str, + path_save_experiments: Union[str, Path] = None, + method: str = 'random', + percentage: float = 0.2, + n_split: int = 1, + seed : int = 1) -> None: + """ + Creates a hold-out patient set to be used for final independent testing after a final + model is chosen. All the information is saved in a JSON file. + + Args: + path_outcome_file (str): Full path to where the outcome CSV file is stored. + outcome_name (str): Name of the outcome. For example, 'OS' for overral survivor. + path_save_experiments (str): Full path to the folder where the experiments + will be saved. + method (str): Method to use for creating the hold-out set. Options are: + - 'random': Randomly selects patients for the hold-out set. + - 'all_learn': No hold-out set is created. All patients are used for learning. + - 'institution': TODO. + percentage (float): Percentage of patients to use for the hold-out set. Default is 0.2. + n_split (int): Number of splits to create. Default is 1. + seed (int): Seed to use for the random split. Default is 1. + + Returns: + None. + """ + # Initilization + outcome_name = outcome_name.upper() + outcome_table = pandas.read_csv(path_outcome_file, sep=',') + outcome_table.dropna(inplace=True) + outcome_table.reset_index(inplace=True, drop=True) + patient_ids = outcome_table['PatientID'] + + # Creating experiment folders and patient test split(s) + outcome_name = re.sub(r'\W', "", outcome_name) + path_outcome = str(path_save_experiments) + '/' + outcome_name + name_outcome_in_table_binary = outcome_name + '_binary' + + # Column names in the outcome table + with open(path_outcome_file, 'r') as infile: + reader = csv.DictReader(infile, delimiter=',') + var_names = reader.fieldnames + + # Include time to event if it exists + flag_time = False + if(outcome_name + '_eventFreeTime' in str(var_names)): + name_outcome_in_table_time = outcome_name + '_eventFreeTime' + flag_time = True + + # Check if the outcome name for binary is correct + if name_outcome_in_table_binary not in outcome_table.columns: + name_outcome_in_table_binary = var_names[-1] + + # Run the split + # Random + if 'random' in method.lower(): + # Creating the experiment folder + path_split = create_experiment_folder(path_outcome, 'random') + + # Getting the random split + patients_learn_temp, patients_hold_out_temp = get_stratified_splits( + outcome_table[['PatientID', name_outcome_in_table_binary]], + n_split, percentage, seed, False) + + # Getting the patient IDs in the learning and hold-out sets + if n_split > 1: + patients_learn = np.empty((n_split, len(patients_learn_temp[0])), dtype=object) + patients_hold_out = np.empty((n_split, len(patients_hold_out_temp[0])), dtype=object) + for s in range(n_split): + patients_learn[s] = patient_ids[patients_learn_temp[s]] + patients_hold_out[s] = patient_ids[patients_hold_out_temp[s]] + else: + patients_learn = patient_ids[patients_learn_temp.values.tolist()] + patients_learn.reset_index(inplace=True, drop=True) + patients_hold_out = patient_ids[patients_hold_out_temp.values.tolist()] + patients_hold_out.reset_index(inplace=True, drop=True) + + # All Learn + elif 'all_learn' in method.lower(): + # Creating the experiment folder + path_split = create_experiment_folder(path_outcome, 'all_learn') + + # Getting the split (all Learn so no hold out) + patients_learn = patient_ids + patients_hold_out = [] + else: + raise ValueError('Method not recognized. Use "random" or "all_learn".') + + # Creating final outcome table and saving it + if flag_time: + outcomes = outcome_table[ + ['PatientID', name_outcome_in_table_binary, name_outcome_in_table_time]] + else: + outcomes = outcome_table[['PatientID', name_outcome_in_table_binary]] + + # Finalize the outcome table + outcomes = outcomes.dropna(inplace=False) # Drop NaNs + outcomes.reset_index(inplace=True, drop=True) # Reset index + + # Save the outcome table + paths_exp_outcomes = str(path_split + '/outcomes.csv') + outcomes.to_csv(paths_exp_outcomes, index=False) + + # Save dict of patientsLearn + paths_exp_patientsLearn = str(path_split) + '/patientsLearn.json' + patients_learn.to_json(paths_exp_patientsLearn, orient='values', indent=4) + + # Save dict of patientsHoldOut + if method == 'random': + paths_exp_patients_hold_out = str(path_split) + '/patientsHoldOut.json' + patients_hold_out.to_json(paths_exp_patients_hold_out, orient='values', indent=4) + + # Save dict of all the paths + data={ + "outcomes" : paths_exp_outcomes, + "patientsLearn": paths_exp_patientsLearn, + "patientsHoldOut": paths_exp_patients_hold_out, + "pathWORK": path_split + } + else: + data={ + "outcomes" : paths_exp_outcomes, + "patientsLearn": paths_exp_patientsLearn, + "pathWORK": path_split + } + paths_exp = str(path_split + '/paths_exp.json') + with open(paths_exp, 'w') as f: + json.dump(data, f, indent=4) + + # Return the path to the experiment and path to split + return path_split, paths_exp + +def cross_validation_split( + outcome: List[Union[int, float]], + n_splits: int = 5, + seed: int = None + ) -> Tuple[List[List[int]], List[List[int]]]: + """ + Perform stratified cross-validation split. + + Args: + outcome (list): Outcome variable (binary). + n_splits (int, optional): Number of folds. Default is 5. + seed (int or None, optional): Random seed for reproducibility. Default is None. + + Returns: + train_indices_list (list of lists): List of training indices for each fold. + test_indices_list (list of lists): List of testing indices for each fold. + """ + + skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed) + train_data_list = [] + test_data_list = [] + patient_ids = pd.Series(outcome.index) + + for train_indices, test_indices in skf.split(X=outcome, y=outcome): + train_data_list.append(patient_ids[train_indices]) + test_data_list.append(patient_ids[test_indices]) + + train_data_array = np.array(train_data_list, dtype=object) + test_data_array = np.array(test_data_list, dtype=object) + + return train_data_array, test_data_array + +def find_best_model(path_results: Path, metric: str = 'AUC', second_metric: str = 'AUC') -> Tuple[Dict, Path]: + """ + Find the best model with the highest performance on the test set + in a given path based on a given metric. + + Args: + path_results (Path): Path to the results folder. + metric (str): Metric to use to find the best model in case of a tie. Default is 'AUC'. + + Returns: + Tuple[Dict, Path]: Tuple containing the best model result dict and the path to the best model. + """ + list_metrics = [ + 'AUC', 'Sensitivity', 'Specificity', + 'BAC', 'AUPRC', 'Precision', + 'NPV', 'Accuracy', 'F1_score', 'MCC', + 'TP', 'TN', 'FP', 'FN' + ] + assert metric in list_metrics, f'Given metric {metric} is not in the list of metrics. Please choose from {list_metrics}' + + # Get all tests paths + list_path_tests = [path for path in path_results.iterdir() if path.is_dir()] + + # Initialization + metric_best = -1 + second_metric_best = -1 + path_result_best = None + + # Get all models and their metrics (AUC especially) + for path_test in list_path_tests: + if not (path_test / 'run_results.json').exists(): + continue + results_dict = load_json(path_test / 'run_results.json') + metric_test = results_dict[list(results_dict.keys())[0]]['test']['metrics'][metric] + if metric_test > metric_best: + metric_best = metric_test + path_result_best = path_test + elif metric_test == metric_best: + second_metric_test = results_dict[list(results_dict.keys())[0]]['test']['metrics'][second_metric] + if second_metric_test > second_metric_best: + second_metric_best = second_metric_test + path_result_best = path_test + + # Load best model result dict + results_dict_best = load_json(path_result_best / 'run_results.json') + + # Load model + model_name = list(results_dict_best.keys())[0] + with open(path_result_best / f'{model_name}.pickle', 'rb') as file: + model = pickle.load(file) + + return model, results_dict_best + +def feature_imporance_analysis(path_results: Path): + """ + Averages the results (AUC, BAC, Sensitivity and Specifity) of all the runs of the same experiment, + for training, testing and holdout sets. + + Args: + path_results(Path): path to the folder containing the results of the experiment. + save (bool, optional): If True, saves the results in the same folder as the model. + + Returns: + None. + """ + # Get all tests paths + list_path_tests = [path for path in path_results.iterdir() if path.is_dir()] + + # Initialization + results_avg_temp = {} + results_avg = {} + + # Process metrics + for path_test in list_path_tests: + variables = [] + list_models = list(path_test.glob('*.pickle')) + if len(list_models) == 0 or len(list_models) > 1: + raise ValueError(f'Path {path_test} does not contain a single model.') + model_obj = list_models[0] + with open(model_obj, "rb") as f: + model_dict = pickle.load(f) + if model_dict["var_names"]: + variables = get_full_rad_names(model_dict['var_info']['variables']['var_def'], model_dict["var_names"]) + for index, var in enumerate(variables): + var = var.split("\\")[-1] # Remove the path for windows + var = var.split("/")[-1] # Remove the path for linux + if var not in results_avg_temp: + results_avg_temp[var] = { + 'importance_mean': [], + 'times_selected': 0 + } + + results_avg_temp[var]['importance_mean'].append(model_dict['model'].feature_importances_[index]) + results_avg_temp[var]['times_selected'] += 1 + for var in results_avg_temp: + results_avg[var] = { + 'importance_mean': np.sum(results_avg_temp[var]['importance_mean']) / len(list_path_tests), + 'times_selected': results_avg_temp[var]['times_selected'] + } + + del results_avg_temp + + save_json(path_results / 'feature_importance_analysis.json', results_avg, cls=NumpyEncoder) + +def get_ml_test_table(variable_table: pd.DataFrame, var_names: List, var_def: str) -> pd.DataFrame: + """ + Gets the test table with the variables that are present in the training table. + + Args: + variable_table (pd.DataFrame): Table with the variables to use for the ML model that + will be matched with the training table. + var_names (List): List of variable names used for the ML model . + var_def (str): String of the full variables names used for the ML model. + + Returns: + pd.DataFrame: Table with the variables that are present in the training table. + """ + + # Get the full variable names for training + full_radvar_names_trained = get_full_rad_names(var_def, var_names).tolist() + + # Get the full variable names for testing + full_rad_var_names_test = get_full_rad_names( + variable_table.Properties['userData']['variables']['var_def'], + variable_table.columns.values + ).tolist() + + # Get the indexes of the variables that are present in the training table + indexes = [] + for radvar in full_radvar_names_trained: + try: + indexes.append(full_rad_var_names_test.index(radvar)) + except ValueError as e: + print(e) + raise ValueError('The variable ' + radvar + ' is not present in the test table.') + + # Get the test table with the variables that are present in the training table + variable_table = variable_table.iloc[:, indexes] + + # User data - var_def + str_names = '||' + for v in range(len(var_names)): + str_names += var_names[v] + ':' + full_radvar_names_trained[v] + '||' + + # Update metadata and variable names + variable_table.columns = var_names + variable_table.Properties['VariableNames'] = var_names + variable_table.Properties['userData']['variables']['var_def'] = str_names + variable_table.Properties['userData']['variables']['continuous'] = var_names + + # Rename columns to s sequential names again + return variable_table + +def finalize_rad_table(rad_table: pd.DataFrame) -> pd.DataFrame: + """ + Finalizes the variable names and the associated metadata. Used to have sequential variable + names and UserData with only variable names present in the table. + + Args: + rad_table (pd.DataFrame): radiomics table to be finalized. + + Returns: + pd.DataFrame: Finalized radiomics table. + """ + + # Initialization + var_names = rad_table.columns.values + full_rad_names = get_full_rad_names(rad_table.Properties['userData']['variables']['var_def'], var_names) + + # User data - var_def + str_names = '||' + for v in range(var_names.size): + var_names[v] = 'radVar' + str(v+1) + str_names = str_names + var_names[v] + ':' + full_rad_names[v] + '||' + + # Update metadata and variable names + rad_table.columns = var_names + rad_table.Properties['VariableNames'] = var_names + rad_table.Properties['userData']['variables']['var_def'] = str_names + rad_table.Properties['userData']['variables']['continuous'] = var_names + + return rad_table + +def get_radiomics_table( + path_radiomics_csv: Path, + path_radiomics_txt: Path, + image_type: str, + patients_ids: List = None + ) -> pd.DataFrame: + """ + Loads the radiomics table from the .csv file and the associated metadata. + + Args: + path_radiomics_csv (Path): full path to the csv file of radiomics table. + --> Ex: /home/myStudy/FEATURES/radiomics__PET(GTV)__image.csv + path_radiomics_txt: full path to the radiomics variable definitions in text format (associated + to path_radiomics_csv). + -> Ex: /home/myStudy/FEATURES/radiomics__PET(GTV)__image.txt + image_type (str): String specifying the type of image on which the radiomics + features were computed. + --> Format: $scan$($roiType$)__$imSpace$ + --> Ex: PET(tumor)__HHH_coif1 + patients_ids (list, optional): List of strings specifying the patientIDs of + patients to fetch from the radiomics table. If this + argument is not present, all patients are fetched. + --> Ex: {'Cervix-UCSF-001';Cervix-McGill-004} + + Returns: + pd.DataFrame: radiomics table + """ + # Read CSV table + radiomics_table = pd.read_csv(path_radiomics_csv, index_col=0) + if patients_ids is not None: + patients_ids = intersect(patients_ids, list(radiomics_table.index)) + radiomics_table = radiomics_table.loc[patients_ids] + + # Read the associated TXT file + with open(path_radiomics_txt, 'r') as f: + user_data = f.read() + + # Grouping the information + radiomics_table._metadata += ["Properties"] + radiomics_table.Properties = dict() + radiomics_table.Properties['userData'] = dict() + radiomics_table.Properties['userData']['variables'] = dict() + radiomics_table.Properties['userData']['variables']['var_def'] = user_data + radiomics_table.Properties['Description'] = image_type + + # Only continuous will be used for now but this design will facilitate the use of + # other categories in the future. + # radiomics = continous. + radiomics_table.Properties['userData']['variables']['continuous'] = np.asarray(list(radiomics_table.columns.values)) + + return radiomics_table + +def get_splits(outcome: pd.DataFrame, n_split: int, test_split_proportion: float) -> Tuple[List, List]: + """ + Splits the given outcome table in two sets. + + Args: + outcome (pd.DataFrame): Table with a single outcome column of 0's and 1's. + n_splits (int): Integer specifying the number of splits to create. + test_split_proportion (float): Float between 0 and 1 specifying the proportion + of patients to include in the test set. + + Returns: + train_sets List of indexes for the train_sets. + test_sets: List of indexes for the test_sets. + + """ + + ind_neg = np.where(outcome == 0) + n_neg = len(ind_neg[0]) + ind_pos = np.where(outcome == 1) + n_pos = len(ind_pos[0]) + n_neg_test = round(test_split_proportion * n_neg) + n_pos_test = round(test_split_proportion * n_pos) + + n_inst = len(outcome) + n_test = n_pos_test + n_neg_test + n_train = n_inst - n_test + + if(n_split==1): + train_sets = np.zeros(n_train) + test_sets = np.zeros(n_test) + else: + train_sets = np.zeros((n_split, n_train)) + test_sets = np.zeros((n_split, n_test)) + + for s in range(n_split): + ind_pos_test = np.random.choice(ind_pos[0], n_pos_test, replace=False) + ind_neg_test = np.random.choice(ind_neg[0], n_neg_test, replace=False) + + ind_test = np.concatenate((ind_pos_test,ind_neg_test)) + ind_test.sort() + + ind_train = np.arange(n_inst) + ind_train = np.delete(ind_train, ind_test) + ind_train.sort() + + if(n_split>1): + train_sets[s] = ind_train + test_sets[s] = ind_test + else: + train_sets = ind_train + test_sets = ind_test + + return train_sets, test_sets + +def get_stratified_splits( + outcome_table: pd.DataFrame, + n_splits: int, + test_split_proportion: float, + seed: int, + flag_by_cat: bool=False + ) -> Tuple[List, List]: + """ + Sub-divides a given outcome dataset into multiple stratified patient splits. + The stratification is performed per class proportion (or by institution). + + Args: + outcome_table: Table with a single outcome column of 0's and 1's. + The rows of the table must define the patient IDs: $Cancer-$Institution-$Number. + n_splits: Integer specifying the number of splits to create. + test_split_proportion: Float between 0 and 1 specifying the proportion + of patients to include in the test set. + seed: Integer specifying the random generator seed to use for random splitting. + flag_by_cat (optional): Logical flag specifying if we are to produce + the split by taking into account the institutions in the outcome table. + If true, patients in Training and testing splits have the same prortion + of events per instiution as originally found in the initial data. Default: False. + + Returns: + List: patients_train_splits, list of size nTrainXnSplit, where each entry + is a string specifying a "Training" patient. + List: patients_test_splits, list of size nTestXnSplit, where each entry + is a string specifying a "testing" patient + """ + patient_ids = pd.Series(outcome_table.index) + patients_train_splits = [] + patients_test_splits = [] + + # Take into account the institutions in the outcome table + if flag_by_cat: + institution_cat_vector = get_institutions_from_ids(patient_ids) + all_categories = np.unique(institution_cat_vector) + n_cat = len(all_categories) + # Split for each institution + for i in range(n_cat): + np.random.seed(seed) + cat = all_categories[i] + flag_cat = institution_cat_vector == cat + patient_ids_cat = patient_ids[flag_cat] + patient_ids_cat.reset_index(inplace=True, drop=True) + + # Split train and test sets + train_sets, test_sets = get_splits(outcome_table[flag_cat.values], n_splits, test_split_proportion) + + if n_splits > 1: + temp_patients_train = np.empty((n_splits, len(train_sets[0])), dtype=object) + temp_patientsTest = np.empty((n_splits, len(test_sets[0])), dtype=object) + for s in range(n_splits): + temp_patients_train[s] = patient_ids_cat[train_sets[s]] + temp_patientsTest[s] = patient_ids_cat[test_sets[s]] + else: + temp_patients_train = patient_ids_cat[train_sets] + temp_patients_train.reset_index(inplace=True, drop=True) + temp_patientsTest = patient_ids_cat[test_sets] + temp_patientsTest.reset_index(inplace=True, drop=True) + + # Initialize the train and test patients list (1st iteration) + if i==0: + patients_train_splits=temp_patients_train + patients_test_splits=temp_patientsTest + + # Add new patients to the train and test patients list (other iterations) + if i>0: + if n_splits>1: + patients_train_splits = np.append(patients_train_splits, temp_patients_train, axis=1) + patients_test_splits = np.append(patients_test_splits, temp_patientsTest, axis=1) + + else: + patients_train_splits = np.append(patients_train_splits, temp_patients_train) + patients_test_splits = np.append(patients_test_splits, temp_patientsTest) + + # Do not take into account the institutions in the outcome table + else: + # Split train and test sets + train_sets, test_sets = get_splits(outcome_table, n_splits, test_split_proportion) + if n_splits > 1: + patients_train_splits = np.empty((n_splits, len(train_sets[0])), dtype=object) + patients_test_splits = np.empty((n_splits, len(test_sets[0])), dtype=object) + for s in range(n_splits): + patients_train_splits[s] = patient_ids[train_sets[s]] + patients_test_splits[s] = patient_ids[test_sets[s]] + else: + patients_train_splits = patient_ids[train_sets] + patients_train_splits.reset_index(inplace=True, drop=True) + patients_test_splits = patient_ids[test_sets] + patients_test_splits.reset_index(inplace=True, drop=True) + + return patients_train_splits, patients_test_splits + +def get_patient_id_classes(outcome_table: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]: + """ + Yields the patients from the majority class and the minority class in the given outcome table. + Only supports binary classes. + + Args: + outcome_table(pd.DataFrame): outcome table with binary labels. + + Returns: + pd.DataFrame: Majority class patientIDs. + pd.DataFrame: Minority class patientIDs. + """ + ones = outcome_table.loc[outcome_table.iloc[0:].values == 1].index + zeros = outcome_table.loc[outcome_table.iloc[0:].values == 0].index + if ones.size > zeros.size: + return ones, zeros + + return zeros, ones + +def intersect(list1: List, list2: List, sort: bool = False) -> List: + """ + Returns the intersection of two list. + + Args: + list1 (List): the first list. + list2 (List): the second list. + order (bool): if True, the intersection is sorted. + + Returns: + List: the intersection of the two lists. + """ + + intersection = list(filter(lambda x: x in list1, list2)) + if sort: + return sorted(intersection) + return intersection + +def intersect_var_tables(var_table1: pd.DataFrame, var_table2: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]: + """ + This function takes 2 variable table, compares the indexes and drops the + ones that are not in both, then returns the 2 table. + + Args: + var_table1 (pd.DataFrame): first variable table. + var_table2 (pd.DataFrame): second variable table. + + Returns: + pd.DataFrame: first variable table with the same indexes as the second. + pd.DataFrame: second variable table with the same indexes as the first. + """ + # Find the unique values in var_table1 that are not in var_table2 + missing = np.setdiff1d(var_table1.index.to_numpy(), var_table2.index.to_numpy()) + if missing.size > 0: + var_table1 = var_table1.drop(missing) + + # Find the unique values in var_table2 that are not in var_table1 + missing = np.setdiff1d(var_table2.index.to_numpy(), var_table1.index.to_numpy()) + if missing.size > 0: + var_table2 = var_table2.drop(missing) + + return var_table1, var_table2 + +def under_sample(outcome_table_binary: pd.DataFrame) -> pd.DataFrame: + """ + Performs under-sampling to obtain an equal number of outcomes in the binary outcome table. + + Args: + outcome_table_binary (pd.DataFrame): outcome table with binary labels. + + Returns: + pd.DataFrame: outcome table with balanced binary labels. + """ + + # We place them prematurely in maj and min and correct it afterwards + n_maj = (outcome_table_binary == 0).sum().values[0] + n_min = (outcome_table_binary == 1).sum().values[0] + if n_maj == n_min: + return outcome_table_binary + elif n_min > n_maj: + n_min, n_maj = n_maj, n_min + + # Sample the patients from the majority class + patient_ids_maj, patient_ids_min = get_patient_id_classes(outcome_table_binary) + patient_ids_min = list(patient_ids_min) + patient_ids_numpy = patient_ids_maj.to_numpy() + np.random.shuffle(patient_ids_numpy) + patient_ids_sample = list(patient_ids_numpy[0:n_min]) + new_ids = patient_ids_min + patient_ids_sample + + return outcome_table_binary.loc[new_ids, :] + +def save_model(model: Dict, var_id: str, path_model: Path, ml: Dict = None, name_type: str = "") -> Dict: + """ + Saves a given model locally as a pickle object and outputs a dictionary + containing the model's information. + + Args: + model (Dict): The model dict to save. + var_id (str): The stduied variable. For ex: 'var3'. + path_model (str): The path to save the model. + ml (Dict, optional): Dicionary containing the settings of the machine learning experiment. + name_type (str, optional): String specifying the type of the variable. For examlpe: "RadiomicsIntensity". Default is "". + + Returns: + Dict: A dictionary containing the model's information. + """ + # Saving model + with open(path_model, "wb") as f: + pickle.dump(model, f) + + # Getting the "var_names" string + if ml is not None: + var_names = ml['variables'][var_id]['nameType'] + elif name_type != "": + var_names = name_type + else: + var_names = [var_id] + + # Recording model info + model_info = dict() + model_info['path'] = path_model + model_info['var_ids'] = var_id + model_info['var_type'] = var_names + + try: # This part may fail if model training failed. + model_info['var_names'] = model['var_names'] + model_info['var_info'] = model['var_info'] + if 'normalization' in model_info['var_info'].keys(): + if 'normalization_table' in model_info['var_info']['normalization'].keys(): + normalization_struct = write_table_structure(model_info['var_info']['normalization']['normalization_table']) + model_info['var_info']['normalization']['normalization_table'] = normalization_struct + model_info['threshold'] = model['threshold'] + except Exception as e: + print("Failed to create a fully model info") + print(e) + + return model_info + +def write_table_structure(data_table: pd.DataFrame) -> Dict: + """ + Writes the structure of a table in a dictionary. + + Args: + data_table (pd.DataFrame): a table. + + Returns: + Dict: a dictionary containing the table's structure. + """ + # Initialization + data_struct = dict() + + if len(data_table.index) != 0: + data_struct['index'] = list(data_table.index) + + # Creating the structure + for column in data_table.columns: + data_struct[column] = data_table[column] + + return data_struct diff --git a/MEDiml/processing/__init__.py b/MEDiml/processing/__init__.py new file mode 100644 index 0000000..32514ea --- /dev/null +++ b/MEDiml/processing/__init__.py @@ -0,0 +1,6 @@ +from . import * +from .compute_suv_map import * +from .discretisation import * +from .interpolation import * +from .resegmentation import * +from .segmentation import * diff --git a/MEDiml/processing/compute_suv_map.py b/MEDiml/processing/compute_suv_map.py new file mode 100644 index 0000000..48e7783 --- /dev/null +++ b/MEDiml/processing/compute_suv_map.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +import numpy as np +import pydicom + + +def compute_suv_map(raw_pet: np.ndarray, + dicom_h: pydicom.Dataset) -> np.ndarray: + """Computes the suv_map of a raw input PET volume. It is assumed that + the calibration factor was applied beforehand to the PET volume. + **E.g: raw_pet = raw_pet*RescaleSlope + RescaleIntercept.** + + Args: + raw_pet (ndarray):3D array representing the PET volume in raw format. + dicom_h (pydicom.dataset.FileDataset): DICOM header of one of the + corresponding slice of ``raw_pet``. + + Returns: + ndarray: ``raw_pet`` converted to SUVs (standard uptake values). + """ + def dcm_hhmmss(date_str: str) -> float: + """"Converts to seconds + + Args: + date_str (str): date string + + Returns: + float: total seconds + """ + # Converts to seconds + if not isinstance(date_str, str): + date_str = str(date_str) + hh = float(date_str[0:2]) + mm = float(date_str[2:4]) + ss = float(date_str[4:6]) + tot_sec = hh*60.0*60.0 + mm*60.0 + ss + return tot_sec + + def pydicom_has_tag(dcm_seq, tag): + # Checks if tag exists + return get_pydicom_meta_tag(dcm_seq, tag, test_tag=True) + + def get_pydicom_meta_tag(dcm_seq, tag, tag_type=None, default=None, + test_tag=False): + # Reads dicom tag + # Initialise with default + tag_value = default + # Read from header using simple itk + try: + tag_value = dcm_seq[tag].value + except KeyError: + if test_tag: + return False + if test_tag: + return True + # Find empty entries + if tag_value is not None: + if tag_value == "": + tag_value = default + # Cast to correct type (meta tags are usually passed as strings) + if tag_value is not None: + # String + if tag_type == "str": + tag_value = str(tag_value) + # Float + elif tag_type == "float": + tag_value = float(tag_value) + # Multiple floats + elif tag_type == "mult_float": + tag_value = [float(str_num) for str_num in tag_value] + # Integer + elif tag_type == "int": + tag_value = int(tag_value) + # Multiple floats + elif tag_type == "mult_int": + tag_value = [int(str_num) for str_num in tag_value] + # Boolean + elif tag_type == "bool": + tag_value = bool(tag_value) + + return tag_value + + # Get patient weight + if pydicom_has_tag(dcm_seq=dicom_h, tag=(0x0010, 0x1030)): + weight = get_pydicom_meta_tag(dcm_seq=dicom_h, tag=(0x0010, 0x1030), + tag_type="float") * 1000.0 # in grams + else: + weight = None + if weight is None: + weight = 75000.0 # estimation + try: + # Get Scan time + scantime = dcm_hhmmss(date_str=get_pydicom_meta_tag( + dcm_seq=dicom_h, tag=(0x0008, 0x0032), tag_type="str")) + # Start Time for the Radiopharmaceutical Injection + injection_time = dcm_hhmmss(date_str=get_pydicom_meta_tag( + dcm_seq=dicom_h[0x0054, 0x0016][0], + tag=(0x0018, 0x1072), tag_type="str")) + # Half Life for Radionuclide + half_life = get_pydicom_meta_tag( + dcm_seq=dicom_h[0x0054, 0x0016][0], + tag=(0x0018, 0x1075), tag_type="float") + # Total dose injected for Radionuclide + injected_dose = get_pydicom_meta_tag( + dcm_seq=dicom_h[0x0054, 0x0016][0], + tag=(0x0018, 0x1074), tag_type="float") + # Calculate decay + decay = np.exp(-np.log(2)*(scantime-injection_time)/half_life) + # Calculate the dose decayed during procedure + injected_dose_decay = injected_dose*decay # in Bq + except KeyError: + # 90 min waiting time, 15 min preparation + decay = np.exp(-np.log(2)*(1.75*3600)/6588) + injected_dose_decay = 420000000 * decay # 420 MBq + + # Calculate SUV + suv_map = raw_pet * weight / injected_dose_decay + + return suv_map diff --git a/MEDiml/processing/discretisation.py b/MEDiml/processing/discretisation.py new file mode 100644 index 0000000..12376b9 --- /dev/null +++ b/MEDiml/processing/discretisation.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from copy import deepcopy +from typing import Tuple + +import numpy as np +from skimage.exposure import equalize_hist + + +def equalization(vol_re: np.ndarray) -> np.ndarray: + """Performs histogram equalisation of the ROI imaging intensities. + + Note: + This is a pure "what is contained within the roi" equalization. this is + not influenced by the :func:`user_set_min_val()` used for FBS discretisation. + + Args: + vol_re (ndarray): 3D array of the image volume that will be studied with + NaN value for the excluded voxels (voxels outside the ROI mask). + + Returns: + ndarray: Same input image volume but with redistributed intensities. + """ + + # AZ: This was made part of the function call + # n_g = 64 + # This is the default we will use. It means that when using 'FBS', + # n_q should be chosen wisely such + # that the total number of grey levels does not exceed 64, for all + # patients (recommended). + # This choice was amde by considering that the best equalization + # performance for "histeq.m" is obtained with low n_g. + # WARNING: The effective number of grey levels coming out of "histeq.m" + # may be lower than n_g. + + # CONSERVE THE INDICES OF THE ROI + x_gl = np.ravel(vol_re) + ind_roi = np.where(~np.isnan(vol_re)) + x_gl = x_gl[~np.isnan(x_gl)] + + # ADJUST RANGE BETWEEN 0 and 1 + min_val = np.min(x_gl) + max_val = np.max(x_gl) + x_gl_01 = (x_gl - min_val)/(max_val - min_val) + + # EQUALIZATION + # x_gl_equal = equalize_hist(x_gl_01, nbins=n_g) + # AT THE MOMENT, WE CHOOSE TO USE THE DEFAULT NUMBER OF BINS OF + # equalize_hist.py (256) + x_gl_equal = equalize_hist(x_gl_01) + # RE-ADJUST TO CORRECT RANGE + x_gl_equal = (x_gl_equal - np.min(x_gl_equal)) / \ + (np.max(x_gl_equal) - np.min(x_gl_equal)) + x_gl_equal = x_gl_equal * (max_val - min_val) + x_gl_equal = x_gl_equal + min_val + + # RECONSTRUCT THE VOLUME WITH EQUALIZED VALUES + vol_equal_re = deepcopy(vol_re) + + vol_equal_re[ind_roi] = x_gl_equal + + return vol_equal_re + +def discretize(vol_re: np.ndarray, + discr_type: str, + n_q: float=None, + user_set_min_val: float=None, + ivh=False) -> Tuple[np.ndarray, float]: + """Quantisizes the image intensities inside the ROI. + + Note: + For 'FBS' type, it is assumed that re-segmentation with + proper range was already performed + + Args: + vol_re (ndarray): 3D array of the image volume that will be studied with + NaN value for the excluded voxels (voxels outside the ROI mask). + discr_type (str): Discretisaion approach/type must be: "FBS", "FBN", "FBSequal" + or "FBNequal". + n_q (float): Number of bins for FBS algorithm and bin width for FBN algorithm. + user_set_min_val (float): Minimum of range re-segmentation for FBS discretisation, + for FBN discretisation, this value has no importance as an argument + and will not be used. + ivh (bool): Must be set to True for IVH (Intensity-Volume histogram) features. + + Returns: + 2-element tuple containing + + - ndarray: Same input image volume but with discretised intensities. + - float: bin width. + """ + + # AZ: NOTE: the "type" variable that appeared in the MATLAB source code + # matches the name of a standard python function. I have therefore renamed + # this variable "discr_type" + + # PARSING ARGUMENTS + vol_quant_re = deepcopy(vol_re) + + if n_q is None: + return None + + if not isinstance(n_q, float): + n_q = float(n_q) + + if discr_type not in ["FBS", "FBN", "FBSequal", "FBNequal"]: + raise ValueError( + "discr_type must either be \"FBS\", \"FBN\", \"FBSequal\" or \"FBNequal\".") + + # DISCRETISATION + if discr_type in ["FBS", "FBSequal"]: + if user_set_min_val is not None: + min_val = deepcopy(user_set_min_val) + else: + min_val = np.nanmin(vol_quant_re) + else: + min_val = np.nanmin(vol_quant_re) + + max_val = np.nanmax(vol_quant_re) + + if discr_type == "FBS": + w_b = n_q + w_d = w_b + vol_quant_re = np.floor((vol_quant_re - min_val) / w_b) + 1.0 + elif discr_type == "FBN": + w_b = (max_val - min_val) / n_q + w_d = 1.0 + vol_quant_re = np.floor( + n_q * ((vol_quant_re - min_val)/(max_val - min_val))) + 1.0 + vol_quant_re[vol_quant_re == np.nanmax(vol_quant_re)] = n_q + elif discr_type == "FBSequal": + w_b = n_q + w_d = w_b + vol_quant_re = equalization(vol_quant_re) + vol_quant_re = np.floor((vol_quant_re - min_val) / w_b) + 1.0 + elif discr_type == "FBNequal": + w_b = (max_val - min_val) / n_q + w_d = 1.0 + vol_quant_re = vol_quant_re.astype(np.float32) + vol_quant_re = equalization(vol_quant_re) + vol_quant_re = np.floor( + n_q * ((vol_quant_re - min_val)/(max_val - min_val))) + 1.0 + vol_quant_re[vol_quant_re == np.nanmax(vol_quant_re)] = n_q + if ivh and discr_type in ["FBS", "FBSequal"]: + vol_quant_re = min_val + (vol_quant_re - 0.5) * w_b + + return vol_quant_re, w_d diff --git a/MEDiml/processing/interpolation.py b/MEDiml/processing/interpolation.py new file mode 100644 index 0000000..f172ce2 --- /dev/null +++ b/MEDiml/processing/interpolation.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +import logging +from copy import deepcopy +from typing import List + +import numpy as np + +from ..MEDscan import MEDscan +from ..processing.segmentation import compute_box +from ..utils.image_volume_obj import image_volume_obj +from ..utils.imref import imref3d, intrinsicToWorld, worldToIntrinsic +from ..utils.interp3 import interp3 + + +def interp_volume( + vol_obj_s: image_volume_obj, + medscan: MEDscan= None, + vox_dim: List = None, + interp_met: str = None, + round_val: float = None, + image_type: str = None, + roi_obj_s: image_volume_obj = None, + box_string: str = None, + texture: bool = False) -> image_volume_obj: + """3D voxel interpolation on the input volume. + + Args: + vol_obj_s (image_volume_obj): Imaging that will be interpolated. + medscan (object): The MEDscan class object. + vox_dim (array): Array of the voxel dimension. The following format is used + [Xin,Yin,Zslice], where Xin and Yin are the X (left to right) and + Y (bottom to top) IN-PLANE resolutions, and Zslice is the slice spacing, + no matter the orientation of the volume (i.e. axial , sagittal, coronal). + interp_met (str): {nearest, linear, spline, cubic} optional, Interpolation method + round_val (float): Rounding value. Must be between 0 and 1 for ROI interpolation + and to a power of 10 for Image interpolation. + image_type (str): 'image' for imaging data interpolation and 'roi' for ROI mask data interpolation. + roi_obj_s (image_volume_obj): Mask data, will be used to compute a new specific box + and the new imref3d object for the imaging data. + box_string (str): Specifies the size if the box containing the ROI + + - 'full': full imaging data as output. + - 'box': computes the smallest bounding box. + - Ex: 'box10': 10 voxels in all three dimensions are added to \ + the smallest bounding box. The number after 'box' defines the \ + number of voxels to add. + - Ex: '2box': Computes the smallest box and outputs double its \ + size. The number before 'box' defines the multiplication in size. + texture (bool): If True, the texture voxel spacing of ``MEDscan`` will be used for interpolation. + + Returns: + ndarray: 3D array of 1's and 0's defining the ROI mask. + """ + try: + # PARSING ARGUMENTS + if vox_dim is None: + if medscan is None: + return deepcopy(vol_obj_s) + else: + if texture: + vox_dim = medscan.params.process.scale_text + else: + vox_dim = medscan.params.process.scale_non_text + if np.sum(vox_dim) == 0: + return deepcopy(vol_obj_s) + if len(vox_dim) == 2: + two_d = True + else: + two_d = False + + if image_type is None: + raise ValueError( + "The type of input image should be specified as \"image\" or \"roi\".") + elif image_type not in ["image", "roi"]: + raise ValueError( + "The type of input image should either be \"image\" or \"roi\".") + elif image_type == "image": + if not interp_met: + if medscan: + interp_met = medscan.params.process.vol_interp + else: + raise ValueError("Interpolation method or MEDscan instance should be provided.") + if interp_met not in ["linear", "cubic", "spline"]: + raise ValueError( + "Interpolation method for images should either be \"linear\", \"cubic\" or \"spline\".") + if medscan and not round_val: + round_val = medscan.params.process.gl_round + if round_val is not None: + if np.mod(np.log10(round_val), 1): + raise ValueError("\"round_val\" should be a power of 10.") + else: + if not interp_met: + if medscan: + interp_met = medscan.params.process.roi_interp + else: + raise ValueError("Interpolation method or MEDscan instance should be provided.") + if interp_met not in ["nearest", "linear", "cubic"]: + raise ValueError( + "Interpolation method for images should either be \"nearest\", \"linear\" or \"cubic\".") + if medscan and not round_val: + round_val = medscan.params.process.roi_pv + if round_val is not None: + if round_val < 0.0 or round_val > 1.0: + raise ValueError("\"round_val\" must be between 0.0 and 1.0.") + else: + raise ValueError("\"round_val\" must be provided for \"roi\".") + if medscan and not box_string: + box_string = medscan.params.process.box_string + if roi_obj_s is None or box_string is None: + use_box = False + else: + use_box = True + + # --> QUERIED POINTS: NEW INTERPOLATED VOLUME: "q" or "Q". + # --> SAMPLED POINTS: ORIGINAL VOLUME: "s" or "S". + # --> Always using XYZ coordinates (unless specifically noted), + # not MATLAB IJK, so beware! + + # INITIALIZATION + res_q = vox_dim + if two_d: + # If 2D, the resolution of the slice dimension of he queried volume is + # set to the same as the sampled volume. + res_q = np.concatenate((res_q, vol_obj_s.spatialRef.PixelExtentInWorldZ)) + + res_s = np.array([vol_obj_s.spatialRef.PixelExtentInWorldX, + vol_obj_s.spatialRef.PixelExtentInWorldY, + vol_obj_s.spatialRef.PixelExtentInWorldZ]) + + if np.array_equal(res_s, res_q): + return deepcopy(vol_obj_s) + + spatial_ref_s = vol_obj_s.spatialRef + extent_s = np.array([spatial_ref_s.ImageExtentInWorldX, + spatial_ref_s.ImageExtentInWorldY, + spatial_ref_s.ImageExtentInWorldZ]) + low_limits_s = np.array([spatial_ref_s.XWorldLimits[0], + spatial_ref_s.YWorldLimits[0], + spatial_ref_s.ZWorldLimits[0]]) + + # CREATING QUERIED "imref3d" OBJECT CENTERED ON SAMPLED VOLUME + + # Switching to IJK (matlab) reference frame for "imref3d" computation. + # Putting a "ceil", according to IBSI standards. This is safer than "round". + size_q = np.ceil(np.around(np.divide(extent_s, res_q), + decimals=3)).astype(int).tolist() + + if two_d: + # If 2D, forcing the size of the queried volume in the slice dimension + # to be the same as the sample volume. + size_q[2] = vol_obj_s.spatialRef.ImageSize[2] + + spatial_ref_q = imref3d(imageSize=size_q, + pixelExtentInWorldX=res_q[0], + pixelExtentInWorldY=res_q[1], + pixelExtentInWorldZ=res_q[2]) + + extent_q = np.array([spatial_ref_q.ImageExtentInWorldX, + spatial_ref_q.ImageExtentInWorldY, + spatial_ref_q.ImageExtentInWorldZ]) + low_limits_q = np.array([spatial_ref_q.XWorldLimits[0], + spatial_ref_q.YWorldLimits[0], + spatial_ref_q.ZWorldLimits[0]]) + diff = extent_q - extent_s + new_low_limits_q = low_limits_s - diff/2 + spatial_ref_q.XWorldLimits = spatial_ref_q.XWorldLimits - \ + (low_limits_q[0] - new_low_limits_q[0]) + spatial_ref_q.YWorldLimits = spatial_ref_q.YWorldLimits - \ + (low_limits_q[1] - new_low_limits_q[1]) + spatial_ref_q.ZWorldLimits = spatial_ref_q.ZWorldLimits - \ + (low_limits_q[2] - new_low_limits_q[2]) + + # REDUCE THE SIZE OF THE VOLUME PRIOR TO INTERPOLATION + # TODO check that compute_box vol and roi are intended to be the same! + if use_box: + _, _, tempSpatialRef = compute_box( + vol=roi_obj_s.data, roi=roi_obj_s.data, spatial_ref=vol_obj_s.spatialRef, + box_string=box_string) + + size_temp = tempSpatialRef.ImageSize + + # Getting world boundaries (center of voxels) of the new box + x_bound, y_bound, z_bound = intrinsicToWorld(R=tempSpatialRef, + xIntrinsic=np.array( + [0.0, size_temp[0]-1.0]), + yIntrinsic=np.array( + [0.0, size_temp[1]-1.0]), + zIntrinsic=np.array([0.0, size_temp[2]-1.0])) + + # Getting the image positions of the boundaries of the new box, IN THE + # FULL QUERIED FRAME OF REFERENCE (centered on the sampled frame of + # reference). + x_bound, y_bound, z_bound = worldToIntrinsic( + R=spatial_ref_q, xWorld=x_bound, yWorld=y_bound, zWorld=z_bound) + + # Rounding to the nearest image position integer + x_bound = np.round(x_bound).astype(int) + y_bound = np.round(y_bound).astype(int) + z_bound = np.round(z_bound).astype(int) + + size_q = np.array([x_bound[1] - x_bound[0] + 1, y_bound[1] - + y_bound[0] + 1, z_bound[1] - z_bound[0] + 1]) + + # Converting back to world positions ion order to correctly define + # edges of the new box and thus center it onto the full queried + # reference frame + x_bound, y_bound, z_bound = intrinsicToWorld(R=spatial_ref_q, + xIntrinsic=x_bound, + yIntrinsic=y_bound, + zIntrinsic=z_bound) + + new_low_limits_q[0] = x_bound[0] - res_q[0]/2 + new_low_limits_q[1] = y_bound[0] - res_q[1]/2 + new_low_limits_q[2] = z_bound[0] - res_q[2]/2 + + spatial_ref_q = imref3d(imageSize=size_q, + pixelExtentInWorldX=res_q[0], + pixelExtentInWorldY=res_q[1], + pixelExtentInWorldZ=res_q[2]) + + spatial_ref_q.XWorldLimits -= spatial_ref_q.XWorldLimits[0] - \ + new_low_limits_q[0] + spatial_ref_q.YWorldLimits -= spatial_ref_q.YWorldLimits[0] - \ + new_low_limits_q[1] + spatial_ref_q.ZWorldLimits -= spatial_ref_q.ZWorldLimits[0] - \ + new_low_limits_q[2] + + # CREATING QUERIED XYZ POINTS + x_q = np.arange(size_q[0]) + y_q = np.arange(size_q[1]) + z_q = np.arange(size_q[2]) + x_q, y_q, z_q = np.meshgrid(x_q, y_q, z_q, indexing='ij') + x_q, y_q, z_q = intrinsicToWorld( + R=spatial_ref_q, xIntrinsic=x_q, yIntrinsic=y_q, zIntrinsic=z_q) + + # CONVERTING QUERIED XZY POINTS TO INTRINSIC COORDINATES IN THE SAMPLED + # REFERENCE FRAME + x_q, y_q, z_q = worldToIntrinsic( + R=spatial_ref_s, xWorld=x_q, yWorld=y_q, zWorld=z_q) + + # INTERPOLATING VOLUME + data = interp3(v=vol_obj_s.data, x_q=x_q, y_q=y_q, z_q=z_q, method=interp_met) + vol_obj_q = image_volume_obj(data=data, spatial_ref=spatial_ref_q) + + # ROUNDING + if image_type == "image": + # Grey level rounding for "image" type + if round_val is not None and (type(round_val) is int or type(round_val) is float): + # DELETE NEXT LINE WHEN THE RADIOMICS PARAMETER OPTIONS OF + # interp.glRound ARE FIXED + round_val = (-np.log10(round_val)).astype(int) + vol_obj_q.data = np.around(vol_obj_q.data, decimals=round_val) + else: + vol_obj_q.data[vol_obj_q.data >= round_val] = 1.0 + vol_obj_q.data[vol_obj_q.data < round_val] = 0.0 + + except Exception as e: + if medscan: + if medscan.params.radiomics.scale_name: + message = f"\n PROBLEM WITH INTERPOLATION:\n {e}" + logging.error(message) + medscan.radiomics.image.update( + {(medscan.params.radiomics.scale_name ): 'ERROR_PROCESSING'}) + else: + message = f"\n PROBLEM WITH INTERPOLATION:\n {e}" + logging.error(message) + medscan.radiomics.image.update( + {('scale'+(str(medscan.params.process.scale_non_text[0])).replace('.','dot')): 'ERROR_PROCESSING'}) + else: + print(f"\n PROBLEM WITH INTERPOLATION:\n {e}") + + return vol_obj_q diff --git a/MEDiml/processing/resegmentation.py b/MEDiml/processing/resegmentation.py new file mode 100644 index 0000000..6fa1b11 --- /dev/null +++ b/MEDiml/processing/resegmentation.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from copy import deepcopy +import numpy as np +from numpy import ndarray + + +def range_re_seg(vol: np.ndarray, + roi: np.ndarray, + im_range=None) -> ndarray: + """Removes voxels from the intensity mask that fall outside + the given range (intensities outside the range are set to 0). + + Args: + vol (ndarray): Imaging data. + roi (ndarray): ROI mask with values of 0's and 1's. + im_range (ndarray): 1-D array with shape (1,2) of the re-segmentation intensity range. + + Returns: + ndarray: Intensity mask with intensities within the re-segmentation range. + """ + + if im_range is not None and len(im_range) == 2: + roi = deepcopy(roi) + roi[vol < im_range[0]] = 0 + roi[vol > im_range[1]] = 0 + + return roi + +def outlier_re_seg(vol: np.ndarray, + roi: np.ndarray, + outliers="") -> np.ndarray: + """Removes voxels with outlier intensities from the given mask + using the Collewet method. + + Args: + vol (ndarray): Imaging data. + roi (ndarray): ROI mask with values of 0 and 1. + outliers (str, optional): Algo used to define outliers. + (For now this methods only implements "Collewet" method). + + Returns: + ndarray: An array with values of 0 and 1. + + Raises: + ValueError: If `outliers` is not "Collewet" or None. + + Todo: + * Delete outliers argument or implements others outlining methods. + """ + + if outliers != '': + roi = deepcopy(roi) + + if outliers == "Collewet": + u = np.mean(vol[roi == 1]) + sigma = np.std(vol[roi == 1]) + + roi[vol > (u + 3*sigma)] = 0 + roi[vol < (u - 3*sigma)] = 0 + else: + raise ValueError("Outlier segmentation not defined.") + + return roi diff --git a/MEDiml/processing/segmentation.py b/MEDiml/processing/segmentation.py new file mode 100644 index 0000000..7d1efb6 --- /dev/null +++ b/MEDiml/processing/segmentation.py @@ -0,0 +1,912 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +import logging +from copy import deepcopy +from typing import List, Sequence, Tuple, Union + +import numpy as np +from nibabel import Nifti1Image +from scipy.ndimage import center_of_mass + +from ..MEDscan import MEDscan +from ..utils.image_volume_obj import image_volume_obj +from ..utils.imref import imref3d, intrinsicToWorld, worldToIntrinsic +from ..utils.inpolygon import inpolygon +from ..utils.interp3 import interp3 +from ..utils.mode import mode +from ..utils.parse_contour_string import parse_contour_string +from ..utils.strfind import strfind + +_logger = logging.getLogger(__name__) + + +def get_roi_from_indexes( + medscan: MEDscan, + name_roi: str, + box_string: str + ) -> Tuple[image_volume_obj, image_volume_obj]: + """Extracts the ROI box (+ smallest box containing the region of interest) + and associated mask from the indexes saved in ``medscan`` scan. + + Args: + medscan (MEDscan): The MEDscan class object. + name_roi (str): name of the ROI since the a volume can have multiple + ROIs. + box_string (str): Specifies the size if the box containing the ROI + + - 'full': Full imaging data as output. + - 'box': computes the smallest bounding box. + - Ex: 'box10': 10 voxels in all three dimensions are added to \ + the smallest bounding box. The number after 'box' defines the \ + number of voxels to add. + - Ex: '2box': Computes the smallest box and outputs double its \ + size. The number before 'box' defines the multiplication in \ + size. + + Returns: + 2-element tuple containing + + - ndarray: vol_obj, 3D array of imaging data defining the smallest box \ + containing the region of interest. + - ndarray: roi_obj, 3D array of 1's and 0's defining the ROI in ROIbox. + """ + # This takes care of the "Volume resection" step + # as well using the argument "box". No fourth + # argument means 'interp' by default. + + # PARSING OF ARGUMENTS + try: + name_structure_set = [] + delimiters = ["\+", "\-"] + n_contour_data = len(medscan.data.ROI.indexes) + + name_roi, vect_plus_minus = get_sep_roi_names(name_roi, delimiters) + contour_number = np.zeros(len(name_roi)) + + if name_structure_set is None: + name_structure_set = [] + + if name_structure_set: + name_structure_set, _ = get_sep_roi_names(name_structure_set, delimiters) + if len(name_roi) != len(name_structure_set): + raise ValueError( + "The numbers of defined ROI names and Structure Set names are not the same") + + for i in range(0, len(name_roi)): + for j in range(0, n_contour_data): + name_temp = medscan.data.ROI.get_roi_name(key=j) + if name_temp == name_roi[i]: + if name_structure_set: + # FOR DICOM + RTSTRUCT + name_set_temp = medscan.data.ROI.get_name_set(key=j) + if name_set_temp == name_structure_set[i]: + contour_number[i] = j + break + else: + contour_number[i] = j + break + + n_roi = np.size(contour_number) + # contour_string IS FOR EXAMPLE '3' or '1-3+2' + contour_string = str(contour_number[0].astype(int)) + + for i in range(1, n_roi): + if vect_plus_minus[i-1] == 1: + sign = '+' + elif vect_plus_minus[i-1] == -1: + sign = '-' + contour_string = contour_string + sign + \ + str(contour_number[i].astype(int)) + + if not (box_string == "full" or "box" in box_string): + raise ValueError( + "The third argument must either be \"full\" or contain the word \"box\".") + + contour_number, operations = parse_contour_string(contour_string) + + # INTIALIZATIONS + if type(contour_number) is int: + n_contour = 1 + contour_number = [contour_number] + else: + n_contour = len(contour_number) + + # Note: sData is a nested dictionary not an object + spatial_ref = medscan.data.volume.spatialRef + vol = medscan.data.volume.array.astype(np.float32) + + # APPLYING OPERATIONS ON ALL MASKS + roi = medscan.data.get_indexes_by_roi_name(name_roi[0]) + for c in np.arange(start=1, stop=n_contour): + if operations[c-1] == "+": + roi += medscan.data.get_indexes_by_roi_name(name_roi[c]) + elif operations[c-1] == "-": + roi -= medscan.data.get_indexes_by_roi_name(name_roi[c]) + else: + raise ValueError("Unknown operation on ROI.") + + roi[roi >= 1.0] = 1.0 + roi[roi < 1.0] = 0.0 + + # COMPUTING THE BOUNDING BOX + vol, roi, new_spatial_ref = compute_box(vol=vol, roi=roi, + spatial_ref=spatial_ref, + box_string=box_string) + + # ARRANGE OUTPUT + vol_obj = image_volume_obj(data=vol, spatial_ref=new_spatial_ref) + roi_obj = image_volume_obj(data=roi, spatial_ref=new_spatial_ref) + + except Exception as e: + message = f"\n PROBLEM WITH PRE-PROCESSING OF FEATURES IN get_roi_from_indexes():\n {e}" + logging.error(message) + print(message) + + if medscan: + medscan.radiomics.image.update( + {('scale'+(str(medscan.params.process.scale_non_text[0])).replace('.', 'dot')): 'ERROR_PROCESSING'}) + + return vol_obj, roi_obj + +def get_sep_roi_names(name_roi_in: str, + delimiters: List) -> Tuple[List[int], + np.ndarray]: + """Seperated ROI names present in the given ROI name. An ROI name can + have multiple ROI names seperated with curly brackets and delimeters. + Note: + Works only for delimiters "+" and "-". + Args: + name_roi_in (str): Name of ROIs that will be extracted from the imagign volume. \ + Separated with curly brackets and delimeters. Ex: '{ED}+{ET}'. + delimiters (List): List of delimeters of "+" and "-". + Returns: + 2-element tuple containing + + - List[int]: List of ROI names seperated and excluding curly brackets. + - ndarray: array of 1's and -1's that defines the regions that will \ + included and/or excluded in/from the imaging data. + Examples: + >>> get_sep_roi_names('{ED}+{ET}', ['+', '-']) + ['ED', 'ET'], [1] + >>> get_sep_roi_names('{ED}-{ET}', ['+', '-']) + ['ED', 'ET'], [-1] + """ + # EX: + #name_roi_in = '{GTV-1}' + #delimiters = ['\\+','\\-'] + + # FINDING "+" and "-" + ind_plus = strfind(string=name_roi_in, pattern=delimiters[0]) + vect_plus = np.ones(len(ind_plus)) + ind_minus = strfind(string=name_roi_in, pattern=delimiters[1]) + vect_minus = np.ones(len(ind_minus)) * -1 + ind = np.argsort(np.hstack((ind_plus, ind_minus))) + vect_plus_minus = np.hstack((vect_plus, vect_minus))[ind] + ind = np.hstack((ind_plus, ind_minus))[ind].astype(int) + n_delim = np.size(vect_plus_minus) + + # MAKING SURE "+" and "-" ARE NOT INSIDE A ROIname + ind_start = strfind(string=name_roi_in, pattern="{") + n_roi = len(ind_start) + ind_stop = strfind(string=name_roi_in, pattern="}") + ind_keep = np.ones(n_delim, dtype=bool) + for d in np.arange(n_delim): + for r in np.arange(n_roi): + # Thus not indise a ROI name + if (ind_stop[r] - ind[d]) > 0 and (ind[d] - ind_start[r]) > 0: + ind_keep[d] = False + break + + ind = ind[ind_keep] + vect_plus_minus = vect_plus_minus[ind_keep] + + # PARSING ROI NAMES + if ind.size == 0: + # Excluding the "{" and "}" at the start and end of the ROIname + name_roi_out = [name_roi_in[1:-1]] + else: + n_ind = len(ind) + # Excluding the "{" and "}" at the start and end of the ROIname + name_roi_out = [name_roi_in[1:(ind[0]-1)]] + for i in np.arange(start=1, stop=n_ind): + # Excluding the "{" and "}" at the start and end of the ROIname + name_roi_out += [name_roi_in[(ind[i-1]+2):(ind[i]-1)]] + name_roi_out += [name_roi_in[(ind[-1]+2):-1]] + + return name_roi_out, vect_plus_minus + +def roi_extract(vol: np.ndarray, + roi: np.ndarray) -> np.ndarray: + """Replaces volume intensities outside the ROI with NaN. + + Args: + vol (ndarray): Imaging data. + roi (ndarray): ROI mask with values of 0's and 1's. + + Returns: + ndarray: Imaging data with original intensities in the ROI \ + and NaN for intensities outside the ROI. + """ + + vol_re = deepcopy(vol) + vol_re[roi == 0] = np.nan + + return vol_re + +def get_polygon_mask(roi_xyz: np.ndarray, + spatial_ref: imref3d) -> np.ndarray: + """Computes the indexes of the ROI (Region of interest) enclosing box + in all dimensions. + + Args: + roi_xyz (ndarray): array of (x,y,z) triplets defining a contour in the + Patient-Based Coordinate System extracted from DICOM RTstruct. + spatial_ref (imref3d): imref3d object (same functionality of MATLAB imref3d class). + + Returns: + ndarray: 3D array of 1's and 0's defining the ROI mask. + """ + + # COMPUTING MASK + s_z = spatial_ref.ImageSize.copy() + roi_mask = np.zeros(s_z) + # X,Y,Z in intrinsic image coordinates + X, Y, Z = worldToIntrinsic(R=spatial_ref, + xWorld=roi_xyz[:, 0], + yWorld=roi_xyz[:, 1], + zWorld=roi_xyz[:, 2]) + + points = np.transpose(np.vstack((X, Y, Z))) + + K = np.round(points[:, 2]) # Must assign the points to one slice + closed_contours = np.unique(roi_xyz[:, 3]) + x_q = np.arange(s_z[0]) + y_q = np.arange(s_z[1]) + x_q, y_q = np.meshgrid(x_q, y_q) + + for c_c in np.arange(len(closed_contours)): + ind = roi_xyz[:, 3] == closed_contours[c_c] + # Taking the mode, just in case. But normally, numel(unique(K(ind))) + # should evaluate to 1, as closed contours are meant to be defined on + # a given slice + select_slice = mode(K[ind]).astype(int) + inpoly = inpolygon(x_q=x_q, y_q=y_q, x_v=points[ind, 0], y_v=points[ind, 1]) + roi_mask[:, :, select_slice] = np.logical_or( + roi_mask[:, :, select_slice], inpoly) + + return roi_mask + +def voxel_to_spatial(affine: np.ndarray, + voxel_pos: list) -> np.array: + """Convert voxel position into spatial position. + + Args: + affine (ndarray): Affine matrix. + voxel_pos (list): A list that correspond to the location in voxel. + + Returns: + ndarray: A numpy array that correspond to the spatial position in mm. + """ + m = affine[:3, :3] + translation = affine[:3, 3] + return m.dot(voxel_pos) + translation + +def spatial_to_voxel(affine: np.ndarray, + spatial_pos: list) -> np.array: + """Convert spatial position into voxel position + + Args: + affine (ndarray): Affine matrix. + spatial_pos (list): A list that correspond to the spatial location in mm. + + Returns: + ndarray: A numpy array that correspond to the position in the voxel. + """ + affine = np.linalg.inv(affine) + m = affine[:3, :3] + translation = affine[:3, 3] + return m.dot(spatial_pos) + translation + +def crop_nifti_box(image: Nifti1Image, + roi: Nifti1Image, + crop_shape: List[int], + center: Union[Sequence[int], None] = None) -> Tuple[Nifti1Image, + Nifti1Image]: + """Crops the Nifti image and ROI. + + Args: + image (Nifti1Image): Class for the file NIfTI1 format image that will be cropped. + roi (Nifti1Image): Class for the file NIfTI1 format ROI that will be cropped. + crop_shape (List[int]): The dimension of the region to crop in term of number of voxel. + center (Union[Sequence[int], None]): A list that indicate the center of the cropping box + in term of spatial position. + + Returns: + Tuple[Nifti1Image, Nifti1Image] : Two Nifti images of the cropped image and roi + """ + assert np.sum(np.array(crop_shape) % 2) == 0, "All elements of crop_shape should be even number." + + image_data = image.get_fdata() + roi_data = roi.get_fdata() + + radius = [int(x / 2) - 1 for x in crop_shape] + if center is None: + center = list(np.array(list(center_of_mass(roi_data))).astype(int)) + + center_min = np.floor(center).astype(int) + center_max = np.ceil(center).astype(int) + + # If center_max and center_min are equal we add 1 to center_max to avoid trouble with crop. + for i in range(3): + center_max[i] += 1 if center_max[i] == center_min[i] else 0 + + img_shape = image.header['dim'][1:4] + + # Pad the image and the ROI if its necessary + padding = [] + for rad, cent_min, cent_max, shape in zip(radius, center_min, center_max, img_shape): + padding.append( + [abs(min(cent_min - rad, 0)), max(cent_max + rad + 1 - shape, 0)] + ) + + image_data = np.pad(image_data, tuple([tuple(x) for x in padding])) + roi_data = np.pad(roi_data, tuple([tuple(x) for x in padding])) + + center_min = [center_min[i] + padding[i][0] for i in range(3)] + center_max = [center_max[i] + padding[i][0] for i in range(3)] + + # Crop the image + image_data = image_data[center_min[0] - radius[0]:center_max[0] + radius[0] + 1, + center_min[1] - radius[1]:center_max[1] + radius[1] + 1, + center_min[2] - radius[2]:center_max[2] + radius[2] + 1] + roi_data = roi_data[center_min[0] - radius[0]:center_max[0] + radius[0] + 1, + center_min[1] - radius[1]:center_max[1] + radius[1] + 1, + center_min[2] - radius[2]:center_max[2] + radius[2] + 1] + + # Update the image and the ROI + image = Nifti1Image(image_data, affine=image.affine, header=image.header) + roi = Nifti1Image(roi_data, affine=roi.affine, header=roi.header) + + return image, roi + +def crop_box(image_data: np.ndarray, + roi_data: np.ndarray, + crop_shape: List[int], + center: Union[Sequence[int], None] = None) -> Tuple[np.ndarray, np.ndarray]: + """Crops the imaging data and the ROI mask. + + Args: + image_data (ndarray): Imaging data that will be cropped. + roi_data (ndarray): Mask data that will be cropped. + crop_shape (List[int]): The dimension of the region to crop in term of number of voxel. + center (Union[Sequence[int], None]): A list that indicate the center of the cropping box + in term of spatial position. + + Returns: + Tuple[ndarray, ndarray] : Two numpy arrays of the cropped image and roi + """ + assert np.sum(np.array(crop_shape) % 2) == 0, "All elements of crop_shape should be even number." + + radius = [int(x / 2) - 1 for x in crop_shape] + if center is None: + center = list(np.array(list(center_of_mass(roi_data))).astype(int)) + + center_min = np.floor(center).astype(int) + center_max = np.ceil(center).astype(int) + + # If center_max and center_min are equal we add 1 to center_max to avoid trouble with crop. + for i in range(3): + center_max[i] += 1 if center_max[i] == center_min[i] else 0 + + img_shape = image_data.shape + + # Pad the image and the ROI if its necessary + padding = [] + for rad, cent_min, cent_max, shape in zip(radius, center_min, center_max, img_shape): + padding.append( + [abs(min(cent_min - rad, 0)), max(cent_max + rad + 1 - shape, 0)] + ) + + image_data = np.pad(image_data, tuple([tuple(x) for x in padding])) + roi_data = np.pad(roi_data, tuple([tuple(x) for x in padding])) + + center_min = [center_min[i] + padding[i][0] for i in range(3)] + center_max = [center_max[i] + padding[i][0] for i in range(3)] + + # Crop the image + image_data = image_data[center_min[0] - radius[0]:center_max[0] + radius[0] + 1, + center_min[1] - radius[1]:center_max[1] + radius[1] + 1, + center_min[2] - radius[2]:center_max[2] + radius[2] + 1] + roi_data = roi_data[center_min[0] - radius[0]:center_max[0] + radius[0] + 1, + center_min[1] - radius[1]:center_max[1] + radius[1] + 1, + center_min[2] - radius[2]:center_max[2] + radius[2] + 1] + + return image_data, roi_data + +def compute_box(vol: np.ndarray, + roi: np.ndarray, + spatial_ref: imref3d, + box_string: str) -> Tuple[np.ndarray, + np.ndarray, + imref3d]: + """Computes a new box around the ROI (Region of interest) from the original box + and updates the volume and the ``spatial_ref``. + + Args: + vol (ndarray): ROI mask with values of 0 and 1. + roi (ndarray): ROI mask with values of 0 and 1. + spatial_ref (imref3d): imref3d object (same functionality of MATLAB imref3d class). + box_string (str): Specifies the new box to be computed + + * 'full': full imaging data as output. + * 'box': computes the smallest bounding box. + * Ex: 'box10' means 10 voxels in all three dimensions are added to the smallest bounding box. The number \ + after 'box' defines the number of voxels to add. + * Ex: '2box' computes the smallest box and outputs double its \ + size. The number before 'box' defines the multiplication in size. + + Returns: + 3-element tuple containing + + - ndarray: 3D array of imaging data defining the smallest box containing the ROI. + - ndarray: 3D array of 1's and 0's defining the ROI in ROIbox. + - imref3d: The associated imref3d object imaging data. + + Todo: + * I would not recommend parsing different settings into a string. \ + Provide two or more parameters instead, and use None if one or more \ + are not used. + * There is no else statement, so "new_spatial_ref" might be unset + """ + + if "box" in box_string: + comp = box_string == "box" + box_bound = compute_bounding_box(mask=roi) + if not comp: + # Always returns the first appearance + ind_box = box_string.find("box") + # Addition of a certain number of voxels in all dimensions + if ind_box == 0: + n_v = float(box_string[(ind_box+3):]) + n_v = np.array([n_v, n_v, n_v]).astype(int) + else: # Multiplication of the size of the box + factor = float(box_string[0:ind_box]) + size_box = np.diff(box_bound, axis=1) + 1 + new_box = size_box * factor + n_v = np.round((new_box - size_box)/2.0).astype(int) + + o_k = False + + while not o_k: + border = np.zeros([3, 2]) + border[0, 0] = box_bound[0, 0] - n_v[0] + border[0, 1] = box_bound[0, 1] + n_v[0] + border[1, 0] = box_bound[1, 0] - n_v[1] + border[1, 1] = box_bound[1, 1] + n_v[1] + border[2, 0] = box_bound[2, 0] - n_v[2] + border[2, 1] = box_bound[2, 1] + n_v[2] + border = border + 1 + check1 = np.sum(border[:, 0] > 0) + check2 = border[0, 1] <= vol.shape[0] + check3 = border[1, 1] <= vol.shape[1] + check4 = border[2, 1] <= vol.shape[2] + + check = check1 + check2 + check3 + check4 + + if check == 6: + o_k = True + else: + n_v = np.floor(n_v / 2.0) + if np.sum(n_v) == 0.0: + o_k = True + n_v = [0.0, 0.0, 0.0] + else: + # Will compute the smallest bounding box possible + n_v = [0.0, 0.0, 0.0] + + box_bound[0, 0] -= n_v[0] + box_bound[0, 1] += n_v[0] + box_bound[1, 0] -= n_v[1] + box_bound[1, 1] += n_v[1] + box_bound[2, 0] -= n_v[2] + box_bound[2, 1] += n_v[2] + + box_bound = box_bound.astype(int) + + vol = vol[box_bound[0, 0]:box_bound[0, 1] + 1, + box_bound[1, 0]:box_bound[1, 1] + 1, + box_bound[2, 0]:box_bound[2, 1] + 1] + roi = roi[box_bound[0, 0]:box_bound[0, 1] + 1, + box_bound[1, 0]:box_bound[1, 1] + 1, + box_bound[2, 0]:box_bound[2, 1] + 1] + + # Resolution in mm, nothing has changed here in terms of resolution; + # XYZ format here. + res = np.array([spatial_ref.PixelExtentInWorldX, + spatial_ref.PixelExtentInWorldY, + spatial_ref.PixelExtentInWorldZ]) + + # IJK, as required by imref3d + size_box = (np.diff(box_bound, axis=1) + 1).tolist() + size_box[0] = size_box[0][0] + size_box[1] = size_box[1][0] + size_box[2] = size_box[2][0] + x_limit, y_limit, z_limit = intrinsicToWorld(spatial_ref, + box_bound[0, 0], + box_bound[1, 0], + box_bound[2, 0]) + new_spatial_ref = imref3d(size_box, res[0], res[1], res[2]) + + # The limit is defined as the border of the first pixel + new_spatial_ref.XWorldLimits = new_spatial_ref.XWorldLimits - ( + new_spatial_ref.XWorldLimits[0] - (x_limit - res[0]/2)) + new_spatial_ref.YWorldLimits = new_spatial_ref.YWorldLimits - ( + new_spatial_ref.YWorldLimits[0] - (y_limit - res[1]/2)) + new_spatial_ref.ZWorldLimits = new_spatial_ref.ZWorldLimits - ( + new_spatial_ref.ZWorldLimits[0] - (z_limit - res[2]/2)) + + elif "full" in box_string: + new_spatial_ref = spatial_ref + + return vol, roi, new_spatial_ref + +def compute_bounding_box(mask:np.ndarray) -> np.ndarray: + """Computes the indexes of the ROI (Region of interest) enclosing box + in all dimensions. + + Args: + mask (ndarray): ROI mask with values of 0 and 1. + + Returns: + ndarray: An array containing the indexes of the bounding box. + """ + + indices = np.where(np.reshape(mask, np.size(mask), order='F') == 1) + iv, jv, kv = np.unravel_index(indices, np.shape(mask), order='F') + box_bound = np.zeros((3, 2)) + box_bound[0, 0] = np.min(iv) + box_bound[0, 1] = np.max(iv) + box_bound[1, 0] = np.min(jv) + box_bound[1, 1] = np.max(jv) + box_bound[2, 0] = np.min(kv) + box_bound[2, 1] = np.max(kv) + + return box_bound.astype(int) + +def get_roi(medscan: MEDscan, + name_roi: str, + box_string: str, + interp=False) -> Union[image_volume_obj, + image_volume_obj]: + """Computes the ROI box (box containing the region of interest) + and associated mask from MEDscan object. + + Args: + medscan (MEDscan): The MEDscan class object. + name_roi (str): name of the ROI since the a volume can have multuiple ROIs. + box_string (str): Specifies the size if the box containing the ROI + + - 'full': full imaging data as output. + - 'box': computes the smallest bounding box. + - Ex: 'box10': 10 voxels in all three dimensions are added to \ + the smallest bounding box. The number after 'box' defines the \ + number of voxels to add. + - Ex: '2box': Computes the smallest box and outputs double its \ + size. The number before 'box' defines the multiplication in size. + + interp (bool): True if we need to use an interpolation for box computation. + + Returns: + 2-element tuple containing + + - image_volume_obj: 3D array of imaging data defining box containing the ROI. \ + vol.data is the 3D array, vol.spatialRef is its associated imref3d object. + - image_volume_obj: 3D array of 1's and 0's defining the ROI. \ + roi.data is the 3D array, roi.spatialRef is its associated imref3d object. + """ + # PARSING OF ARGUMENTS + try: + name_structure_set = [] + delimiters = ["\+", "\-"] + n_contour_data = len(medscan.data.ROI.indexes) + + name_roi, vect_plus_minus = get_sep_roi_names(name_roi, delimiters) + contour_number = np.zeros(len(name_roi)) + + if name_structure_set is None: + name_structure_set = [] + + if name_structure_set: + name_structure_set, _ = get_sep_roi_names(name_structure_set, delimiters) + if len(name_roi) != len(name_structure_set): + raise ValueError( + "The numbers of defined ROI names and Structure Set names are not the same") + + for i in range(0, len(name_roi)): + for j in range(0, n_contour_data): + name_temp = medscan.data.ROI.get_roi_name(key=j) + if name_temp == name_roi[i]: + if name_structure_set: + # FOR DICOM + RTSTRUCT + name_set_temp = medscan.data.ROI.get_name_set(key=j) + if name_set_temp == name_structure_set[i]: + contour_number[i] = j + break + else: + contour_number[i] = j + break + + n_roi = np.size(contour_number) + # contour_string IS FOR EXAMPLE '3' or '1-3+2' + contour_string = str(contour_number[0].astype(int)) + + for i in range(1, n_roi): + if vect_plus_minus[i-1] == 1: + sign = '+' + elif vect_plus_minus[i-1] == -1: + sign = '-' + contour_string = contour_string + sign + \ + str(contour_number[i].astype(int)) + + if not (box_string == "full" or "box" in box_string): + raise ValueError( + "The third argument must either be \"full\" or contain the word \"box\".") + + if type(interp) != bool: + raise ValueError( + "If present (i.e. it is optional), the fourth argument must be bool") + + contour_number, operations = parse_contour_string(contour_string) + + # INTIALIZATIONS + if type(contour_number) is int: + n_contour = 1 + contour_number = [contour_number] + else: + n_contour = len(contour_number) + + roi_mask_list = [] + if medscan.type not in ["PTscan", "CTscan", "MRscan", "ADCscan"]: + raise ValueError("Unknown scan type.") + + spatial_ref = medscan.data.volume.spatialRef + vol = medscan.data.volume.array.astype(np.float32) + + # COMPUTING ALL MASKS + for c in np.arange(start=0, stop=n_contour): + contour = contour_number[c] + # GETTING THE XYZ POINTS FROM medscan + roi_xyz = medscan.data.ROI.get_indexes(key=contour).copy() + + # APPLYING ROTATION TO XYZ POINTS (if necessary --> MRscan) + if hasattr(medscan.data.volume, 'scan_rot') and medscan.data.volume.scan_rot is not None: + roi_xyz[:, [0, 1, 2]] = np.transpose( + medscan.data.volume.scan_rot @ np.transpose(roi_xyz[:, [0, 1, 2]])) + + # APPLYING TRANSLATION IF SIMULATION STRUCTURE AS INPUT + # (software STAMP utility) + if hasattr(medscan.data.volume, 'transScanToModel'): + translation = medscan.data.volume.transScanToModel + roi_xyz[:, 0] += translation[0] + roi_xyz[:, 1] += translation[1] + roi_xyz[:, 2] += translation[2] + + # COMPUTING THE ROI MASK + # Problem here in compute_roi.m: If the volume is a full-body CT and the + # slice interpolation process occurs, a lot of RAM will be used. + # One solution could be to a priori compute the bounding box before + # computing the ROI (using XYZ points). But we still want the user to + # be able to fully use the "box" argument, so we are fourré...TO SOLVE! + roi_mask_list += [compute_roi(roi_xyz=roi_xyz, + spatial_ref=spatial_ref, + orientation=medscan.data.orientation, + scan_type=medscan.type, + interp=interp).astype(np.float32)] + + # APPLYING OPERATIONS ON ALL MASKS + roi = roi_mask_list[0] + for c in np.arange(start=1, stop=n_contour): + if operations[c-1] == "+": + roi += roi_mask_list[c] + elif operations[c-1] == "-": + roi -= roi_mask_list[c] + else: + raise ValueError("Unknown operation on ROI.") + + roi[roi >= 1.0] = 1.0 + roi[roi < 1.0] = 0.0 + + # COMPUTING THE BOUNDING BOX + vol, roi, new_spatial_ref = compute_box(vol=vol, + roi=roi, + spatial_ref=spatial_ref, + box_string=box_string) + + # ARRANGE OUTPUT + vol_obj = image_volume_obj(data=vol, spatial_ref=new_spatial_ref) + roi_obj = image_volume_obj(data=roi, spatial_ref=new_spatial_ref) + + except Exception as e: + message = f"\n PROBLEM WITH PRE-PROCESSING OF FEATURES IN get_roi(): \n {e}" + _logger.error(message) + print(message) + + if medscan: + medscan.radiomics.image.update( + {('scale'+(str(medscan.params.process.scale_non_text[0])).replace('.', 'dot')): 'ERROR_PROCESSING'}) + + return vol_obj, roi_obj + +def compute_roi(roi_xyz: np.ndarray, + spatial_ref: imref3d, + orientation: str, + scan_type: str, + interp=False) -> np.ndarray: + """Computes the ROI (Region of interest) mask using the XYZ coordinates. + + Args: + roi_xyz (ndarray): array of (x,y,z) triplets defining a contour in the Patient-Based + Coordinate System extracted from DICOM RTstruct. + spatial_ref (imref3d): imref3d object (same functionality of MATLAB imref3d class). + orientation (str): Imaging data ``orientation`` (axial, sagittal or coronal). + scan_type (str): Imaging modality (MRscan, CTscan...). + interp (bool): Specifies if we need to use an interpolation \ + process prior to :func:`get_polygon_mask()` in the slice axis direction. + + - True: Interpolation is performed in the slice axis dimensions. To be further \ + tested, thus please use with caution (True is safer). + - False (default): No interpolation. This can definitely be safe \ + when the RTstruct has been saved specifically for the volume of \ + interest. + + Returns: + ndarray: 3D array of 1's and 0's defining the ROI mask. + + Todo: + - Using interpolation: this part needs to be further tested. + - Consider changing to 'if statement'. Changing ``interp`` variable here will change the ``interp`` variable everywhere + """ + + while interp: + # Initialization + if orientation == "Axial": + dim_ijk = 2 + dim_xyz = 2 + direction = "Z" + # Only the resolution in 'Z' will be changed + res_xyz = np.array([spatial_ref.PixelExtentInWorldX, + spatial_ref.PixelExtentInWorldY, 0.0]) + elif orientation == "Sagittal": + dim_ijk = 0 + dim_xyz = 1 + direction = "Y" + # Only the resolution in 'Y' will be changed + res_xyz = np.array([spatial_ref.PixelExtentInWorldX, 0.0, + spatial_ref.PixelExtentInWorldZ]) + elif orientation == "Coronal": + dim_ijk = 1 + dim_xyz = 0 + direction = "X" + # Only the resolution in 'X' will be changed + res_xyz = np.array([0.0, spatial_ref.PixelExtentInWorldY, + spatial_ref.PixelExtentInWorldZ]) + else: + raise ValueError( + "Provided orientation is not one of \"Axial\", \"Sagittal\", \"Coronal\".") + + # Creating new imref3d object for sample points (with slice dimension + # similar to original volume + # where RTstruct was created) + # Slice spacing in mm + slice_spacing = find_spacing( + roi_xyz[:, dim_ijk], scan_type).astype(np.float32) + + # Only one slice found in the function "find_spacing" on the above line. + # We thus must set "slice_spacing" to the slice spacing of the queried + # volume, and no interpolation will be performed. + if slice_spacing is None: + slice_spacing = spatial_ref.PixelExtendInWorld(axis=direction) + + new_size = round(spatial_ref.ImageExtentInWorld( + axis=direction) / slice_spacing) + res_xyz[dim_xyz] = slice_spacing + s_z = spatial_ref.ImageSize.copy() + s_z[dim_ijk] = new_size + + xWorldLimits = spatial_ref.XWorldLimits.copy() + yWorldLimits = spatial_ref.YWorldLimits.copy() + zWorldLimits = spatial_ref.ZWorldLimits.copy() + + new_spatial_ref = imref3d(imageSize=s_z, + pixelExtentInWorldX=res_xyz[0], + pixelExtentInWorldY=res_xyz[1], + pixelExtentInWorldZ=res_xyz[2], + xWorldLimits=xWorldLimits, + yWorldLimits=yWorldLimits, + zWorldLimits=zWorldLimits) + + diff = (new_spatial_ref.ImageExtentInWorld(axis=direction) - + spatial_ref.ImageExtentInWorld(axis=direction)) + + if np.abs(diff) >= 0.01: + # Sampled and queried volume are considered "different". + new_limit = spatial_ref.WorldLimits(axis=direction)[0] - diff / 2.0 + + # Sampled volume is now centered on queried volume. + new_spatial_ref.WorldLimits(axis=direction, newValue=(new_spatial_ref.WorldLimits(axis=direction) - + (new_spatial_ref.WorldLimits(axis=direction)[0] - + new_limit))) + else: + # Less than a 0.01 mm, sampled and queried volume are considered + # to be the same. At this point, + # spatial_ref and new_spatial_ref may have differed due to data + # manipulation, so we simply compute + # the ROI mask with spatial_ref (i.e. simply using "poly2mask.m"), + # without performing interpolation. + interp = False + break # Getting out of the "while" statement + + V = get_polygon_mask(roi_xyz, new_spatial_ref) + + # Getting query points (x_q,y_q,z_q) of output roi_mask + sz_q = spatial_ref.ImageSize + x_qi = np.arange(sz_q[0]) + y_qi = np.arange(sz_q[1]) + z_qi = np.arange(sz_q[2]) + x_qi, y_qi, z_qi = np.meshgrid(x_qi, y_qi, z_qi, indexing='ij') + + # Getting queried mask + v_q = interp3(V=V, x_q=x_qi, y_q=y_qi, z_q=z_qi, method="cubic") + roi_mask = v_q + roi_mask[v_q < 0.5] = 0 + roi_mask[v_q >= 0.5] = 1 + + # Getting out of the "while" statement + interp = False + + # SIMPLY USING "poly2mask.m" or "inpolygon.m". "inpolygon.m" is slower, but + # apparently more accurate. + if not interp: + # Using the inpolygon.m function. To be further tested. + roi_mask = get_polygon_mask(roi_xyz, spatial_ref) + + return roi_mask + +def find_spacing(points: np.ndarray, + scan_type: str) -> float: + """Finds the slice spacing in mm. + + Note: + This function works for points from at least 2 slices. If only + one slice is present, the function returns a None. + + Args: + points (ndarray): Array of (x,y,z) triplets defining a contour in the + Patient-Based Coordinate System extracted from DICOM RTstruct. + scan_type (str): Imaging modality (MRscan, CTscan...) + + Returns: + float: Slice spacing in mm. + """ + decim_keep = 4 # We keep at most 4 decimals to find the slice spacing. + + # Rounding to the nearest 0.1 mm, MRI is more problematic due to arbitrary + # orientations allowed for imaging volumes. + if scan_type == "MRscan": + slices = np.unique(np.around(points, 1)) + else: + slices = np.unique(np.around(points, 2)) + + n_slices = len(slices) + if n_slices == 1: + return None + + diff = np.abs(np.diff(slices)) + diff = np.round(diff, decim_keep) + slice_spacing, nOcc = mode(x=diff, return_counts=True) + if np.max(nOcc) == 1: + slice_spacing = np.mean(diff) + + return slice_spacing diff --git a/MEDiml/utils/__init__.py b/MEDiml/utils/__init__.py new file mode 100644 index 0000000..825f367 --- /dev/null +++ b/MEDiml/utils/__init__.py @@ -0,0 +1,25 @@ +from . import * +from .batch_patients import * +from .create_radiomics_table import * +from .data_frame_export import * +from .find_process_names import * +from .get_file_paths import * +from .get_full_rad_names import * +from .get_institutions_from_ids import * +from .get_patient_id_from_scan_name import * +from .get_patient_names import * +from .get_radiomic_names import * +from .get_scan_name_from_rad_name import * +from .image_reader_SITK import * +from .image_volume_obj import * +from .imref import * +from .initialize_features_names import * +from .inpolygon import * +from .interp3 import * +from .json_utils import * +from .mode import * +from .parse_contour_string import * +from .save_MEDscan import * +from .strfind import * +from .textureTools import * +from .write_radiomics_csv import * diff --git a/MEDiml/utils/batch_patients.py b/MEDiml/utils/batch_patients.py new file mode 100644 index 0000000..57ae128 --- /dev/null +++ b/MEDiml/utils/batch_patients.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import numpy as np + + +def batch_patients(n_patient: int, + n_batch: int) -> np.ndarray: + """Replaces volume intensities outside the ROI with NaN. + + Args: + n_patient (int): Number of patient. + n_batch (int): Number of batch, usually less or equal to the cores number on your machine. + + Returns: + ndarray: List of indexes with size n_batch and max value n_patient. + """ + + # FIND THE NUMBER OF PATIENTS IN EACH BATCH + patients = [0] * n_batch # np.zeros(n_batch, dtype=int) + patient_vect = np.random.permutation(n_patient) # To randomize stuff a bit. + if n_batch: + n_p = n_patient / n_batch + n_sup = np.ceil(n_p).astype(int) + n_inf = np.floor(n_p).astype(int) + if n_sup != n_inf: + n_sub_inf = n_batch - 1 + n_sub_sup = 1 + total = n_sub_inf*n_inf + n_sub_sup*n_sup + while total != n_patient: + n_sub_inf = n_sub_inf - 1 + n_sub_sup = n_sub_sup + 1 + total = n_sub_inf*n_inf + n_sub_sup*n_sup + + n_p = np.hstack((np.tile(n_inf, (1, n_sub_inf))[ + 0], np.tile(n_sup, (1, n_sub_sup))[0])) + else: # The number of patients in all batches will be the same + n_p = np.tile(n_sup, (1, n_batch))[0] + + start = 0 + for i in range(0, n_batch): + patients[i] = patient_vect[start:(start+n_p[i])].tolist() + start += n_p[i] + + return patients diff --git a/MEDiml/utils/create_radiomics_table.py b/MEDiml/utils/create_radiomics_table.py new file mode 100644 index 0000000..1323e5f --- /dev/null +++ b/MEDiml/utils/create_radiomics_table.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import logging +import random +from json import load +from pathlib import Path +from typing import Dict, List, Union + +import numpy as np +import pandas as pd + +from ..utils.get_patient_id_from_scan_name import get_patient_id_from_scan_name +from ..utils.initialize_features_names import initialize_features_names + + +def create_radiomics_table(radiomics_files_paths: List, image_space: str, log_file: Union[str, Path]) -> Dict: + """ + Creates a dictionary with a csv and other information + + Args: + radiomics_files_paths(List): List of paths to the radiomics JSON files. + image_space(str): String of the image space that contains the extracted features + log_file(Union[str, Path]): Path to logging file. + + Returns: + Dict: Dictionary containing the extracted radiomics and other info (patientID, feature names...) + """ + if log_file: + # Setting up logging settings + logging.basicConfig(filename=log_file, level=logging.DEBUG) + + # INITIALIZATIONS OF RADIOMICS STRUCTURES + n_files = len(radiomics_files_paths) + patientID = [0] * n_files + rad_structs = [0] * n_files + file_open = [False] * n_files + + for f in range(n_files): + with open(radiomics_files_paths[f], "r") as fp: + radStruct = load(fp) + rad_structs[f] = radStruct + file_open[f] = True + patientID[f] = get_patient_id_from_scan_name(radiomics_files_paths[f].stem) + + # INITIALIZE FEATURE NAMES + logging.info(f"\nnFiles: {n_files}") + non_text_cell = [] + text_cell = [] + while len(non_text_cell) == 0 and len(text_cell) == 0: + try: + rand_patient = np.floor(n_files * random.uniform(0, 1)).astype(int) + with open(radiomics_files_paths[rand_patient], "r") as fp: + radiomics_struct = load(fp) + + # IMAGE SPACE STRUCTURE --> .morph, .locInt, ..., .texture + image_space_struct = radiomics_struct[image_space] + non_text_cell, text_cell = initialize_features_names(image_space_struct) + except: + pass + + # CREATE TABLE DATA + features_name_dict = {} + str_table = '' + str_names = '||' + count_var = 0 + + # Non-texture features + for im_type in range(len(non_text_cell[0])): + for param in range(len(non_text_cell[2][im_type])): + for feat in range(len(non_text_cell[1][im_type])): + count_var = count_var + 1 + feature_name = 'radVar' + str(count_var) + features_name_dict.update({feature_name: [0] * n_files}) + real_name_feature = non_text_cell[0][im_type] + '__' + \ + non_text_cell[1][im_type][feat] + '__' + \ + non_text_cell[2][im_type][param] + str_table = str_table + feature_name + ',' + str_names = str_names + feature_name + ':' + real_name_feature + '||' + + for f in range(n_files): + if file_open[f]: + try: + val = rad_structs[f][image_space][ + non_text_cell[0][im_type]][ + non_text_cell[2][im_type][param]][ + non_text_cell[1][im_type][feat]] + except: + val = np.NaN + if type(val) in [str, list]: + val = np.NaN + else: + val = np.NaN + features_name_dict[feature_name][f] = val + + # Texture features + for im_type in range(len(text_cell[0])): + for param in range(len(text_cell[2][im_type])): + for feat in range(len(text_cell[1][im_type])): + count_var = count_var + 1 + feature_name = 'radVar' + str(count_var) + features_name_dict.update({feature_name: [0] * n_files}) + real_name_feature = text_cell[0][im_type] + '__' + \ + text_cell[1][im_type][feat] + '__' + \ + text_cell[2][im_type][param] + str_table = str_table + feature_name + ',' + str_names = str_names + feature_name + ':' + real_name_feature + '||' + for f in range(n_files): + if file_open[f]: + try: + val = rad_structs[f][image_space]['texture'][ + text_cell[0][im_type]][ + text_cell[2][im_type][param]][ + text_cell[1][im_type][feat]] + except: + val = np.NaN + if type(val) in [str, list]: + val = np.NaN + else: + val = np.NaN + features_name_dict[feature_name][f] = val + + radiomics_table_dict = { + 'Table': pd.DataFrame(features_name_dict, index=patientID), + 'Properties': {'UserData': str_names, + 'RowNames': patientID, + 'DimensionNames': ['PatientID', 'Variables'], + 'VariableNames': [key for key in features_name_dict.keys()] + }} + + return radiomics_table_dict diff --git a/MEDiml/utils/data_frame_export.py b/MEDiml/utils/data_frame_export.py new file mode 100644 index 0000000..0cd2b01 --- /dev/null +++ b/MEDiml/utils/data_frame_export.py @@ -0,0 +1,42 @@ +import os.path +from isort import file +import pandas as pd + +def export_table(file_name: file, + data: object): + """Export table + + Args: + file_name (file): name of the file + data (object): the data + + Returns: + None + """ + + if not isinstance(data, (pd.DataFrame, pd.Series)): + raise TypeError(f"The exported data should be a pandas DataFrame or Series. Found: {type(data)}") + + # Find the extension + ext = os.path.splitext(file_name)[1] + + # Set an index switch based on type of input + if isinstance(data, pd.DataFrame): + write_index = False + else: + write_index = True + + if ext == ".csv": + data.to_csv(path_or_buf=file_name, sep=";", index=write_index) + elif ext in [".xls", ".xlsx"]: + data.to_excel(excel_writer=file_name, index=write_index) + elif ext in [".tex"]: + with open(file=file_name, mode="w") as f: + data.to_latex(buf=f, index=write_index) + elif ext in [".html"]: + with open(file=file_name, mode="w") as f: + data.to_html(buf=f, index=write_index) + elif ext in [".json"]: + data.to_json(path_or_buf=file_name) + else: + raise ValueError(f"File extension not supported for export of table data. Recognised extensions are: \".csv\", \".xls\", \".xlsx\", \".tex\", \".html\" and \".json\". Found: {ext}") diff --git a/MEDiml/utils/find_process_names.py b/MEDiml/utils/find_process_names.py new file mode 100644 index 0000000..0349601 --- /dev/null +++ b/MEDiml/utils/find_process_names.py @@ -0,0 +1,16 @@ +from inspect import stack, getmodule +from typing import List + +def get_process_names() -> List: + """Get process names + + Returns: + List: process names + """ + module_names = ["none"] + for stack_entry in stack(): + current_module = getmodule(stack_entry[0]) + if current_module is not None: + module_names += [current_module.__name__] + + return module_names \ No newline at end of file diff --git a/MEDiml/utils/get_file_paths.py b/MEDiml/utils/get_file_paths.py new file mode 100644 index 0000000..dfd6c90 --- /dev/null +++ b/MEDiml/utils/get_file_paths.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from importlib.resources import path +from pathlib import Path +from typing import List, Union + + +def get_file_paths(path_to_parent_folder: Union[str, Path], wildcard: str=None) -> List[Path]: + """Finds all files in the given path that matches the pattern/wildcard. + + Note: + The search is done recursively in all subdirectories. + + Args: + path_to_parent_folder (Union[str, Path]): Full path to where the files are located. + wildcard (str, optional): String specifying which type of files + to locate in the parent folder. + - Ex : '*.dcm*', to look for dicom files. + + Returns: + List: List of full paths to files with the specific wildcard located \ + in the given path to parent folder. + """ + if wildcard is None: + wildcard = '*' + + # Getting the list of all files full path in file_paths + path_to_parent_folder = Path(path_to_parent_folder) + file_paths_list = list(path_to_parent_folder.rglob(wildcard)) + # for the name only put file.name + file_paths = [file for file in file_paths_list if file.is_file()] + + return file_paths diff --git a/MEDiml/utils/get_full_rad_names.py b/MEDiml/utils/get_full_rad_names.py new file mode 100644 index 0000000..6d94634 --- /dev/null +++ b/MEDiml/utils/get_full_rad_names.py @@ -0,0 +1,21 @@ +from typing import List + +import numpy as np + + +def get_full_rad_names(str_user_data: str, rad_var_ids: List): + """ + Returns the full real names of the radiomics variables (sequential names are not very informative) + Args: + str_user_data: string containing the full rad names + rad_var_ids: can get it by doing table.column.values + + Returns: + List: List of full radiomic names. + """ + full_rad_names = np.array([]) + for rad_var in rad_var_ids: + ind_var = int(rad_var[6:]) + full_rad_names = np.append(full_rad_names, str_user_data.split('||')[ind_var].split(':')[1]) + + return full_rad_names diff --git a/MEDiml/utils/get_institutions_from_ids.py b/MEDiml/utils/get_institutions_from_ids.py new file mode 100644 index 0000000..d3213d0 --- /dev/null +++ b/MEDiml/utils/get_institutions_from_ids.py @@ -0,0 +1,16 @@ +import pandas as pd + + +def get_institutions_from_ids(patient_ids): + """ + Extracts the institution strings from a cell of patient IDs. + + Args: + patient_ids (Any): Patient ID (string, list of strings or pandas Series). Ex: 'Cervix-CEM-010'. + + Returns: + str: Categorical vector, specifying the institution of each patient_id entry in "patient_ids". Ex: 'CEM'. + """ + if isinstance(patient_ids, list): + patient_ids = pd.Series(patient_ids) + return patient_ids.str.rsplit('-', expand=True)[1] diff --git a/MEDiml/utils/get_patient_id_from_scan_name.py b/MEDiml/utils/get_patient_id_from_scan_name.py new file mode 100644 index 0000000..8534a5a --- /dev/null +++ b/MEDiml/utils/get_patient_id_from_scan_name.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +def get_patient_id_from_scan_name(rad_name: str) -> str: + """ + Finds the patient id from the given string + + Args: + rad_name(str): Name of a scan or a radiomics structure + + Returns: + str: patient id + + Example: + >>> get_patient_id_from_scan_name('STS-McGill-001__T1(tumourAndEdema).MRscan') + STS-McGill-001 + """ + ind_double_under = rad_name.find('__') + patientID = rad_name[:ind_double_under] + + return patientID diff --git a/MEDiml/utils/get_patient_names.py b/MEDiml/utils/get_patient_names.py new file mode 100644 index 0000000..68f6ab6 --- /dev/null +++ b/MEDiml/utils/get_patient_names.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from typing import List + +import numpy as np + + +def get_patient_names(roi_names: np.ndarray) -> List[str]: + """Generates all file names for scans using CSV data. + + Args: + roi_names (ndarray): Array with CSV data organized as follows + [[patient_id], [imaging_scan_name], [imagning_modality]] + + Returns: + list[str]: List of scans files name. + """ + n_names = np.size(roi_names[0]) + patient_names = [0] * n_names + for n in range(0, n_names): + patient_names[n] = roi_names[0][n]+'__'+roi_names[1][n] + \ + '.'+roi_names[2][n]+'.npy' + + return patient_names diff --git a/MEDiml/utils/get_radiomic_names.py b/MEDiml/utils/get_radiomic_names.py new file mode 100644 index 0000000..80bdc59 --- /dev/null +++ b/MEDiml/utils/get_radiomic_names.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from typing import Dict +import numpy as np + + +def get_radiomic_names(roi_names: np.array, + roi_type: str) -> Dict: + """Generates radiomics names using ``roi_names`` and ``roi_types``. + + Args: + roi_names (np.array): array of the ROI names. + roi_type(str): string of the ROI. + + Returns: + dict: dict with the radiomic names + """ + + n_names = np.size(roi_names)[0] + radiomic_names = [0] * n_names + for n in range(0, n_names): + radiomic_names[n] = roi_names[n, 0]+'__'+roi_names[n, 1] + \ + '('+roi_type+').'+roi_names[n, 2]+'.npy' + + return radiomic_names diff --git a/MEDiml/utils/get_scan_name_from_rad_name.py b/MEDiml/utils/get_scan_name_from_rad_name.py new file mode 100644 index 0000000..1a6676e --- /dev/null +++ b/MEDiml/utils/get_scan_name_from_rad_name.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +def get_scan_name_from_rad_name(rad_name: str) -> str: + """Finds the imaging scan name from thr radiomics structure name + + Args: + rad_name (str): radiomics structure name. + + Returns: + str: String of the imaging scan name + + Example: + >>> get_scan_name_from_rad_name('STS-McGill-001__T1(tumourAndEdema).MRscan') + 'T1' + """ + ind_double_under = rad_name.find('__') + ind_open_par = rad_name.find('(') + scan_name = rad_name[ind_double_under + 2:ind_open_par] + + return scan_name \ No newline at end of file diff --git a/MEDiml/utils/image_reader_SITK.py b/MEDiml/utils/image_reader_SITK.py new file mode 100644 index 0000000..df8ed51 --- /dev/null +++ b/MEDiml/utils/image_reader_SITK.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from pathlib import Path +from typing import Dict, Union +import SimpleITK as sitk +import numpy as np + + +def image_reader_SITK(path: Path, + option: str=None) -> Union[Dict, None]: + """Return the image in a numpy array or a dictionary with the header of the image. + + Args: + path (path): path of the file + option (str): name of the option, either 'image' or 'header' + + Returns: + Union[Dict, None]: dictionary with the header of the image + """ + if option is None or option == 'image': + # return the image in a numpy array + return np.transpose(sitk.GetArrayFromImage(sitk.ReadImage(path))) + elif option == 'header': + # Return a dictionary with the header of the image. + reader = sitk.ImageFileReader() + reader.SetFileName(path) + # reader.LoadPrivateTagsOn() + reader.ReadImageInformation() + dic_im_header = {} + for key in reader.GetMetaDataKeys(): + dic_im_header.update({key: reader.GetMetaData(key)}) + return dic_im_header + else: + print("Argument option should be the string 'image' or 'header'") + return None diff --git a/MEDiml/utils/image_volume_obj.py b/MEDiml/utils/image_volume_obj.py new file mode 100644 index 0000000..d5f8664 --- /dev/null +++ b/MEDiml/utils/image_volume_obj.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +class image_volume_obj: + """Used to organize Imaging data and their corresponding imref3d object. + + Args: + data (ndarray, optional): 3D array of imaging data. + spatialRef (imref3d, optional): The corresponding imref3d object + (same functionality of MATLAB imref3d class). + + Attributes: + data (ndarray): 3D array of imaging data. + spatialRef (imref3d): The corresponding imref3d object + (same functionality of MATLAB imref3d class). + + """ + + def __init__(self, data=None, spatial_ref=None) -> None: + self.data = data + self.spatialRef = spatial_ref diff --git a/MEDiml/utils/imref.py b/MEDiml/utils/imref.py new file mode 100644 index 0000000..1d812eb --- /dev/null +++ b/MEDiml/utils/imref.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from typing import Tuple, Union + +import numpy as np + + +def intrinsicToWorld(R, xIntrinsic: float, yIntrinsic: float, zIntrinsic:float) -> Tuple[float, float, float]: + """Convert from intrinsic to world coordinates. + + Args: + R (imref3d): imref3d object (same functionality of MATLAB imref3d class) + xIntrinsic (float): Coordinates along the x-dimension in the intrinsic coordinate system + yIntrinsic (float): Coordinates along the y-dimension in the intrinsic coordinate system + zIntrinsic (float): Coordinates along the z-dimension in the intrinsic coordinate system + + Returns: + float: world coordinates + """ + return R.intrinsicToWorld(xIntrinsic=xIntrinsic, yIntrinsic=yIntrinsic, zIntrinsic=zIntrinsic) + + +def worldToIntrinsic(R, xWorld: float, yWorld: float, zWorld: float) -> Tuple[float, float, float] : + """Convert from world coordinates to intrinsic. + + Args: + R (imref3d): imref3d object (same functionality of MATLAB imref3d class) + xWorld (float): Coordinates along the x-dimension in the intrinsic coordinate system + yWorld (float): Coordinates along the y-dimension in the intrinsic coordinate system + zWorld (float): Coordinates along the z-dimension in the intrinsic coordinate system + + Returns: + _type_: intrinsic coordinates + """ + return R.worldToIntrinsic(xWorld=xWorld, yWorld=yWorld, zWorld=zWorld) + + +def sizes_match(R, A): + """Compares whether the two imref3d objects have the same size. + + Args: + R (imref3d): First imref3d object. + A (imref3d): Second imref3d object. + + Returns: + bool: True if ``R`` and ``A`` have the same size, and false if not. + + """ + return np.all(R.imageSize == A.imageSize) + + +class imref3d: + """This class mirrors the functionality of the matlab imref3d class + + An `imref3d object `_ + stores the relationship between the intrinsic coordinates + anchored to the columns, rows, and planes of a 3-D image and the spatial + location of the same column, row, and plane locations in a world coordinate system. + + The image is sampled regularly in the planar world-x, world-y, and world-z coordinates + of the coordinate system such that intrinsic-x, -y and -z values align with world-x, -y + and -z values, respectively. The resolution in each dimension can be different. + + Args: + ImageSize (ndarray, optional): Number of elements in each spatial dimension, + specified as a 3-element positive row vector. + PixelExtentInWorldX (float, optional): Size of a single pixel in the x-dimension + measured in the world coordinate system. + PixelExtentInWorldY (float, optional): Size of a single pixel in the y-dimension + measured in the world coordinate system. + PixelExtentInWorldZ (float, optional): Size of a single pixel in the z-dimension + measured in the world coordinate system. + xWorldLimits (ndarray, optional): Limits of image in world x, specified as a 2-element row vector, + [xMin xMax]. + yWorldLimits (ndarray, optional): Limits of image in world y, specified as a 2-element row vector, + [yMin yMax]. + zWorldLimits (ndarray, optional): Limits of image in world z, specified as a 2-element row vector, + [zMin zMax]. + + Attributes: + ImageSize (ndarray): Number of elements in each spatial dimension, + specified as a 3-element positive row vector. + PixelExtentInWorldX (float): Size of a single pixel in the x-dimension + measured in the world coordinate system. + PixelExtentInWorldY (float): Size of a single pixel in the y-dimension + measured in the world coordinate system. + PixelExtentInWorldZ (float): Size of a single pixel in the z-dimension + measured in the world coordinate system. + XIntrinsicLimits (ndarray): Limits of image in intrinsic units in the x-dimension, + specified as a 2-element row vector [xMin xMax]. + YIntrinsicLimits (ndarray): Limits of image in intrinsic units in the y-dimension, + specified as a 2-element row vector [yMin yMax]. + ZIntrinsicLimits (ndarray): Limits of image in intrinsic units in the z-dimension, + specified as a 2-element row vector [zMin zMax]. + ImageExtentInWorldX (float): Span of image in the x-dimension in + the world coordinate system. + ImageExtentInWorldY (float): Span of image in the y-dimension in + the world coordinate system. + ImageExtentInWorldZ (float): Span of image in the z-dimension in + the world coordinate system. + xWorldLimits (ndarray): Limits of image in world x, specified as a 2-element row vector, + [xMin xMax]. + yWorldLimits (ndarray): Limits of image in world y, specified as a 2-element row vector, + [yMin yMax]. + zWorldLimits (ndarray): Limits of image in world z, specified as a 2-element row vector, + [zMin zMax]. + """ + + def __init__(self, + imageSize=None, + pixelExtentInWorldX=1.0, + pixelExtentInWorldY=1.0, + pixelExtentInWorldZ=1.0, + xWorldLimits=None, + yWorldLimits=None, + zWorldLimits=None) -> None: + + # Check if imageSize is an ndarray, and cast to ndarray otherwise + self.ImageSize = self._parse_to_ndarray(x=imageSize, n=3) + + # Size of single voxels along axis in world coordinate system. + # Equivalent to voxel spacing. + self.PixelExtentInWorldX = pixelExtentInWorldX + self.PixelExtentInWorldY = pixelExtentInWorldY + self.PixelExtentInWorldZ = pixelExtentInWorldZ + + # Limits of the image in intrinsic coordinates + # AZ: this differs from DICOM, which assumes that the origin lies + # at the center of the first voxel. + if imageSize is not None: + self.XIntrinsicLimits = np.array([-0.5, imageSize[0]-0.5]) + self.YIntrinsicLimits = np.array([-0.5, imageSize[1]-0.5]) + self.ZIntrinsicLimits = np.array([-0.5, imageSize[2]-0.5]) + else: + self.XIntrinsicLimits = None + self.YIntrinsicLimits = None + self.ZIntrinsicLimits = None + + # Size of the image in world coordinates + if imageSize is not None: + self.ImageExtentInWorldX = imageSize[0] * pixelExtentInWorldX + self.ImageExtentInWorldY = imageSize[1] * pixelExtentInWorldY + self.ImageExtentInWorldZ = imageSize[2] * pixelExtentInWorldZ + else: + self.ImageExtentInWorldX = None + self.ImageExtentInWorldY = None + self.ImageExtentInWorldZ = None + + # Limits of the image in the world coordinates + self.XWorldLimits = self._parse_to_ndarray(x=xWorldLimits, n=2) + self.YWorldLimits = self._parse_to_ndarray(x=yWorldLimits, n=2) + self.ZWorldLimits = self._parse_to_ndarray(x=zWorldLimits, n=2) + + if xWorldLimits is None and imageSize is not None: + self.XWorldLimits = np.array([0.0, self.ImageExtentInWorldX]) + if yWorldLimits is None and imageSize is not None: + self.YWorldLimits = np.array([0.0, self.ImageExtentInWorldY]) + if zWorldLimits is None and imageSize is not None: + self.ZWorldLimits = np.array([0.0, self.ImageExtentInWorldZ]) + + def _parse_to_ndarray(self, + x: np.iterable, + n=None) -> np.ndarray: + """Internal function to cast input to a numpy array. + + Args: + x (iterable): Object that supports __iter__. + n (int, optional): expected length. + + Returns: + ndarray: iterable input as a numpy array. + """ + if x is not None: + # Cast to ndarray + if not isinstance(x, np.ndarray): + x = np.array(x) + + # Check length + if n is not None: + if not len(x) == n: + raise ValueError( + "Length of array does not meet the expected length.", len(x), n) + + return x + + def intrinsicToWorld(self, + xIntrinsic: np.ndarray, + yIntrinsic: np.ndarray, + zIntrinsic: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Convert from intrinsic to world coordinates. + + Args: + xIntrinsic (ndarray): Coordinates along the x-dimension in the intrinsic coordinate system. + yIntrinsic (ndarray): Coordinates along the y-dimension in the intrinsic coordinate system. + zIntrinsic (ndarray): Coordinates along the z-dimension in the intrinsic coordinate system. + + Returns: + Tuple[np.ndarray, np.ndarray, np.ndarray]: [xWorld, yWorld, zWorld] in world coordinate system. + """ + xWorld = (self.XWorldLimits[0] + 0.5*self.PixelExtentInWorldX) + \ + xIntrinsic * self.PixelExtentInWorldX + yWorld = (self.YWorldLimits[0] + 0.5*self.PixelExtentInWorldY) + \ + yIntrinsic * self.PixelExtentInWorldY + zWorld = (self.ZWorldLimits[0] + 0.5*self.PixelExtentInWorldZ) + \ + zIntrinsic * self.PixelExtentInWorldZ + + return xWorld, yWorld, zWorld + + def worldToIntrinsic(self, + xWorld: np.ndarray, + yWorld: np.ndarray, + zWorld: np.ndarray)-> Union[np.ndarray, + np.ndarray, + np.ndarray]: + """Converts from world coordinates to intrinsic coordinates. + + Args: + xWorld (ndarray): Coordinates along the x-dimension in the world coordinate system. + yWorld (ndarray): Coordinates along the y-dimension in the world coordinate system. + zWorld (ndarray): Coordinates along the z-dimension in the world coordinate system. + + Returns: + ndarray: [xIntrinsic,yIntrinsic,zIntrinsic] in intrinsic coordinate system. + """ + + xIntrinsic = ( + xWorld - (self.XWorldLimits[0] + 0.5*self.PixelExtentInWorldX)) / self.PixelExtentInWorldX + yIntrinsic = ( + yWorld - (self.YWorldLimits[0] + 0.5*self.PixelExtentInWorldY)) / self.PixelExtentInWorldY + zIntrinsic = ( + zWorld - (self.ZWorldLimits[0] + 0.5*self.PixelExtentInWorldZ)) / self.PixelExtentInWorldZ + + return xIntrinsic, yIntrinsic, zIntrinsic + + def contains_point(self, + xWorld: np.ndarray, + yWorld: np.ndarray, + zWorld: np.ndarray) -> np.ndarray: + """Determines which points defined by ``xWorld``, ``yWorld`` and ``zWorld``. + + Args: + xWorld (ndarray): Coordinates along the x-dimension in the world coordinate system. + yWorld (ndarray): Coordinates along the y-dimension in the world coordinate system. + zWorld (ndarray): Coordinates along the z-dimension in the world coordinate system. + + Returns: + ndarray: boolean array for coordinate sets that are within the bounds of the image. + """ + xInside = np.logical_and( + xWorld >= self.XWorldLimits[0], xWorld <= self.XWorldLimits[1]) + yInside = np.logical_and( + yWorld >= self.YWorldLimits[0], yWorld <= self.YWorldLimits[1]) + zInside = np.logical_and( + zWorld >= self.ZWorldLimits[0], zWorld <= self.ZWorldLimits[1]) + + return xInside + yInside + zInside == 3 + + def WorldLimits(self, + axis=None, + newValue=None) -> Union[np.ndarray, None]: + """Sets the WorldLimits to the new value for the given ``axis``. + If the newValue is None, the method returns the attribute value. + + Args: + axis (str, optional): Specify the dimension, must be 'X', 'Y' or 'Z'. + newValue (iterable, optional): New value for the WorldLimits attribute. + + Returns: + ndarray: Limits of image in world along the axis-dimension. + """ + if newValue is None: + # Get value + if axis == "X": + return self.XWorldLimits + elif axis == "Y": + return self.YWorldLimits + elif axis == "Z": + return self.ZWorldLimits + else: + # Set value + if axis == "X": + self.XWorldLimits = self._parse_to_ndarray(x=newValue, n=2) + elif axis == "Y": + self.YWorldLimits = self._parse_to_ndarray(x=newValue, n=2) + elif axis == "Z": + self.ZWorldLimits = self._parse_to_ndarray(x=newValue, n=2) + + def PixelExtentInWorld(self, axis=None) -> Union[float, None]: + """Returns the PixelExtentInWorld attribute value for the given ``axis``. + + Args: + axis (str, optional): Specify the dimension, must be 'X', 'Y' or 'Z'. + + Returns: + float: Size of a single pixel in the axis-dimension measured in the world coordinate system. + """ + if axis == "X": + return self.PixelExtentInWorldX + elif axis == "Y": + return self.PixelExtentInWorldY + elif axis == "Z": + return self.PixelExtentInWorldZ + + def IntrinsicLimits(self, + axis=None) -> Union[np.ndarray, + None]: + """Returns the IntrinsicLimits attribute value for the given ``axis``. + + Args: + axis (str, optional): Specify the dimension, must be 'X', 'Y' or 'Z'. + + Returns: + ndarray: Limits of image in intrinsic units in the axis-dimension, specified as a 2-element row vector [xMin xMax]. + """ + if axis == "X": + return self.XIntrinsicLimits + elif axis == "Y": + return self.YIntrinsicLimits + elif axis == "Z": + return self.ZIntrinsicLimits + + def ImageExtentInWorld(self, + axis=None) -> Union[float, + None]: + """Returns the ImageExtentInWorld attribute value for the given ``axis``. + + Args: + axis (str, optional): Specify the dimension, must be 'X', 'Y' or 'Z'. + + Returns: + ndarray: Span of image in the axis-dimension in the world coordinate system. + + """ + if axis == "X": + return self.ImageExtentInWorldX + elif axis == "Y": + return self.ImageExtentInWorldY + elif axis == "Z": + return self.ImageExtentInWorldZ diff --git a/MEDiml/utils/initialize_features_names.py b/MEDiml/utils/initialize_features_names.py new file mode 100644 index 0000000..098887c --- /dev/null +++ b/MEDiml/utils/initialize_features_names.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from typing import Dict, List, Tuple + + +def initialize_features_names(image_space_struct: Dict) -> Tuple[List, List]: + """Finds all the features names from `image_space_struct` + + Args: + image_space_struct(Dict): Dictionary of the extracted features (Texture & Non-texture) + + Returns: + Tuple[List, List]: Two lists of the texture and non-texture features names found in the `image_space_struct`. + """ + # First entry is the names of feature types. Second entry is the name of + # the features for a given feature type. Third entry is the name of the + # extraction parameters for all features of a given feature type. + non_text_cell = [0] * 3 + # First entry is the names of feature types. Second entry is the name of + # the features for a given feature type. Third entry is the name of the + # extraction parameters for all features of a given feature type. + text_cell = [0] * 3 + + # NON-TEXTURE FEATURES + field_non_text = [key for key in image_space_struct.keys() if key != 'texture'] + n_non_text_type = len(field_non_text) + non_text_cell[0] = field_non_text + non_text_cell[1] = [0] * n_non_text_type + non_text_cell[2] = [0] * n_non_text_type + + for t in range(0, n_non_text_type): + dic_image_space_struct_non_text = image_space_struct[non_text_cell[0][t]] + field_params_non_text = [ + key for key in dic_image_space_struct_non_text.keys()] + dic_image_space_struct_params_non_text = image_space_struct[non_text_cell[0] + [t]][field_params_non_text[0]] + field_feat_non_text = [ + key for key in dic_image_space_struct_params_non_text.keys()] + non_text_cell[1][t] = field_feat_non_text + non_text_cell[2][t] = field_params_non_text + + # TEXTURE FEATURES + dic_image_space_struct_texture = image_space_struct['texture'] + field_text = [key for key in dic_image_space_struct_texture.keys()] + n_text_type = len(field_text) + text_cell[0] = field_text + text_cell[1] = [0] * n_text_type + text_cell[2] = [0] * n_text_type + + for t in range(0, n_text_type): + dic_image_space_struct_text = image_space_struct['texture'][text_cell[0][t]] + field_params_text = [key for key in dic_image_space_struct_text.keys()] + dic_image_space_struct_params_text = image_space_struct['texture'][text_cell[0] + [t]][field_params_text[0]] + field_feat_text = [ + key for key in dic_image_space_struct_params_text.keys()] + text_cell[1][t] = field_feat_text + text_cell[2][t] = field_params_text + + return non_text_cell, text_cell diff --git a/MEDiml/utils/inpolygon.py b/MEDiml/utils/inpolygon.py new file mode 100644 index 0000000..e0e7a68 --- /dev/null +++ b/MEDiml/utils/inpolygon.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +import numpy as np + + +def inpolygon(x_q: np.ndarray, + y_q: np.ndarray, + x_v: np.ndarray, + y_v: np.ndarray) -> np.ndarray: + """Implements similar functionality MATLAB inpolygon. + Finds points located inside or on edge of polygonal region. + + Note: + Unlike matlab inpolygon, this function does not determine the + status of single points :math:`(x_q, y_q)`. Instead, it determines the + status for an entire grid by ray-casting. + + Args: + x_q (ndarray): x-coordinates of query points, in intrinsic reference system. + y_q (ndarray): y-coordinates of query points, in intrinsic reference system. + x_q (ndarray): x-coordinates of polygon vertices, in intrinsic reference system. + y_q (ndarray): y-coordinates of polygon vertices, in intrinsic reference system. + + Returns: + ndarray: boolean array indicating if the query points are on the edge of the polygon area. + + """ + def ray_line_intersection(ray_orig, ray_dir, vert_1, vert_2): + """ + + """ + epsilon = 0.000001 + + # Define edge + edge_line = vert_1 - vert_2 + + # Define ray vertices + r_vert_1 = ray_orig + r_vert_2 = ray_orig + ray_dir + edge_ray = - ray_dir + + # Calculate determinant - if close to 0, lines are parallel and will + # not intersect + det = np.cross(edge_ray, edge_line) + if (det > -epsilon) and (det < epsilon): + return np.nan + + # Calculate inverse of the determinant + inv_det = 1.0 / det + + # Calculate determinant + a11 = np.cross(r_vert_1, r_vert_2) + a21 = np.cross(vert_1, vert_2) + + # Solve for x + a12 = edge_ray[0] + a22 = edge_line[0] + x = np.linalg.det(np.array([[a11, a12], [a21, a22]])) * inv_det + + # Solve for y + b12 = edge_ray[1] + b22 = edge_line[1] + y = np.linalg.det(np.array([[a11, b12], [a21, b22]])) * inv_det + + t = np.array([x, y]) + + # Check whether the solution falls within the line segment + u1 = np.around(np.dot(edge_line, edge_line), 5) + u2 = np.around(np.dot(edge_line, vert_1-t), 5) + if (u2 / u1) < 0.0 or (u2 / u1) > 1.0: + return np.nan + + # Return scalar length from ray origin + t_scal = np.linalg.norm(ray_orig - t) + + return t_scal + + # These are hacks to actually make this function work + spacing = np.array([1.0, 1.0]) + origin = np.array([0.0, 0.0]) + shape = np.array([np.max(x_q) + 1, np.max(y_q) + 1]) + # shape = np.array([np.max(x_q), np.max(y_q)]) Original from Alex + vertices = np.vstack((x_v, y_v)).transpose() + lines = np.vstack( + ([np.arange(0, len(x_v))], [np.arange(-1, len(x_v) - 1)])).transpose() + + # Set up line vertices + vertex_a = vertices[lines[:, 0], :] + vertex_b = vertices[lines[:, 1], :] + + # Remove lines with length 0 and center on the origin + line_mask = np.sum(np.abs(vertex_a - vertex_b), axis=1) > 0.0 + vertex_a = vertex_a[line_mask] - origin + vertex_b = vertex_b[line_mask] - origin + + # Find extent of contours in x + x_min_ind = int( + np.max([np.floor(np.min(vertices[:, 0]) / spacing[0]), 0.0])) + x_max_ind = int( + np.min([np.ceil(np.max(vertices[:, 0]) / spacing[0]), shape[0] * 1.0])) + + # Set up voxel grid and y-span + vox_grid = np.zeros(shape, dtype=int) + vox_span = origin[1] + np.arange(0, shape[1]) * spacing[1] + + # Set ray origin and direction (starts at negative y, and travels towards + # positive y + ray_origin = np.array([0.0, -1.0]) + ray_dir = np.array([0.0, 1.0]) + + for x_ind in np.arange(x_min_ind, x_max_ind): + # Update ray origin + ray_origin[0] = origin[0] + x_ind * spacing[0] + + # Scan both forward and backward to resolve points located on + # the polygon + vox_col_frwd = np.zeros(np.shape(vox_span), dtype=int) + vox_col_bkwd = np.zeros(np.shape(vox_span), dtype=int) + + # Find lines that are intersected by the ray + ray_hit = np.sum( + np.sign(np.vstack((vertex_a[:, 0], vertex_b[:, 0])) - ray_origin[0]), axis=0) + + # If the ray crosses a vertex, the sum of the sign is 0 when the ray + # does not hit an vertex point, and -1 or 1 when it does. + # In the latter case, we only keep of the vertices for each hit. + simplex_mask = np.logical_or(ray_hit == 0, ray_hit == 1) + + # Go to next iterator if mask is empty + if np.sum(simplex_mask) == 0: + continue + + # Determine the selected vertices + selected_verts = np.squeeze(np.where(simplex_mask)) + + # Find intercept of rays with lines + t_scal = np.array([ray_line_intersection(ray_orig=ray_origin, ray_dir=ray_dir, + vert_1=vertex_a[ii, :], vert_2=vertex_b[ii, :]) for ii in selected_verts]) + + # Remove invalid results + t_scal = t_scal[np.isfinite(t_scal)] + if t_scal.size == 0: + continue + + # Update vox_col based on t_scal. This basically adds a 1 for all + # voxels that lie behind the line intersections + # of the ray. + for t_curr in t_scal: + vox_col_frwd[vox_span > t_curr + ray_origin[1]] += 1 + for t_curr in t_scal: + vox_col_bkwd[vox_span < t_curr + ray_origin[1]] += 1 + + # Voxels in the roi cross an uneven number of meshes from the origin + vox_grid[x_ind, + :] += np.logical_and(vox_col_frwd % 2, vox_col_bkwd % 2) + + return vox_grid.astype(dtype=bool) diff --git a/MEDiml/utils/interp3.py b/MEDiml/utils/interp3.py new file mode 100644 index 0000000..1291c85 --- /dev/null +++ b/MEDiml/utils/interp3.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import numpy as np +from scipy.ndimage import map_coordinates + + +def interp3(v, x_q, y_q, z_q, method) -> np.ndarray: + """`Interpolation for 3-D gridded data `_\ + in meshgrid format, implements similar functionality MATLAB interp3. + + Args: + X, Y, Z (ndarray) : Query points, should be intrinsic coordinates. + method (str): {nearest, linear, spline, cubic}, Interpolation ``method``. + + Returns: + ndarray: Array of interpolated values. + + Raises: + ValueError: If ``method`` is not 'nearest', 'linear', 'spline' or 'cubic'. + + """ + + # Parse method + if method == "nearest": + spline_order = 0 + elif method == "linear": + spline_order = 1 + elif method in ["spline", "cubic"]: + spline_order = 3 + else: + raise ValueError("Interpolator not implemented.") + + size = np.size(x_q) + coord_X = np.reshape(x_q, size, order='F') + coord_Y = np.reshape(y_q, size, order='F') + coord_Z = np.reshape(z_q, size, order='F') + coordinates = np.array([coord_X, coord_Y, coord_Z]).astype(np.float32) + v_q = map_coordinates(input=v.astype( + np.float32), coordinates=coordinates, order=spline_order, mode='nearest') + v_q = np.reshape(v_q, np.shape(x_q), order='F') + + return v_q diff --git a/MEDiml/utils/json_utils.py b/MEDiml/utils/json_utils.py new file mode 100644 index 0000000..355a015 --- /dev/null +++ b/MEDiml/utils/json_utils.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +import json +import pathlib +from typing import Dict + + +def _is_jsonable(data: any, cls: object) -> bool: + """Checks if the given ``data`` is JSON serializable. + + Args: + data (Any): ``Data`` that will be checked. + cls(object, optional): Costum JSONDecoder subclass. If not specified JSONDecoder is used. + + Returns: + bool: True if the given ``data`` is serializable, False if not. + """ + try: + json.dumps(data, cls=cls) + return True + except (TypeError, OverflowError): + return False + + +def posix_to_string(dictionnary: Dict) -> Dict: + """Converts all Pathlib.Path to str [Pathlib is not serializable]. + + Args: + dictionnary (Dict): dict with Pathlib.Path values to convert. + + Returns: + Dict: ``dictionnary`` with all Pathlib.Path converted to str. + """ + for key, value in dictionnary.items(): + if type(value) is dict: + value = posix_to_string(value) + else: + if issubclass(type(value), (pathlib.WindowsPath, pathlib.PosixPath, pathlib.Path)): + dictionnary[key] = str(value) + + return dictionnary + +def load_json(file_path: pathlib.Path) -> Dict: + """Wrapper to json.load function. + + Args: + file_path (Path): Path of the json file to load. + + Returns: + Dict: The loaded json file. + + """ + with open(file_path, 'r') as fp: + return json.load(fp) + + +def save_json(file_path: pathlib.Path, data: any, cls=None) -> None: + """Wrapper to json.dump function. + + Args: + file_path (Path): Path to write the json file to. + data (Any): Data to write to the given path. Must be serializable by JSON. + cls(object, optional): Costum JSONDecoder subclass. If not specified JSONDecoder is used. + + Returns: + None: saves the ``data`` in JSON file to the ``file_path``. + + Raises: + TypeError: If ``data`` is not JSON serializable. + """ + if _is_jsonable(data, cls): + with open(file_path, 'w') as fp: + json.dump(data, fp, indent=4, cls=cls) + else: + raise TypeError("The given data is not JSON serializable. \ + We rocommend using a costum encoder.") diff --git a/MEDiml/utils/mode.py b/MEDiml/utils/mode.py new file mode 100644 index 0000000..35aac31 --- /dev/null +++ b/MEDiml/utils/mode.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from typing import Tuple, Union + +import numpy as np + + +def mode(x: np.ndarray, + return_counts=False) -> Union[Tuple[np.ndarray, np.ndarray], + np.ndarray]: + """Implementation of mode that also returns counts, unlike the standard statistics.mode. + + Args: + x (ndarray): n-dimensional array of which to find mode. + return_counts (bool): If True, also return the number of times each unique item appears in ``x``. + + Returns: + 2-element tuple containing + + - ndarray: Array of the modal (most common) value in the given array. + - ndarray: Array of the counts if ``return_counts`` is True. + """ + + unique_values, counts = np.unique(x, return_counts=True) + + if return_counts: + return unique_values[np.argmax(counts)], np.max(counts) + + return unique_values[np.argmax(counts)] diff --git a/MEDiml/utils/parse_contour_string.py b/MEDiml/utils/parse_contour_string.py new file mode 100644 index 0000000..675d465 --- /dev/null +++ b/MEDiml/utils/parse_contour_string.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from typing import List, Tuple, Union + +import numpy as np + +from ..utils.strfind import strfind + + +def parse_contour_string(contour_string) -> Union[Tuple[float, List[str]], + Tuple[int, List[str]], + Tuple[List[int], List[str]]]: + """Finds the delimeters (:math:`'+'` and :math:`'-'`) and the contour indexe(s) from the given string. + + Args: + contour_string (str, float or int): Index or string of indexes with + delimeters. For example: :math:`'3'` or :math:`'1-3+2'`. + + Returns: + float, int: If ``contour_string`` is a an int or float we return ``contour_string``. + List[str]: List of the delimeters. + List[int]: List of the contour indexes. + + Example: + >>> ``contour_string`` = '1-3+2' + >>> :function: parse_contour_string(contour_string) + [1, 2, 3], ['+', '-'] + >>> ``contour_string`` = 1 + >>> :function: parse_contour_string(contour_string) + 1, [] + """ + + if isinstance(contour_string, (int, float)): + return contour_string, [] + + ind_plus = strfind(string=contour_string, pattern='\+') + ind_minus = strfind(string=contour_string, pattern='\-') + ind_operations = np.sort(np.hstack((ind_plus, ind_minus))).astype(int) + + # Parsing operations and contour numbers + # AZ: I assume that contour_number is an integer + if ind_operations.size == 0: + operations = [] + contour_number = [int(contour_string)] + else: + n_op = len(ind_operations) + operations = [contour_string[ind_operations[i]] for i in np.arange(n_op)] + + contour_number = np.zeros(n_op + 1, dtype=int) + contour_number[0] = int(contour_string[0:ind_operations[0]]) + for c in np.arange(start=1, stop=n_op): + contour_number[c] = int(contour_string[(ind_operations[c-1]+1) : ind_operations[c]]) + + contour_number[-1] = int(contour_string[(ind_operations[-1]+1):]) + contour_number.tolist() + + return contour_number, operations diff --git a/MEDiml/utils/save_MEDscan.py b/MEDiml/utils/save_MEDscan.py new file mode 100644 index 0000000..2a71cfe --- /dev/null +++ b/MEDiml/utils/save_MEDscan.py @@ -0,0 +1,30 @@ +import pickle +from pathlib import Path + +from ..MEDscan import MEDscan + + +def save_MEDscan(medscan: MEDscan, + path_save: Path) -> str: + """Saves MEDscan class instance in a pickle object + + Args: + medscan (MEDscan): MEDscan instance + path_save (Path): MEDscan instance saving paths + + Returns: + None. + """ + + series_description = medscan.series_description.translate({ord(ch): '-' for ch in '/\\ ()&:*'}) + name_id = medscan.patientID + name_id = name_id.translate({ord(ch): '-' for ch in '/\\ ()&:*'}) + + # final saving name + name_complete = name_id + '__' + series_description + '.' + medscan.type + '.npy' + + # save + with open(path_save / name_complete,'wb') as f: + pickle.dump(medscan, f) + + return name_complete diff --git a/MEDiml/utils/strfind.py b/MEDiml/utils/strfind.py new file mode 100644 index 0000000..bf3ec15 --- /dev/null +++ b/MEDiml/utils/strfind.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from re import finditer +from typing import List + + +def strfind(pattern: str, + string: str) -> List[int]: + """Finds indices of ``pattern`` in ``string``. Based on regex. + + Note: + Be careful with + and - symbols. Use :math:`\+` and :math:`\-` instead. + + Args: + pattern (str): Substring to be searched in the ``string``. + string (str): String used to find matches. + + Returns: + List[int]: List of indexes of every occurence of ``pattern`` in the passed ``string``. + + Raises: + ValueError: If the ``pattern`` does not use backslash with special regex symbols + """ + + if pattern in ('+', '-'): + raise ValueError( + "Please use a backslash with special regex symbols in findall.") + + ind = [m.start() for m in finditer(pattern, string)] + + return ind diff --git a/MEDiml/utils/textureTools.py b/MEDiml/utils/textureTools.py new file mode 100644 index 0000000..7d5ff52 --- /dev/null +++ b/MEDiml/utils/textureTools.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from typing import List, Union + +import numpy as np + + +def get_neighbour_direction(d=1.8, + distance="euclidian", + centre=False, + complete=False, + dim3=True) -> np.ndarray: + """Defines transitions to neighbour voxels. + + Note: + This code was adapted from the in-house radiomics software created at + OncoRay, Dresden, Germany. + + Args: + d (float, optional): Max ``distance`` between voxels. + distance (str, optional): Distance norm used to compute distances. must be + "manhattan", "l1", "l_1", "euclidian", "l2", "l_2", "chebyshev", "linf" or "l_inf". + centre (bool, optional): Flags whether the [0,0,0] direction should be included + complete(bool, optional): Flags whether all directions should be computed (True) + or just the primary ones (False). For example, including [0,0,1] and [0,0,-1] + directions may lead to redundant texture matrices. + dim3(bool, optional): flags whether full 3D (True) or only in-slice (2D; False) + directions should be considered. + + Returns: + ndarray: set of k neighbour direction vectors. + """ + + # Base transition vector + trans = np.arange(start=-np.ceil(d), stop=np.ceil(d)+1) + n = np.size(trans) + + # Build transition array [x,y,z] + nbrs = np.array([rep(x=trans, each=n * n, times=1), + rep(x=trans, each=n, times=n), + rep(x=trans, each=1, times=n * n)], dtype=np.int32) + + # Initiate maintenance index + index = np.zeros(np.shape(nbrs)[1], dtype=bool) + + # Remove neighbours more than distance d from the center ---------------- + + # Manhattan distance + if distance.lower() in ["manhattan", "l1", "l_1"]: + index = np.logical_or(index, np.sum(np.abs(nbrs), axis=0) <= d) + # Eucldian distance + if distance.lower() in ["euclidian", "l2", "l_2"]: + index = np.logical_or(index, np.sqrt( + np.sum(np.multiply(nbrs, nbrs), axis=0)) <= d) + # Chebyshev distance + if distance.lower() in ["chebyshev", "linf", "l_inf"]: + index = np.logical_or(index, np.max(np.abs(nbrs), axis=0) <= d) + + # Check if centre voxel [0,0,0] should be maintained; False indicates removal + if centre is False: + index = np.logical_and(index, (np.sum(np.abs(nbrs), axis=0)) > 0) + + # Check if a complete neighbourhood should be returned + # False indicates that only half of the vectors are returned + if complete is False: + index[np.arange(start=0, stop=len(index)//2 + 1)] = False + + # Check if neighbourhood should be 3D or 2D + if dim3 is False: + index[nbrs[2, :] != 0] = False + + return nbrs[:, index] + + +def rep(x: np.ndarray, + each=1, + times=1) -> np.ndarray: + """Replicates the values in ``x``. + Replicates the :func:`"rep"` function found in R for tiling and repeating vectors. + + Note: + Code was adapted from the in-house radiomics software created at OncoRay, + Dresden, Germany. + + Args: + x (ndarray): Array to replicate. + each (int): Integer (non-negative) giving the number of times to repeat + each element of the passed array. + times (int): Integer (non-negative). Each element of ``x`` is repeated each times. + + Returns: + ndarray: Array with same values but replicated. + """ + + each = int(each) + times = int(times) + + if each > 1: + x = np.repeat(x, repeats=each) + + if times > 1: + x = np.tile(x, reps=times) + + return x + +def get_value(x: np.ndarray, + index: int, + replace_invalid=True) -> np.ndarray: + """Retrieves intensity values from an image intensity table used for computing + texture features. + + Note: + Code was adapted from the in-house radiomics software created at OncoRay, + Dresden, Germany. + + Args: + x (ndarray): set of intensity values. + index (int): Index to the provided set of intensity values. + replace_invalid (bool, optional): If True, invalid indices will be replaced + by a placeholder "NaN" value. + + Returns: + ndarray: Array of the intensity values found at the requested indices. + + """ + + # Initialise placeholder + read_x = np.zeros(np.shape(x)) + + # Read variables for valid indices + read_x[index >= 0] = x[index[index >= 0]] + + if replace_invalid: + # Set variables for invalid indices to nan + read_x[index < 0] = np.nan + + # Set variables for invalid initial indices to nan + read_x[np.isnan(x)] = np.nan + + return read_x + + +def coord2index(x: np.ndarray, + y: np.ndarray, + z: np.ndarray, + dims: Union[List, np.ndarray]) -> Union[np.ndarray, + List]: + """Translate requested coordinates to row indices in image intensity tables. + + Note: + Code was adapted from the in-house radiomics software created at OncoRay, + Dresden, Germany. + + Args: + x (ndarray): set of discrete x-coordinates. + y (ndarray): set of discrete y-coordinates. + z (ndarray): set of discrete z-coordinates. + dims (ndarray or List): dimensions of the image. + + Returns: + ndarray or List: Array or List of indexes corresponding the requested coordinates + + """ + + # Translate coordinates to indices + index = z + y * dims[2] + x * dims[2] * dims[1] + + # Mark invalid transitions + index[np.logical_or(x < 0, x >= dims[0])] = -99999 + index[np.logical_or(y < 0, y >= dims[1])] = -99999 + index[np.logical_or(z < 0, z >= dims[2])] = -99999 + + return index + + +def is_list_all_none(x: List) -> bool: + """Determines if all list elements are None. + + Args: + x (List): List of elements to check. + + Returns: + bool: True if all elemets in `x` are None. + + """ + return all(y is None for y in x) diff --git a/MEDiml/utils/texture_features_names.py b/MEDiml/utils/texture_features_names.py new file mode 100644 index 0000000..a286c51 --- /dev/null +++ b/MEDiml/utils/texture_features_names.py @@ -0,0 +1,115 @@ +glcm_features_names = [ + "Fcm_joint_max", + "Fcm_joint_avg", + "Fcm_joint_var", + "Fcm_joint_entr", + "Fcm_diff_avg", + "Fcm_diff_var", + "Fcm_diff_entr", + "Fcm_sum_avg", + "Fcm_sum_var", + "Fcm_sum_entr", + "Fcm_energy", + "Fcm_contrast", + "Fcm_dissimilarity", + "Fcm_inv_diff", + "Fcm_inv_diff_norm", + "Fcm_inv_diff_mom", + "Fcm_inv_diff_mom_norm", + "Fcm_inv_var", + "Fcm_corr", + "Fcm_auto_corr", + "Fcm_info_corr1", + "Fcm_info_corr2", + "Fcm_clust_tend", + "Fcm_clust_shade", + "Fcm_clust_prom" +] +glrlm_features_names = [ + "Frlm_sre", + "Frlm_lre", + "Frlm_lgre", + "Frlm_hgre", + "Frlm_srlge", + "Frlm_srhge", + "Frlm_lrlge", + "Frlm_lrhge", + "Frlm_glnu", + "Frlm_glnu_norm", + "Frlm_rlnu", + "Frlm_rlnu_norm", + "Frlm_r_perc", + "Frlm_gl_var", + "Frlm_rl_var", + "Frlm_rl_entr" +] +glszm_features_names = [ + "Fszm_sze", + "Fszm_lze", + "Fszm_lgze", + "Fszm_hgze", + "Fszm_szlge", + "Fszm_szhge", + "Fszm_lzlge", + "Fszm_lzhge", + "Fszm_glnu", + "Fszm_glnu_norm", + "Fszm_zsnu", + "Fszm_zsnu_norm", + "Fszm_z_perc", + "Fszm_gl_var", + "Fszm_zs_var", + "Fszm_zs_entr", +] +gldzm_features_names = [ + "Fdzm_sde", + "Fdzm_lde", + "Fdzm_lgze", + "Fdzm_hgze", + "Fdzm_sdlge", + "Fdzm_sdhge", + "Fdzm_ldlge", + "Fdzm_ldhge", + "Fdzm_glnu", + "Fdzm_glnu_norm", + "Fdzm_zdnu", + "Fdzm_zdnu_norm", + "Fdzm_z_perc", + "Fdzm_gl_var", + "Fdzm_zd_var", + "Fdzm_zd_entr" +] +ngtdm_features_names = [ + "Fngt_coarseness" + "Fngt_contrast" + "Fngt_busyness" + "Fngt_complexity" + "Fngt_strength" +] +ngldm_features_names = [ + "Fngl_lde", + "Fngl_hde", + "Fngl_lgce", + "Fngl_hgce", + "Fngl_ldlge", + "Fngl_ldhge", + "Fngl_hdlge", + "Fngl_hdhge", + "Fngl_glnu", + "Fngl_glnu_norm", + "Fngl_dcnu", + "Fngl_dcnu_norm", + "Fngl_gl_var", + "Fngl_dc_var", + "Fngl_dc_entr", + "Fngl_dc_energy" +] +# PS: DO NOT CHANGE THE ORDER OF THE LISTS BELOW, CHANGING THE ORDER WILL BREAK THE CODE (RESULTS CLASS) +texture_features_all = [ + glcm_features_names, + ngtdm_features_names, + ngldm_features_names, + glrlm_features_names, + gldzm_features_names, + glszm_features_names +] \ No newline at end of file diff --git a/MEDiml/utils/write_radiomics_csv.py b/MEDiml/utils/write_radiomics_csv.py new file mode 100644 index 0000000..c4e66ff --- /dev/null +++ b/MEDiml/utils/write_radiomics_csv.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from pathlib import Path +from typing import Union + +import numpy as np + + +def write_radiomics_csv(path_radiomics_table: Union[Path, str]) -> None: + """ + Loads a radiomics structure (dict with radiomics features) to convert it to a CSV file and save it. + + Args: + path_radiomics_table(Union[Path, str]): path to the radiomics dict. + + Returns: + None. + """ + + # INITIALIZATION + path_radiomics_table = Path(path_radiomics_table) + path_to_table = path_radiomics_table.parent + name_table = path_radiomics_table.stem + + # LOAD RADIOMICS TABLE + radiomics_table_dict = np.load(path_radiomics_table, allow_pickle=True)[0] + + # WRITE RADIOMICS TABLE IN CSV FORMAT + csv_name = name_table + '.csv' + csv_path = path_to_table / csv_name + radiomics_table_dict['Table'] = radiomics_table_dict['Table'].fillna(value='NaN') + radiomics_table_dict['Table'] = radiomics_table_dict['Table'].sort_index() + radiomics_table_dict['Table'].to_csv(csv_path, + sep=',', + encoding='utf-8', + index=True, + index_label=radiomics_table_dict['Properties']['DimensionNames'][0]) + + # WRITE DEFINITIONS.TXT + txt_name = name_table + '.txt' + txt_Path = path_to_table / txt_name + + # WRITE THE CSV + fid = open(txt_Path, 'w') + fid.write(radiomics_table_dict['Properties']['UserData']) + fid.close() diff --git a/MEDiml/wrangling/DataManager.py b/MEDiml/wrangling/DataManager.py new file mode 100644 index 0000000..b6fcfb3 --- /dev/null +++ b/MEDiml/wrangling/DataManager.py @@ -0,0 +1,1724 @@ +import json +import logging +import os +import pickle +import re +from dataclasses import dataclass +from pathlib import Path +from time import time +from typing import List, Union + +import matplotlib.pyplot as plt +import nibabel as nib +import numpy as np +import pandas as pd +import pydicom +import pydicom.errors +import pydicom.misc +import ray +from nilearn import image +from numpyencoder import NumpyEncoder +from tqdm import tqdm, trange + +from ..MEDscan import MEDscan +from ..processing.compute_suv_map import compute_suv_map +from ..processing.segmentation import get_roi_from_indexes +from ..utils.get_file_paths import get_file_paths +from ..utils.get_patient_names import get_patient_names +from ..utils.imref import imref3d +from ..utils.json_utils import load_json, save_json +from ..utils.save_MEDscan import save_MEDscan +from .ProcessDICOM import ProcessDICOM + + +class DataManager(object): + """Reads all the raw data (DICOM, NIfTI) content and organizes it in instances of the MEDscan class.""" + + + @dataclass + class DICOM(object): + """DICOM data management class that will organize data during the conversion to MEDscan class process""" + stack_series_rs: List + stack_path_rs: List + stack_frame_rs: List + cell_series_id: List + cell_path_rs: List + cell_path_images: List + cell_frame_rs: List + cell_frame_id: List + + + @dataclass + class NIfTI(object): + """NIfTI data management class that will organize data during the conversion to MEDscan class process""" + stack_path_images: List + stack_path_roi: List + stack_path_all: List + + + @dataclass + class Paths(object): + """Paths management class that will organize the paths used in the processing""" + _path_to_dicoms: List + _path_to_niftis: List + _path_csv: Union[Path, str] + _path_save: Union[Path, str] + _path_save_checks: Union[Path, str] + _path_pre_checks_settings: Union[Path, str] + + def __init__( + self, + path_to_dicoms: List = [], + path_to_niftis: List = [], + path_csv: Union[Path, str] = None, + path_save: Union[Path, str] = None, + path_save_checks: Union[Path, str] = None, + path_pre_checks_settings: Union[Path, str] = None, + save: bool = True, + n_batch: int = 2 + ) -> None: + """Constructor of the class DataManager. + + Args: + path_to_dicoms (Union[Path, str], optional): Full path to the starting directory + where the DICOM data is located. + path_to_niftis (Union[Path, str], optional): Full path to the starting directory + where the NIfTI is located. + path_csv (Union[Path, str], optional): Full path to the CSV file containing the scans info list. + path_save (Union[Path, str], optional): Full path to the directory where to save all the MEDscan classes. + path_save_checks(Union[Path, str], optional): Full path to the directory where to save all + the pre-radiomics checks analysis results. + path_pre_checks_settings(Union[Path, str], optional): Full path to the JSON file of the pre-checks analysis + parameters. + save (bool, optional): True to save the MEDscan classes in `path_save`. + n_batch (int, optional): Numerical value specifying the number of batch to use in the + parallel computations (use 0 for serial computation). + + Returns: + None + """ + # Convert all paths to Pathlib.Path + if path_to_dicoms: + path_to_dicoms = Path(path_to_dicoms) + if path_to_niftis: + path_to_niftis = Path(path_to_niftis) + if path_csv: + path_csv = Path(path_csv) + if path_save: + path_save = Path(path_save) + if path_save_checks: + path_save_checks = Path(path_save_checks) + if path_pre_checks_settings: + path_pre_checks_settings = Path(path_pre_checks_settings) + + self.paths = self.Paths( + path_to_dicoms, + path_to_niftis, + path_csv, + path_save, + path_save_checks, + path_pre_checks_settings, + ) + self.save = save + self.n_batch = n_batch + self.__dicom = self.DICOM( + stack_series_rs=[], + stack_path_rs=[], + stack_frame_rs=[], + cell_series_id=[], + cell_path_rs=[], + cell_path_images=[], + cell_frame_rs=[], + cell_frame_id=[] + ) + self.__nifti = self.NIfTI( + stack_path_images=[], + stack_path_roi=[], + stack_path_all=[] + ) + self.path_to_objects = [] + self.summary = {} + self.csv_data = None + self.__studies = [] + self.__institutions = [] + self.__scans = [] + + def __find_uid_cell_index(self, uid: Union[str, List[str]], cell: List[str]) -> List: + """Finds the cell with the same `uid`. If not is present in `cell`, creates a new position + in the `cell` for the new `uid`. + + Args: + uid (Union[str, List[str]]): Unique identifier of the Series to find. + cell (List[str]): List of Unique identifiers of the Series. + + Returns: + Union[List[str], str]: List or string of the uid + """ + return [len(cell)] if uid not in cell else[i for i, e in enumerate(cell) if e == uid] + + def __get_list_of_files(self, dir_name: str) -> List: + """Gets all files in the given directory + + Args: + dir_name (str): directory name + + Returns: + List: List of all files in the directory + """ + list_of_file = os.listdir(dir_name) + all_files = list() + for entry in list_of_file: + full_path = os.path.join(dir_name, entry) + if os.path.isdir(full_path): + all_files = all_files + self.__get_list_of_files(full_path) + else: + all_files.append(full_path) + + return all_files + + def __get_MEDscan_name_save(self, medscan: MEDscan) -> str: + """Returns the name that will be used to save the MEDscan instance, based on the values of the attributes. + + Args: + medscan(MEDscan): A MEDscan class instance. + + Returns: + str: String of the name save. + """ + series_description = medscan.series_description.translate({ord(ch): '-' for ch in '/\\ ()&:*'}) + name_id = medscan.patientID.translate({ord(ch): '-' for ch in '/\\ ()&:*'}) + # final saving name + name_complete = name_id + '__' + series_description + '.' + medscan.type + '.npy' + return name_complete + + def __associate_rt_stuct(self) -> None: + """Associates the imaging volumes to their mask using UIDs + + Returns: + None + """ + print('--> Associating all RT objects to imaging volumes') + n_rs = len(self.__dicom.stack_path_rs) + self.__dicom.stack_series_rs = list(dict.fromkeys(self.__dicom.stack_series_rs)) + if n_rs: + for i in trange(0, n_rs): + try: + # PUT ALL THE DICOM PATHS WITH THE SAME UID IN THE SAME PATH LIST + ind_series_id = self.__find_uid_cell_index( + self.__dicom.stack_series_rs[i], + self.__dicom.cell_series_id) + for n in range(len(ind_series_id)): + if ind_series_id[n] < len(self.__dicom.cell_path_rs): + self.__dicom.cell_path_rs[ind_series_id[n]] += [self.__dicom.stack_path_rs[i]] + except: + ind_series_id = self.__find_uid_cell_index( + self.__dicom.stack_frame_rs[i], + self.__dicom.cell_frame_id) + for n in range(len(ind_series_id)): + if ind_series_id[n] < len(self.__dicom.cell_path_rs): + self.__dicom.cell_path_rs[ind_series_id[n]] += [self.__dicom.stack_path_rs[i]] + print('DONE') + + def __read_all_dicoms(self) -> None: + """Reads all the dicom files in the all the paths of the attribute `_path_to_dicoms` + + Returns: + None + """ + # SCANNING ALL FOLDERS IN INITIAL DIRECTORY + print('\n--> Scanning all folders in initial directory...', end='') + p = Path(self.paths._path_to_dicoms) + e_rglob = '*.dcm' + + # EXTRACT ALL FILES IN THE PATH TO DICOMS + if self.paths._path_to_dicoms.is_dir(): + stack_folder_temp = list(p.rglob(e_rglob)) + stack_folder = [x for x in stack_folder_temp if not x.is_dir()] + elif str(self.paths._path_to_dicoms).find('json') != -1: + with open(self.paths._path_to_dicoms) as f: + data = json.load(f) + for value in data.values(): + stack_folder_temp = value + directory_name = str(stack_folder_temp).replace("'", '').replace('[', '').replace(']', '') + stack_folder = self.__get_list_of_files(directory_name) + else: + raise ValueError("The given dicom folder path either doesn't exist or not a folder.") + # READ ALL DICOM FILES AND UPDATE ATTRIBUTES FOR FURTHER PROCESSING + for file in tqdm(stack_folder): + if pydicom.misc.is_dicom(file): + try: + info = pydicom.dcmread(str(file)) + if info.Modality in ['MR', 'PT', 'CT']: + ind_series_id = self.__find_uid_cell_index( + info.SeriesInstanceUID, + self.__dicom.cell_series_id)[0] + if ind_series_id == len(self.__dicom.cell_series_id): # New volume + self.__dicom.cell_series_id = self.__dicom.cell_series_id + [info.SeriesInstanceUID] + self.__dicom.cell_frame_id += [info.FrameOfReferenceUID] + self.__dicom.cell_path_images += [[]] + self.__dicom.cell_path_rs = self.__dicom.cell_path_rs + [[]] + self.__dicom.cell_path_images[ind_series_id] += [file] + elif info.Modality == 'RTSTRUCT': + self.__dicom.stack_path_rs += [file] + try: + series_uid = info.ReferencedFrameOfReferenceSequence[ + 0].RTReferencedStudySequence[ + 0].RTReferencedSeriesSequence[ + 0].SeriesInstanceUID + except: + series_uid = 'NotFound' + self.__dicom.stack_series_rs += [series_uid] + try: + frame_uid = info.ReferencedFrameOfReferenceSequence[0].FrameOfReferenceUID + except: + frame_uid = info.FrameOfReferenceUID + self.__dicom.stack_frame_rs += [frame_uid] + else: + print("Modality not supported: ", info.Modality) + + except Exception as e: + print(f'Error while reading: {file}, error: {e}\n') + continue + print('DONE') + + # ASSOCIATE ALL VOLUMES TO THEIR MASK + self.__associate_rt_stuct() + + def process_all_dicoms(self) -> Union[List[MEDscan], None]: + """This function reads the DICOM content of all the sub-folder tree of a starting directory defined by + `path_to_dicoms`. It then organizes the data (files throughout the starting directory are associated by + 'SeriesInstanceUID') in the MEDscan class including the region of interest (ROI) defined by an + associated RTstruct. All MEDscan classes hereby created are saved in `path_save` with a name + varying with every scan. + + Returns: + List[MEDscan]: List of MEDscan instances. + """ + ray.init(local_mode=True, include_dashboard=True) + + print('--> Reading all DICOM objects to create MEDscan classes') + self.__read_all_dicoms() + + print('--> Processing DICOMs and creating MEDscan objects') + n_scans = len(self.__dicom.cell_path_images) + if self.n_batch is None: + n_batch = 1 + elif n_scans < self.n_batch: + n_batch = n_scans + else: + n_batch = self.n_batch + + # Distribute the first tasks to all workers + pds = [ProcessDICOM( + self.__dicom.cell_path_images[i], + self.__dicom.cell_path_rs[i], + self.paths._path_save, + self.save) + for i in range(n_batch)] + + ids = [pd.process_files() for pd in pds] + + # Update the path to the created instances + for name_save in ray.get(ids): + if self.paths._path_save: + self.path_to_objects.append(str(self.paths._path_save / name_save)) + # Update processing summary + if name_save.split('_')[0].count('-') >= 2: + scan_type = name_save[name_save.find('__')+2 : name_save.find('.')] + if name_save.split('-')[0] not in self.__studies: + self.__studies.append(name_save.split('-')[0]) # add new study + if name_save.split('-')[1] not in self.__institutions: + self.__institutions.append(name_save.split('-')[1]) # add new study + if name_save.split('-')[0] not in self.summary: + self.summary[name_save.split('-')[0]] = {} + if name_save.split('-')[1] not in self.summary[name_save.split('-')[0]]: + self.summary[name_save.split('-')[0]][name_save.split('-')[1]] = {} # add new institution + if scan_type not in self.__scans: + self.__scans.append(scan_type) + if scan_type not in self.summary[name_save.split('-')[0]][name_save.split('-')[1]]: + self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type] = [] + if name_save not in self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type]: + self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type].append(name_save) + else: + if self.save: + logging.warning(f"The patient ID of the following file: {name_save} does not respect the MEDiml "\ + "naming convention 'study-institution-id' (Ex: Glioma-TCGA-001)") + + nb_job_left = n_scans - n_batch + + # Distribute the remaining tasks + for _ in trange(n_scans): + _, ids = ray.wait(ids, num_returns=1) + if nb_job_left > 0: + idx = n_scans - nb_job_left + pd = ProcessDICOM( + self.__dicom.cell_path_images[idx], + self.__dicom.cell_path_rs[idx], + self.paths._path_save, + self.save) + ids.extend([pd.process_files()]) + nb_job_left -= 1 + + # Update the path to the created instances + for name_save in ray.get(ids): + if self.paths._path_save: + self.path_to_objects.extend(str(self.paths._path_save / name_save)) + # Update processing summary + if name_save.split('_')[0].count('-') >= 2: + scan_type = name_save[name_save.find('__')+2 : name_save.find('.')] + if name_save.split('-')[0] not in self.__studies: + self.__studies.append(name_save.split('-')[0]) # add new study + if name_save.split('-')[1] not in self.__institutions: + self.__institutions.append(name_save.split('-')[1]) # add new study + if name_save.split('-')[0] not in self.summary: + self.summary[name_save.split('-')[0]] = {} + if name_save.split('-')[1] not in self.summary[name_save.split('-')[0]]: + self.summary[name_save.split('-')[0]][name_save.split('-')[1]] = {} # add new institution + if scan_type not in self.__scans: + self.__scans.append(scan_type) + if scan_type not in self.summary[name_save.split('-')[0]][name_save.split('-')[1]]: + self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type] = [] + if name_save not in self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type]: + self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type].append(name_save) + else: + if self.save: + logging.warning(f"The patient ID of the following file: {name_save} does not respect the MEDiml "\ + "naming convention 'study-institution-id' (Ex: Glioma-TCGA-001)") + print('DONE') + + def __read_all_niftis(self) -> None: + """Reads all files in the initial path and organizes other path to images and roi + in the class attributes. + + Returns: + None. + """ + print('\n--> Scanning all folders in initial directory') + if not self.paths._path_to_niftis: + raise ValueError("The path to the niftis is not defined") + p = Path(self.paths._path_to_niftis) + e_rglob1 = '*.nii' + e_rglob2 = '*.nii.gz' + + # EXTRACT ALL FILES IN THE PATH TO DICOMS + if p.is_dir(): + self.__nifti.stack_path_all = list(p.rglob(e_rglob1)) + self.__nifti.stack_path_all.extend(list(p.rglob(e_rglob2))) + else: + raise TypeError(f"{p} must be a path to a directory") + + all_niftis = list(self.__nifti.stack_path_all) + for i in trange(0, len(all_niftis)): + if 'ROI' in all_niftis[i].name.split("."): + self.__nifti.stack_path_roi.append(all_niftis[i]) + else: + self.__nifti.stack_path_images.append(all_niftis[i]) + print('DONE') + + def __associate_roi_to_image( + self, + image_file: Union[Path, str], + medscan: MEDscan, + nifti: nib.Nifti1Image, + path_roi_data: Path = None + ) -> MEDscan: + """Extracts all ROI data from the given path for the given patient ID and updates all class attributes with + the new extracted data. + + Args: + image_file(Union[Path, str]): Path to the ROI data. + medscan (MEDscan): MEDscan class instance that will hold the data. + + Returns: + MEDscan: Returns a MEDscan instance with updated roi attributes. + """ + image_file = Path(image_file) + roi_index = 0 + + if not path_roi_data: + if not self.paths._path_to_niftis: + raise ValueError("The path to the niftis is not defined") + else: + path_roi_data = self.paths._path_to_niftis + + for file in path_roi_data.glob('*.nii.gz'): + _id = image_file.name.split("(")[0] # id is PatientID__ImagingScanName + # Load the patient's ROI nifti files: + if file.name.startswith(_id) and 'ROI' in file.name.split("."): + roi = nib.load(file) + roi = image.resample_to_img(roi, nifti, interpolation='nearest') + roi_data = roi.get_fdata() + roi_name = file.name[file.name.find("(") + 1 : file.name.find(")")] + name_set = file.name[file.name.find("_") + 2 : file.name.find("(")] + medscan.data.ROI.update_indexes(key=roi_index, indexes=np.nonzero(roi_data.flatten())) + medscan.data.ROI.update_name_set(key=roi_index, name_set=name_set) + medscan.data.ROI.update_roi_name(key=roi_index, roi_name=roi_name) + roi_index += 1 + return medscan + + def __associate_spatialRef(self, nifti_file: Union[Path, str], medscan: MEDscan) -> MEDscan: + """Computes the imref3d spatialRef using a NIFTI file and updates the spatialRef attribute. + + Args: + nifti_file(Union[Path, str]): Path to the nifti data. + medscan (MEDscan): MEDscan class instance that will hold the data. + + Returns: + MEDscan: Returns a MEDscan instance with updated spatialRef attribute. + """ + # Loading the nifti file : + nifti = nib.load(nifti_file) + nifti_data = medscan.data.volume.array + + # spatialRef Creation + pixel_x = abs(nifti.affine[0, 0]) + pixel_y = abs(nifti.affine[1, 1]) + slices = abs(nifti.affine[2, 2]) + min_grid = nifti.affine[:3, 3] * [-1.0, -1.0, 1.0] # x and y are flipped + min_x_grid = min_grid[0] + min_y_grid = min_grid[1] + min_z_grid = min_grid[2] + size_image = np.shape(nifti_data) + spatialRef = imref3d(size_image, abs(pixel_x), abs(pixel_y), abs(slices)) + spatialRef.XWorldLimits = (np.array(spatialRef.XWorldLimits) - + (spatialRef.XWorldLimits[0] - + (min_x_grid-pixel_x/2)) + ).tolist() + spatialRef.YWorldLimits = (np.array(spatialRef.YWorldLimits) - + (spatialRef.YWorldLimits[0] - + (min_y_grid-pixel_y/2)) + ).tolist() + spatialRef.ZWorldLimits = (np.array(spatialRef.ZWorldLimits) - + (spatialRef.ZWorldLimits[0] - + (min_z_grid-slices/2)) + ).tolist() + + # Converting the results into lists + spatialRef.ImageSize = spatialRef.ImageSize.tolist() + spatialRef.XIntrinsicLimits = spatialRef.XIntrinsicLimits.tolist() + spatialRef.YIntrinsicLimits = spatialRef.YIntrinsicLimits.tolist() + spatialRef.ZIntrinsicLimits = spatialRef.ZIntrinsicLimits.tolist() + + # update spatialRef in the volume sub-class + medscan.data.volume.update_spatialRef(spatialRef) + + return medscan + + def __process_one_nifti(self, nifti_file: Union[Path, str], path_data) -> MEDscan: + """ + Processes one NIfTI file to create a MEDscan class instance. + + Args: + nifti_file (Union[Path, str]): Path to the NIfTI file. + path_data (Union[Path, str]): Path to the data. + + Returns: + MEDscan: MEDscan class instance. + """ + medscan = MEDscan() + medscan.patientID = os.path.basename(nifti_file).split("_")[0] + medscan.type = os.path.basename(nifti_file).split(".")[-3] + medscan.series_description = nifti_file.name[nifti_file.name.find('__') + 2: nifti_file.name.find('(')] + medscan.format = "nifti" + medscan.data.set_orientation(orientation="Axial") + medscan.data.set_patient_position(patient_position="HFS") + medscan.data.volume.array = nib.load(nifti_file).get_fdata() + medscan.data.volume.scan_rot = None + + # Update spatialRef + self.__associate_spatialRef(nifti_file, medscan) + + # Assiocate ROI + medscan = self.__associate_roi_to_image(nifti_file, medscan, nib.load(nifti_file), path_data) + + return medscan + + def process_all(self) -> None: + """Processes both DICOM & NIfTI content to create MEDscan classes + """ + self.process_all_dicoms() + self.process_all_niftis() + + def process_all_niftis(self) -> List[MEDscan]: + """This function reads the NIfTI content of all the sub-folder tree of a starting directory. + It then organizes the data in the MEDscan class including the region of interest (ROI) + defined by an associated mask file. All MEDscan classes hereby created are saved in a specific path + with a name specific name varying with every scan. + + Args: + None. + + Returns: + List[MEDscan]: List of MEDscan instances. + """ + + # Reading all NIfTI files + self.__read_all_niftis() + + # Create the MEDscan instances + print('--> Reading all NIfTI objects (imaging volumes & masks) to create MEDscan classes') + list_instances = [] + for file in tqdm(self.__nifti.stack_path_images): + # Assert the list of instances does not exceed the a size of 10 + if len(list_instances) >= 10: + print('The number of MEDscan instances exceeds 10, please consider saving the instances') + break + # INITIALIZE MEDscan INSTANCE AND UPDATE ATTRIBUTES + medscan = MEDscan() + medscan.patientID = os.path.basename(file).split("_")[0] + medscan.type = os.path.basename(file).split(".")[-3] + medscan.series_description = file.name[file.name.find('__') + 2: file.name.find('(')] + medscan.format = "nifti" + medscan.data.set_orientation(orientation="Axial") + medscan.data.set_patient_position(patient_position="HFS") + medscan.data.volume.array = nib.load(file).get_fdata() + + # RAS to LPS + #medscan.data.volume.convert_to_LPS() + medscan.data.volume.scan_rot = None + + # Update spatialRef + medscan = self.__associate_spatialRef(file, medscan) + + # Get ROI + medscan = self.__associate_roi_to_image(file, medscan, nib.load(file)) + + # SAVE MEDscan INSTANCE + if self.save and self.paths._path_save: + save_MEDscan(medscan, self.paths._path_save) + else: + list_instances.append(medscan) + + # Update the path to the created instances + name_save = self.__get_MEDscan_name_save(medscan) + + # Clear memory + del medscan + + # Update the path to the created instances + if self.paths._path_save: + self.path_to_objects.append(str(self.paths._path_save / name_save)) + + # Update processing summary + if name_save.split('_')[0].count('-') >= 2: + scan_type = name_save[name_save.find('__')+2 : name_save.find('.')] + if name_save.split('-')[0] not in self.__studies: + self.__studies.append(name_save.split('-')[0]) # add new study + if name_save.split('-')[1] not in self.__institutions: + self.__institutions.append(name_save.split('-')[1]) # add new institution + if name_save.split('-')[0] not in self.summary: + self.summary[name_save.split('-')[0]] = {} # add new study to summary + if name_save.split('-')[1] not in self.summary[name_save.split('-')[0]]: + self.summary[name_save.split('-')[0]][name_save.split('-')[1]] = {} # add new institution + if scan_type not in self.__scans: + self.__scans.append(scan_type) + if scan_type not in self.summary[name_save.split('-')[0]][name_save.split('-')[1]]: + self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type] = [] + if name_save not in self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type]: + self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type].append(name_save) + else: + if self.save: + logging.warning(f"The patient ID of the following file: {name_save} does not respect the MEDiml "\ + "naming convention 'study-institution-id' (Ex: Glioma-TCGA-001)") + print('DONE') + + if list_instances: + return list_instances + + def update_from_csv(self, path_csv: Union[str, Path] = None) -> None: + """Updates the class from a given CSV and summarizes the processed scans again according to it. + + Args: + path_csv(optional, Union[str, Path]): Path to a csv file, if not given, will check + for csv info in the class attributes. + + Returns: + None + """ + if not (path_csv or self.paths._path_csv): + print('No csv provided, no updates will be made') + else: + if path_csv: + self.paths._path_csv = path_csv + # Extract roi type label from csv file name + name_csv = self.paths._path_csv.name + roi_type_label = name_csv[name_csv.find('_')+1 : name_csv.find('.')] + + # Create a dictionary + csv_data = {} + csv_data[roi_type_label] = pd.read_csv(self.paths._path_csv) + self.csv_data = csv_data + self.summarize() + + def summarize(self, retrun_summary: bool = False) -> None: + """Creates and shows a summary of processed scans organized by study, institution, scan type and roi type + + Args: + retrun_summary (bool, optional): If True, will return the summary as a dictionary. + + Returns: + None + """ + def count_scans(summary): + count = 0 + if type(summary) == dict: + for study in summary: + if type(summary[study]) == dict: + for institution in summary[study]: + if type(summary[study][institution]) == dict: + for scan in self.summary[study][institution]: + count += len(summary[study][institution][scan]) + else: + count += len(summary[study][institution]) + else: + count += len(summary[study]) + elif type(summary) == list: + count = len(summary) + return count + + summary_df = pd.DataFrame(columns=['study', 'institution', 'scan_type', 'roi_type', 'count']) + + for study in self.summary: + summary_df = summary_df.append({ + 'study': study, + 'institution': "", + 'scan_type': "", + 'roi_type': "", + 'count' : count_scans(self.summary) + }, ignore_index=True) + for institution in self.summary[study]: + summary_df = summary_df.append({ + 'study': study, + 'institution': institution, + 'scan_type': "", + 'roi_type': "", + 'count' : count_scans(self.summary[study][institution]) + }, ignore_index=True) + for scan in self.summary[study][institution]: + summary_df = summary_df.append({ + 'study': study, + 'institution': institution, + 'scan_type': scan, + 'roi_type': "", + 'count' : count_scans(self.summary[study][institution][scan]) + }, ignore_index=True) + if self.csv_data: + roi_count = 0 + for roi_type in self.csv_data: + csv_table = pd.DataFrame(self.csv_data[roi_type]) + csv_table['under'] = '_' + csv_table['dot'] = '.' + csv_table['npy'] = '.npy' + name_patients = (pd.Series( + csv_table[['PatientID', 'under', 'under', + 'ImagingScanName', + 'dot', + 'ImagingModality', + 'npy']].fillna('').values.tolist()).str.join('')).tolist() + for patient_id in self.summary[study][institution][scan]: + if patient_id in name_patients: + roi_count += 1 + summary_df = summary_df.append({ + 'study': study, + 'institution': institution, + 'scan_type': scan, + 'roi_type': roi_type, + 'count' : roi_count + }, ignore_index=True) + print(summary_df.to_markdown(index=False)) + + if retrun_summary: + return summary_df + + def __pre_radiomics_checks_dimensions( + self, + path_data: Union[Path, str] = None, + wildcards_dimensions: List[str] = [], + min_percentile: float = 0.05, + max_percentile: float = 0.95, + save: bool = False + ) -> None: + """Finds proper voxels dimension options for radiomics analyses for a group of scans + + Args: + path_data (Path, optional): Path to the MEDscan objects, if not specified will use ``path_save`` from the + inner-class ``Paths`` in the current instance. + wildcards_dimensions(List[str], optional): List of wildcards that determines the scans + that will be analyzed. You can learn more about wildcards in + :ref:`this link `. + min_percentile (float, optional): Minimum percentile to use for the histograms. Defaults to 0.05. + max_percentile (float, optional): Maximum percentile to use for the histograms. Defaults to 0.95. + save (bool, optional): If True, will save the results in a json file. Defaults to False. + + Returns: + None. + """ + xy_dim = { + "data": [], + "mean": [], + "median": [], + "std": [], + "min": [], + "max": [], + f"p{min_percentile}": [], + f"p{max_percentile}": [] + } + z_dim = { + "data": [], + "mean": [], + "median": [], + "std": [], + "min": [], + "max": [], + f"p{min_percentile}": [], + f"p{max_percentile}": [] + } + if type(wildcards_dimensions) is str: + wildcards_dimensions = [wildcards_dimensions] + + if len(wildcards_dimensions) == 0: + print("Wildcard is empty, the pre-checks will be aborted") + return + + # Updating plotting params + plt.rcParams["figure.figsize"] = (20,20) + plt.rcParams.update({'font.size': 22}) + + # TODO: seperate by studies and scan type (MRscan, CTscan...) + # TODO: Two summaries (df, list of names saves) -> + # name_save = name_save(ROI) : Glioma-Huashan-001__T1.MRscan.npy({GTV}) + file_paths = list() + for w in range(len(wildcards_dimensions)): + wildcard = wildcards_dimensions[w] + if path_data: + file_paths = get_file_paths(path_data, wildcard) + elif self.paths._path_save: + file_paths = get_file_paths(self.paths._path_save, wildcard) + else: + raise ValueError("Path data is invalid.") + n_files = len(file_paths) + xy_dim["data"] = np.zeros((n_files, 1)) + xy_dim["data"] = np.multiply(xy_dim["data"], np.nan) + z_dim["data"] = np.zeros((n_files, 1)) + z_dim["data"] = np.multiply(z_dim["data"], np.nan) + for f in tqdm(range(len(file_paths))): + try: + if file_paths[f].name.endswith("nii.gz") or file_paths[f].name.endswith("nii"): + medscan = nib.load(file_paths[f]) + xy_dim["data"][f] = medscan.header.get_zooms()[0] + z_dim["data"][f] = medscan.header.get_zooms()[2] + else: + medscan = np.load(file_paths[f], allow_pickle=True) + xy_dim["data"][f] = medscan.data.volume.spatialRef.PixelExtentInWorldX + z_dim["data"][f] = medscan.data.volume.spatialRef.PixelExtentInWorldZ + except Exception as e: + print(e) + + # Running analysis + xy_dim["data"] = np.concatenate(xy_dim["data"]) + xy_dim["mean"] = np.mean(xy_dim["data"][~np.isnan(xy_dim["data"])]) + xy_dim["median"] = np.median(xy_dim["data"][~np.isnan(xy_dim["data"])]) + xy_dim["std"] = np.std(xy_dim["data"][~np.isnan(xy_dim["data"])]) + xy_dim["min"] = np.min(xy_dim["data"][~np.isnan(xy_dim["data"])]) + xy_dim["max"] = np.max(xy_dim["data"][~np.isnan(xy_dim["data"])]) + xy_dim[f"p{min_percentile}"] = np.percentile(xy_dim["data"][~np.isnan(xy_dim["data"])], + min_percentile) + xy_dim[f"p{max_percentile}"] = np.percentile(xy_dim["data"][~np.isnan(xy_dim["data"])], + max_percentile) + z_dim["mean"] = np.mean(z_dim["data"][~np.isnan(z_dim["data"])]) + z_dim["median"] = np.median(z_dim["data"][~np.isnan(z_dim["data"])]) + z_dim["std"] = np.std(z_dim["data"][~np.isnan(z_dim["data"])]) + z_dim["min"] = np.min(z_dim["data"][~np.isnan(z_dim["data"])]) + z_dim["max"] = np.max(z_dim["data"][~np.isnan(z_dim["data"])]) + z_dim[f"p{min_percentile}"] = np.percentile(z_dim["data"][~np.isnan(z_dim["data"])], + min_percentile) + z_dim[f"p{max_percentile}"] = np.percentile(z_dim["data"][~np.isnan(z_dim["data"])], max_percentile) + xy_dim["data"] = xy_dim["data"].tolist() + z_dim["data"] = z_dim["data"].tolist() + + # Plotting xy-spacing data histogram + df_xy = pd.DataFrame(xy_dim["data"], columns=['data']) + del xy_dim["data"] # no interest in keeping data (we only need statistics) + ax = df_xy.hist(column='data') + min_quant, max_quant, median = df_xy.quantile(min_percentile), df_xy.quantile(max_percentile), df_xy.median() + for x in ax[0]: + x.axvline(min_quant.data, linestyle=':', color='r', label=f"Min Percentile: {float(min_quant):.3f}") + x.axvline(max_quant.data, linestyle=':', color='g', label=f"Max Percentile: {float(max_quant):.3f}") + x.axvline(median.data, linestyle='solid', color='gold', label=f"Median: {float(median.data):.3f}") + x.grid(False) + plt.title(f"Voxels xy-spacing checks for {wildcard}") + plt.legend() + # Save the plot + if save: + plt.savefig(self.paths._path_save_checks / ('Voxels_xy_check.png')) + else: + plt.show() + + # Plotting z-spacing data histogram + df_z = pd.DataFrame(z_dim["data"], columns=['data']) + del z_dim["data"] # no interest in keeping data (we only need statistics) + ax = df_z.hist(column='data') + min_quant, max_quant, median = df_z.quantile(min_percentile), df_z.quantile(max_percentile), df_z.median() + for x in ax[0]: + x.axvline(min_quant.data, linestyle=':', color='r', label=f"Min Percentile: {float(min_quant):.3f}") + x.axvline(max_quant.data, linestyle=':', color='g', label=f"Max Percentile: {float(max_quant):.3f}") + x.axvline(median.data, linestyle='solid', color='gold', label=f"Median: {float(median.data):.3f}") + x.grid(False) + plt.title(f"Voxels z-spacing checks for {wildcard}") + plt.legend() + # Save the plot + if save: + plt.savefig(self.paths._path_save_checks / ('Voxels_z_check.png')) + else: + plt.show() + + # Saving files using wildcard for name + if save: + wildcard = str(wildcard).replace('*', '').replace('.npy', '.json') + save_json(self.paths._path_save_checks / ('xyDim_' + wildcard), xy_dim, cls=NumpyEncoder) + save_json(self.paths._path_save_checks / ('zDim_' + wildcard), z_dim, cls=NumpyEncoder) + + def __pre_radiomics_checks_window( + self, + path_data: Union[str, Path] = None, + wildcards_window: List = [], + path_csv: Union[str, Path] = None, + min_percentile: float = 0.05, + max_percentile: float = 0.95, + bin_width: int = 0, + hist_range: list = [], + nifti: bool = True, + save: bool = False + ) -> None: + """Finds proper re-segmentation ranges options for radiomics analyses for a group of scans + + Args: + path_data (Path, optional): Path to the MEDscan objects, if not specified will use ``path_save`` from the + inner-class ``Paths`` in the current instance. + wildcards_window(List[str], optional): List of wildcards that determines the scans + that will be analyzed. You can learn more about wildcards in + :ref:`this link `. + path_csv(Union[str, Path], optional): Path to a csv file containing a list of the scans that will be + analyzed (a CSV file for a single ROI type). + min_percentile (float, optional): Minimum percentile to use for the histograms. Defaults to 0.05. + max_percentile (float, optional): Maximum percentile to use for the histograms. Defaults to 0.95. + bin_width(int, optional): Width of the bins for the histograms. If not provided, will use the + default number of bins in the method + :ref:`pandas.DataFrame.hist `: 10 bins. + hist_range(list, optional): Range of the histograms. If empty, will use the minimum and maximum values. + nifti(bool, optional): If True, will use the NIfTI files, otherwise will use the numpy files. + save (bool, optional): If True, will save the results in a json file. Defaults to False. + + Returns: + None. + """ + # Updating plotting params + plt.rcParams["figure.figsize"] = (20,20) + plt.rcParams.update({'font.size': 22}) + + if type(wildcards_window) is str: + wildcards_window = [wildcards_window] + + if len(wildcards_window) == 0: + print("Wilcards is empty") + return + if path_csv: + self.paths._path_csv = Path(path_csv) + roi_table = pd.read_csv(self.paths._path_csv) + if nifti: + roi_table['under'] = '_' + roi_table['dot'] = '.' + roi_table['roi_label'] = 'GTV' + roi_table['oparenthesis'] = '(' + roi_table['cparenthesis'] = ')' + roi_table['ext'] = '.nii.gz' + patient_names = (pd.Series( + roi_table[['PatientID', 'under', 'under', + 'ImagingScanName', + 'oparenthesis', + 'roi_label', + 'cparenthesis', + 'dot', + 'ImagingModality', + 'ext']].fillna('').values.tolist()).str.join('')).tolist() + else: + roi_names = [[], [], []] + roi_names[0] = roi_table['PatientID'] + roi_names[1] = roi_table['ImagingScanName'] + roi_names[2] = roi_table['ImagingModality'] + patient_names = get_patient_names(roi_names) + for w in range(len(wildcards_window)): + temp_val = [] + temp = [] + file_paths = [] + roi_data= { + "data": [], + "mean": [], + "median": [], + "std": [], + "min": [], + "max": [], + f"p{min_percentile}": [], + f"p{max_percentile}": [] + } + wildcard = wildcards_window[w] + if path_data: + file_paths = get_file_paths(path_data, wildcard) + elif self.paths._path_save: + path_data = self.paths._path_save + file_paths = get_file_paths(self.paths._path_save, wildcard) + else: + raise ValueError("Path data is invalid.") + n_files = len(file_paths) + i = 0 + for f in tqdm(range(n_files)): + file = file_paths[f] + _, filename = os.path.split(file) + filename, ext = os.path.splitext(filename) + patient_name = filename + ext + try: + if file.name.endswith('nii.gz') or file.name.endswith('nii'): + medscan = self.__process_one_nifti(file, path_data) + else: + medscan = np.load(file, allow_pickle=True) + if re.search('PTscan', wildcard) and medscan.format != 'nifti': + medscan.data.volume.array = compute_suv_map( + np.double(medscan.data.volume.array), + medscan.dicomH[2]) + patient_names = pd.Index(patient_names) + ind_roi = patient_names.get_loc(patient_name) + name_roi = roi_table.loc[ind_roi][3] + vol_obj_init, roi_obj_init = get_roi_from_indexes(medscan, name_roi, 'box') + temp = vol_obj_init.data[roi_obj_init.data == 1] + temp_val.append(len(temp)) + roi_data["data"].append(np.zeros(shape=(n_files, temp_val[i]))) + roi_data["data"][i] = temp + i+=1 + del medscan + del vol_obj_init + del roi_obj_init + except Exception as e: + print(f"Problem with patient {patient_name}, error: {e}") + + roi_data["data"] = np.concatenate(roi_data["data"]) + roi_data["mean"] = np.mean(roi_data["data"][~np.isnan(roi_data["data"])]) + roi_data["median"] = np.median(roi_data["data"][~np.isnan(roi_data["data"])]) + roi_data["std"] = np.std(roi_data["data"][~np.isnan(roi_data["data"])]) + roi_data["min"] = np.min(roi_data["data"][~np.isnan(roi_data["data"])]) + roi_data["max"] = np.max(roi_data["data"][~np.isnan(roi_data["data"])]) + roi_data[f"p{min_percentile}"] = np.percentile(roi_data["data"][~np.isnan(roi_data["data"])], + min_percentile) + roi_data[f"p{max_percentile}"] = np.percentile(roi_data["data"][~np.isnan(roi_data["data"])], + max_percentile) + + # Set bin width if not provided + if bin_width != 0: + if hist_range: + nb_bins = (round(hist_range[1]) - round(hist_range[0])) // bin_width + else: + nb_bins = (round(roi_data["max"]) - round(roi_data["min"])) // bin_width + else: + nb_bins = 10 + if hist_range: + bin_width = int((round(hist_range[1]) - round(hist_range[0])) // nb_bins) + else: + bin_width = int((round(roi_data["max"]) - round(roi_data["min"])) // nb_bins) + nb_bins = int(nb_bins) + + # Set histogram range if not provided + if not hist_range: + hist_range = (roi_data["min"], roi_data["max"]) + + # re-segment data according to histogram range + roi_data["data"] = roi_data["data"][(roi_data["data"] > hist_range[0]) & (roi_data["data"] < hist_range[1])] + df_data = pd.DataFrame(roi_data["data"], columns=['data']) + del roi_data["data"] # no interest in keeping data (we only need statistics) + + # Plot histogram + ax = df_data.hist(column='data', bins=nb_bins, range=(hist_range[0], hist_range[1]), edgecolor='black') + min_quant, max_quant= df_data.quantile(min_percentile), df_data.quantile(max_percentile) + for x in ax[0]: + x.axvline(min_quant.data, linestyle=':', color='r', label=f"{min_percentile*100}% Percentile: {float(min_quant):.3f}") + x.axvline(max_quant.data, linestyle=':', color='g', label=f"{max_percentile*100}% Percentile: {float(max_quant):.3f}") + x.grid(False) + x.xaxis.set_ticks(np.arange(hist_range[0], hist_range[1], bin_width, dtype=int)) + x.set_xticklabels(x.get_xticks(), rotation=45) + x.xaxis.set_tick_params(pad=15) + plt.title(f"Intensity range checks for {wildcard}, bw={bin_width}") + plt.legend() + # Save the plot + if save: + plt.savefig(self.paths._path_save_checks / ('Intensity_range_check_' + f'bw_{bin_width}.png')) + else: + plt.show() + + # save final checks + if save: + wildcard = str(wildcard).replace('*', '').replace('.npy', '.json') + save_json(self.paths._path_save_checks / ('roi_data_' + wildcard), roi_data, cls=NumpyEncoder) + + def pre_radiomics_checks(self, + path_data: Union[str, Path] = None, + wildcards_dimensions: List = [], + wildcards_window: List = [], + path_csv: Union[str, Path] = None, + min_percentile: float = 0.05, + max_percentile: float = 0.95, + bin_width: int = 0, + hist_range: list = [], + nifti: bool = False, + save: bool = False) -> None: + """Finds proper dimension and re-segmentation ranges options for radiomics analyses. + + The resulting files from this method can then be analyzed and used to set up radiomics + parameters options in computation methods. + + Args: + path_data (Path, optional): Path to the MEDscan objects, if not specified will use ``path_save`` from the + inner-class ``Paths`` in the current instance. + wildcards_dimensions(List[str], optional): List of wildcards that determines the scans + that will be analyzed. You can learn more about wildcards in + `this link `_. + wildcards_window(List[str], optional): List of wildcards that determines the scans + that will be analyzed. You can learn more about wildcards in + `this link `_. + path_csv(Union[str, Path], optional): Path to a csv file containing a list of the scans that will be + analyzed (a CSV file for a single ROI type). + min_percentile (float, optional): Minimum percentile to use for the histograms. Defaults to 0.05. + max_percentile (float, optional): Maximum percentile to use for the histograms. Defaults to 0.95. + bin_width(int, optional): Width of the bins for the histograms. If not provided, will use the + default number of bins in the method + :ref:`pandas.DataFrame.hist `: 10 bins. + hist_range(list, optional): Range of the histograms. If empty, will use the minimum and maximum values. + nifti (bool, optional): Set to True if the scans are nifti files. Defaults to False. + save (bool, optional): If True, will save the results in a json file. Defaults to False. + + Returns: + None + """ + # Initialization + path_study = Path.cwd() + + # Load params + if not self.paths._path_pre_checks_settings: + if not wildcards_dimensions or not wildcards_window: + raise ValueError("path to pre-checks settings is None.\ + wildcards_dimensions and wildcards_window need to be specified") + else: + settings = self.paths._path_pre_checks_settings + settings = load_json(settings) + settings = settings['pre_radiomics_checks'] + + # Setting up paths + if 'path_save_checks' in settings and settings['path_save_checks']: + self.paths._path_save_checks = Path(settings['path_save_checks']) + if 'path_csv' in settings and settings['path_csv']: + self.paths._path_csv = Path(settings['path_csv']) + + # Wildcards of groups of files to analyze for dimensions in path_data. + # See for example: https://www.linuxtechtips.com/2013/11/how-wildcards-work-in-linux-and-unix.html + # Keep the cell empty if no dimension checks are to be performed. + if not wildcards_dimensions: + wildcards_dimensions = [] + for i in range(len(settings['wildcards_dimensions'])): + wildcards_dimensions.append(settings['wildcards_dimensions'][i]) + + # ROI intensity window checks params + if not wildcards_window: + wildcards_window = [] + for i in range(len(settings['wildcards_window'])): + wildcards_window.append(settings['wildcards_window'][i]) + + # PRE-RADIOMICS CHECKS + if not self.paths._path_save_checks: + if (path_study / 'checks').exists(): + self.paths._path_save_checks = Path(path_study / 'checks') + else: + os.mkdir(path_study / 'checks') + self.paths._path_save_checks = Path(path_study / 'checks') + else: + if self.paths._path_save_checks.name != 'checks': + if (self.paths._path_save_checks / 'checks').exists(): + self.paths._path_save_checks /= 'checks' + else: + os.mkdir(self.paths._path_save_checks / 'checks') + self.paths._path_save_checks = Path(self.paths._path_save_checks / 'checks') + + # Initializing plotting params + plt.rcParams["figure.figsize"] = (20,20) + plt.rcParams.update({'font.size': 22}) + + start = time() + print('\n\n************************* PRE-RADIOMICS CHECKS *************************', end='') + + # 1. PRE-RADIOMICS CHECKS -- DIMENSIONS + start1 = time() + print('\n--> PRE-RADIOMICS CHECKS -- DIMENSIONS ... ', end='') + self.__pre_radiomics_checks_dimensions( + path_data, + wildcards_dimensions, + min_percentile, + max_percentile, + save) + print('DONE', end='') + time1 = f"{time() - start1:.2f}" + print(f'\nElapsed time: {time1} sec', end='') + + # 2. PRE-RADIOMICS CHECKS - WINDOW + start2 = time() + print('\n\n--> PRE-RADIOMICS CHECKS -- WINDOW ... \n', end='') + self.__pre_radiomics_checks_window( + path_data, + wildcards_window, + path_csv, + min_percentile, + max_percentile, + bin_width, + hist_range, + nifti, + save) + print('DONE', end='') + time2 = f"{time() - start2:.2f}" + print(f'\nElapsed time: {time2} sec', end='') + + time_elapsed = f"{time() - start:.2f}" + print(f'\n\n--> TOTAL TIME FOR PRE-RADIOMICS CHECKS: {time_elapsed} seconds') + print('-------------------------------------------------------------------------------------') + + def perform_mr_imaging_summary(self, + wildcards_scans: List[str], + path_data: Path = None, + path_save_checks: Path = None, + min_percentile: float = 0.05, + max_percentile: float = 0.95 + ) -> None: + """ + Summarizes MRI imaging acquisition parameters. Plots summary histograms + for different dimensions and saves all acquisition parameters locally in JSON files. + + Args: + wildcards_scans (List[str]): List of wildcards that determines the scans + that will be analyzed (Only MRI scans will be analyzed). You can learn more about wildcards in + `this link `_. + For example: ``[\"STS*.MRscan.npy\"]``. + path_data (Path, optional): Path to the MEDscan objects, if not specified will use ``path_save`` from the + inner-class ``Paths`` in the current instance. + path_save_checks (Path, optional): Path where to save the checks, if not specified will use the one + in the current instance. + min_percentile (float, optional): Minimum percentile to use for the histograms. Defaults to 0.05. + max_percentile (float, optional): Maximum percentile to use for the histograms. Defaults to 0.95. + + Returns: + None. + """ + # Initializing data structures + class param: + dates = [] + manufacturer = [] + scanning_sequence = [] + class years: + data = [] + + class fieldStrength: + data = [] + + class repetitionTime: + data = [] + + class echoTime: + data = [] + + class inversionTime: + data = [] + + class echoTrainLength: + data = [] + + class flipAngle: + data = [] + + class numberAverages: + data = [] + + class xyDim: + data = [] + + class zDim: + data = [] + + if len(wildcards_scans) == 0: + print('wildcards_scans is empty') + + # wildcards checks: + no_mr_scan = True + for wildcard in wildcards_scans: + if 'MRscan' in wildcard: + no_mr_scan = False + if no_mr_scan: + raise ValueError(f"wildcards: {wildcards_scans} does not include MR scans. (Only MR scans are supported)") + + # Initialization + if path_data is None: + if self.paths._path_save: + path_data = Path(self.paths._path_save) + else: + print("No path to data was given and path save is None.") + return 0 + + if not path_save_checks: + if self.paths._path_save_checks: + path_save_checks = Path(self.paths._path_save_checks) + else: + if (Path(os.getcwd()) / "checks").exists(): + path_save_checks = Path(os.getcwd()) / "checks" + else: + path_save_checks = (Path(os.getcwd()) / "checks").mkdir() + # Looping through all the different wildcards + for i in tqdm(range(len(wildcards_scans))): + wildcard = wildcards_scans[i] + file_paths = get_file_paths(path_data, wildcard) + n_files = len(file_paths) + param.dates = np.zeros(n_files) + param.years.data = np.zeros((n_files, 1)) + param.years.data = np.multiply(param.years.data, np.NaN) + param.manufacturer = [None] * n_files + param.scanning_sequence = [None] * n_files + param.fieldStrength.data = np.zeros((n_files, 1)) + param.fieldStrength.data = np.multiply(param.fieldStrength.data, np.NaN) + param.repetitionTime.data = np.zeros((n_files, 1)) + param.repetitionTime.data = np.multiply(param.repetitionTime.data, np.NaN) + param.echoTime.data = np.zeros((n_files, 1)) + param.echoTime.data = np.multiply(param.echoTime.data, np.NaN) + param.inversionTime.data = np.zeros((n_files, 1)) + param.inversionTime.data = np.multiply(param.inversionTime.data, np.NaN) + param.echoTrainLength.data = np.zeros((n_files, 1)) + param.echoTrainLength.data = np.multiply(param.echoTrainLength.data, np.NaN) + param.flipAngle.data = np.zeros((n_files, 1)) + param.flipAngle.data = np.multiply(param.flipAngle.data, np.NaN) + param.numberAverages.data = np.zeros((n_files, 1)) + param.numberAverages.data = np.multiply(param.numberAverages.data, np.NaN) + param.xyDim.data = np.zeros((n_files, 1)) + param.xyDim.data = np.multiply(param.xyDim.data, np.NaN) + param.zDim.data = np.zeros((n_files, 1)) + param.zDim.data = np.multiply(param.zDim.data, np.NaN) + + # Loading and recording data + for f in tqdm(range(n_files)): + file = file_paths[f] + + #Open file for warning + try: + warn_file = open(path_save_checks / 'imaging_summary_mr_warnings.txt', 'a') + except IOError: + print("Could not open warning file") + + # Loading Data + try: + print(f'\nCurrently working on: {file}', file = warn_file) + with open(path_data / file, 'rb') as fe: medscan = pickle.load(fe) + + # Example of DICOM header + info = medscan.dicomH[1] + # Recording dates (info.AcquistionDates) + try: + param.dates[f] = info.AcquisitionDate + except AttributeError: + param.dates[f] = info.StudyDate + # Recording years + try: + y = str(param.dates[f]) # Only the first four characters represent the years + param.years.data[f] = y[0:4] + except Exception as e: + print(f'Cannot read years of: {file}. Error: {e}', file = warn_file) + # Recording manufacturers + try: + param.manufacturer[f] = info.Manufacturer + except Exception as e: + print(f'Cannot read manufacturer of: {file}. Error: {e}', file = warn_file) + # Recording scanning sequence + try: + param.scanning_sequence[f] = info.scanning_sequence + except Exception as e: + print(f'Cannot read scanning sequence of: {file}. Error: {e}', file = warn_file) + # Recording field strength + try: + param.fieldStrength.data[f] = info.MagneticFieldStrength + except Exception as e: + print(f'Cannot read field strength of: {file}. Error: {e}', file = warn_file) + # Recording repetition time + try: + param.repetitionTime.data[f] = info.RepetitionTime + except Exception as e: + print(f'Cannot read repetition time of: {file}. Error: {e}', file = warn_file) + # Recording echo time + try: + param.echoTime.data[f] = info.EchoTime + except Exception as e: + print(f'Cannot read echo time of: {file}. Error: {e}', file = warn_file) + # Recording inversion time + try: + param.inversionTime.data[f] = info.InversionTime + except Exception as e: + print(f'Cannot read inversion time of: {file}. Error: {e}', file = warn_file) + # Recording echo train length + try: + param.echoTrainLength.data[f] = info.EchoTrainLength + except Exception as e: + print(f'Cannot read echo train length of: {file}. Error: {e}', file = warn_file) + # Recording flip angle + try: + param.flipAngle.data[f] = info.FlipAngle + except Exception as e: + print(f'Cannot read flip angle of: {file}. Error: {e}', file = warn_file) + # Recording number of averages + try: + param.numberAverages.data[f] = info.NumberOfAverages + except Exception as e: + print(f'Cannot read number averages of: {file}. Error: {e}', file = warn_file) + # Recording xy spacing + try: + param.xyDim.data[f] = medscan.data.volume.spatialRef.PixelExtentInWorldX + except Exception as e: + print(f'Cannot read x spacing of: {file}. Error: {e}', file = warn_file) + # Recording z spacing + try: + param.zDim.data[f] = medscan.data.volume.spatialRef.PixelExtentInWorldZ + except Exception as e: + print(f'Cannot read z spacing of: {file}', file = warn_file) + except Exception as e: + print(f'Cannot read file: {file}. Error: {e}', file = warn_file) + + warn_file.close() + + # Summarize data + # Summarizing years + df_years = pd.DataFrame(param.years.data, + columns=['years']).describe(percentiles=[min_percentile, max_percentile], + include='all') + # Summarizing field strength + df_fs = pd.DataFrame(param.fieldStrength.data, + columns=['fieldStrength']).describe(percentiles=[min_percentile, max_percentile], + include='all') + # Summarizing repetition time + df_rt = pd.DataFrame(param.repetitionTime.data, + columns=['repetitionTime']).describe(percentiles=[min_percentile, max_percentile], + include='all') + # Summarizing echo time + df_et = pd.DataFrame(param.echoTime.data, + columns=['echoTime']).describe(percentiles=[min_percentile, max_percentile], + include='all') + # Summarizing inversion time + df_it = pd.DataFrame(param.inversionTime.data, + columns=['inversionTime']).describe(percentiles=[min_percentile, max_percentile], + include='all') + # Summarizing echo train length + df_etl = pd.DataFrame(param.echoTrainLength.data, + columns=['echoTrainLength']).describe(percentiles=[min_percentile, max_percentile], + include='all') + # Summarizing flip angle + df_fa = pd.DataFrame(param.flipAngle.data, + columns=['flipAngle']).describe(percentiles=[min_percentile, max_percentile], + include='all') + # Summarizing number of averages + df_na = pd.DataFrame(param.numberAverages.data, + columns=['numberAverages']).describe(percentiles=[min_percentile, max_percentile], + include='all') + # Summarizing xy-spacing + df_xy = pd.DataFrame(param.xyDim.data, + columns=['xyDim']) + # Summarizing z-spacing + df_z = pd.DataFrame(param.zDim.data, + columns=['zDim']) + + # Plotting xy-spacing histogram + ax = df_xy.hist(column='xyDim') + min_quant, max_quant, average = df_xy.quantile(min_percentile), df_xy.quantile(max_percentile), param.xyDim.data.mean() + for x in ax[0]: + x.axvline(min_quant.xyDim, linestyle=':', color='r', label=f"Min Percentile: {float(min_quant):.3f}") + x.axvline(max_quant.xyDim, linestyle=':', color='g', label=f"Max Percentile: {float(max_quant):.3f}") + x.axvline(average, linestyle='solid', color='gold', label=f"Average: {float(average):.3f}") + x.grid(False) + plt.title(f"MR xy-spacing imaging summary for {wildcard}") + plt.legend() + plt.show() + + # Plotting z-spacing histogram + ax = df_z.hist(column='zDim') + min_quant, max_quant, average = df_z.quantile(min_percentile), df_z.quantile(max_percentile), param.zDim.data.mean() + for x in ax[0]: + x.axvline(min_quant.zDim, linestyle=':', color='r', label=f"Min Percentile: {float(min_quant):.3f}") + x.axvline(max_quant.zDim, linestyle=':', color='g', label=f"Max Percentile: {float(max_quant):.3f}") + x.axvline(average, linestyle='solid', color='gold', label=f"Average: {float(average):.3f}") + x.grid(False) + plt.title(f"MR z-spacing imaging summary for {wildcard}") + plt.legend() + plt.show() + + # Summarizing xy-spacing + df_xy = df_xy.describe(percentiles=[min_percentile, max_percentile], include='all') + # Summarizing z-spacing + df_z = df_z.describe(percentiles=[min_percentile, max_percentile], include='all') + + # Saving data + name_save = wildcard.replace('*', '').replace('.npy', '') + save_name = 'imagingSummary__' + name_save + ".json" + df_all = [df_years, df_fs, df_rt, df_et, df_it, df_etl, df_fa, df_na, df_xy, df_z] + df_all = df_all[0].join(df_all[1:]) + df_all.to_json(path_save_checks / save_name, orient='columns', indent=4) + + def perform_ct_imaging_summary(self, + wildcards_scans: List[str], + path_data: Path = None, + path_save_checks: Path = None, + min_percentile: float = 0.05, + max_percentile: float = 0.95 + ) -> None: + """ + Summarizes CT imaging acquisition parameters. Plots summary histograms + for different dimensions and saves all acquisition parameters locally in JSON files. + + Args: + wildcards_scans (List[str]): List of wildcards that determines the scans + that will be analyzed (Only MRI scans will be analyzed). You can learn more about wildcards in + `this link `_. + For example: ``[\"STS*.CTscan.npy\"]``. + path_data (Path, optional): Path to the MEDscan objects, if not specified will use ``path_save`` from the + inner-class ``Paths`` in the current instance. + path_save_checks (Path, optional): Path where to save the checks, if not specified will use the one + in the current instance. + min_percentile (float, optional): Minimum percentile to use for the histograms. Defaults to 0.05. + max_percentile (float, optional): Maximum percentile to use for the histograms. Defaults to 0.95. + + Returns: + None. + """ + + class param: + manufacturer = [] + dates = [] + kernel = [] + + class years: + data = [] + class voltage: + data = [] + class exposure: + data = [] + class xyDim: + data = [] + class zDim: + data = [] + + if len(wildcards_scans) == 0: + print('wildcards_scans is empty') + + # wildcards checks: + no_mr_scan = True + for wildcard in wildcards_scans: + if 'CTscan' in wildcard: + no_mr_scan = False + if no_mr_scan: + raise ValueError(f"wildcards: {wildcards_scans} does not include CT scans. (Only CT scans are supported)") + + # Initialization + if path_data is None: + if self.paths._path_save: + path_data = Path(self.paths._path_save) + else: + print("No path to data was given and path save is None.") + return 0 + + if not path_save_checks: + if self.paths._path_save_checks: + path_save_checks = Path(self.paths._path_save_checks) + else: + if (Path(os.getcwd()) / "checks").exists(): + path_save_checks = Path(os.getcwd()) / "checks" + else: + path_save_checks = (Path(os.getcwd()) / "checks").mkdir() + + # Looping through all the different wildcards + for i in tqdm(range(len(wildcards_scans))): + wildcard = wildcards_scans[i] + file_paths = get_file_paths(path_data, wildcard) + n_files = len(file_paths) + param.dates = np.zeros(n_files) + param.years.data = np.zeros(n_files) + param.years.data = np.multiply(param.years.data, np.NaN) + param.manufacturer = [None] * n_files + param.voltage.data = np.zeros(n_files) + param.voltage.data = np.multiply(param.voltage.data, np.NaN) + param.exposure.data = np.zeros(n_files) + param.exposure.data = np.multiply(param.exposure.data, np.NaN) + param.kernel = [None] * n_files + param.xyDim.data = np.zeros(n_files) + param.xyDim.data = np.multiply(param.xyDim.data, np.NaN) + param.zDim.data = np.zeros(n_files) + param.zDim.data = np.multiply(param.zDim.data, np.NaN) + + # Loading and recording data + for f in tqdm(range(n_files)): + file = file_paths[f] + + # Open file for warning + try: + warn_file = open(path_save_checks / 'imaging_summary_ct_warnings.txt', 'a') + except IOError: + print("Could not open file") + + # Loading Data + try: + with open(path_data / file, 'rb') as fe: medscan = pickle.load(fe) + print(f'Currently working on: {file}', file=warn_file) + + # DICOM header + info = medscan.dicomH[1] + + # Recording dates + try: + param.dates[f] = info.AcquisitionDate + except AttributeError: + param.dates[f] = info.StudyDate + # Recording years + try: + years = str(param.dates[f]) # Only the first four characters represent the years + param.years.data[f] = years[0:4] + except Exception as e: + print(f'Cannot read dates of : {file}. Error: {e}', file=warn_file) + # Recording manufacturers + try: + param.manufacturer[f] = info.Manufacturer + except Exception as e: + print(f'Cannot read Manufacturer of: {file}. Error: {e}', file=warn_file) + # Recording voltage + try: + param.voltage.data[f] = info.KVP + except Exception as e: + print(f'Cannot read voltage of: {file}. Error: {e}', file=warn_file) + # Recording exposure + try: + param.exposure.data[f] = info.Exposure + except Exception as e: + print(f'Cannot read exposure of: {file}. Error: {e}', file=warn_file) + # Recording reconstruction kernel + try: + param.kernel[f] = info.ConvolutionKernel + except Exception as e: + print(f'Cannot read Kernel of: {file}. Error: {e}', file=warn_file) + # Recording xy spacing + try: + param.xyDim.data[f] = medscan.data.volume.spatialRef.PixelExtentInWorldX + except Exception as e: + print(f'Cannot read x spacing of: {file}. Error: {e}', file=warn_file) + # Recording z spacing + try: + param.zDim.data[f] = medscan.data.volume.spatialRef.PixelExtentInWorldZ + except Exception as e: + print(f'Cannot read z spacing of: {file}. Error: {e}', file=warn_file) + except Exception as e: + print(f'Cannot load file: {file}', file=warn_file) + + warn_file.close() + + # Summarize data + # Summarizing years + df_years = pd.DataFrame(param.years.data, columns=['years']).describe(percentiles=[min_percentile, max_percentile], include='all') + # Summarizing voltage + df_voltage = pd.DataFrame(param.voltage.data, columns=['voltage']).describe(percentiles=[min_percentile, max_percentile], include='all') + # Summarizing exposure + df_exposure = pd.DataFrame(param.exposure.data, columns=['exposure']).describe(percentiles=[min_percentile, max_percentile], include='all') + # Summarizing kernel + df_kernel = pd.DataFrame(param.kernel, columns=['kernel']).describe(percentiles=[min_percentile, max_percentile], include='all') + # Summarize xy spacing + df_xy = pd.DataFrame(param.xyDim.data, columns=['xyDim']).describe(percentiles=[min_percentile, max_percentile], include='all') + # Summarize z spacing + df_z = pd.DataFrame(param.zDim.data, columns=['zDim']).describe(percentiles=[min_percentile, max_percentile], include='all') + # Summarizing xy-spacing + df_xy = pd.DataFrame(param.xyDim.data, columns=['xyDim']) + # Summarizing z-spacing + df_z = pd.DataFrame(param.zDim.data, columns=['zDim']) + + # Plotting xy-spacing histogram + ax = df_xy.hist(column='xyDim') + min_quant, max_quant, average = df_xy.quantile(min_percentile), df_xy.quantile(max_percentile), param.xyDim.data.mean() + for x in ax[0]: + x.axvline(min_quant.xyDim, linestyle=':', color='r', label=f"Min Percentile: {float(min_quant):.3f}") + x.axvline(max_quant.xyDim, linestyle=':', color='g', label=f"Max Percentile: {float(max_quant):.3f}") + x.axvline(average, linestyle='solid', color='gold', label=f"Average: {float(average):.3f}") + x.grid(False) + plt.title(f"CT xy-spacing imaging summary for {wildcard}") + plt.legend() + plt.show() + + # Plotting z-spacing histogram + ax = df_z.hist(column='zDim') + min_quant, max_quant, average = df_z.quantile(min_percentile), df_z.quantile(max_percentile), param.zDim.data.mean() + for x in ax[0]: + x.axvline(min_quant.zDim, linestyle=':', color='r', label=f"Min Percentile: {float(min_quant):.3f}") + x.axvline(max_quant.zDim, linestyle=':', color='g', label=f"Max Percentile: {float(max_quant):.3f}") + x.axvline(average, linestyle='solid', color='gold', label=f"Average: {float(average):.3f}") + x.grid(False) + plt.title(f"CT z-spacing imaging summary for {wildcard}") + plt.legend() + plt.show() + + # Summarizing xy-spacing + df_xy = df_xy.describe(percentiles=[min_percentile, max_percentile], include='all') + # Summarizing z-spacing + df_z = df_z.describe(percentiles=[min_percentile, max_percentile], include='all') + + # Saving data + name_save = wildcard.replace('*', '').replace('.npy', '') + save_name = 'imagingSummary__' + name_save + ".json" + df_all = [df_years, df_voltage, df_exposure, df_kernel, df_xy, df_z] + df_all = df_all[0].join(df_all[1:]) + df_all.to_json(path_save_checks / save_name, orient='columns', indent=4) + + def perform_imaging_summary(self, + wildcards_scans: List[str], + path_data: Path = None, + path_save_checks: Path = None, + min_percentile: float = 0.05, + max_percentile: float = 0.95 + ) -> None: + """ + Summarizes CT and MR imaging acquisition parameters. Plots summary histograms + for different dimensions and saves all acquisition parameters locally in JSON files. + + Args: + wildcards_scans (List[str]): List of wildcards that determines the scans + that will be analyzed (CT and MRI scans will be analyzed). You can learn more about wildcards in + `this link `_. + For example: ``[\"STS*.CTscan.npy\", \"STS*.MRscan.npy\"]``. + path_data (Path, optional): Path to the MEDscan objects, if not specified will use ``path_save`` from the + inner-class ``Paths`` in the current instance. + path_save_checks (Path, optional): Path where to save the checks, if not specified will use the one + in the current instance. + min_percentile (float, optional): Minimum percentile to use for the histograms. Defaults to 0.05. + max_percentile (float, optional): Maximum percentile to use for the histograms. Defaults to 0.95. + + Returns: + None. + """ + # MR imaging summary + wildcards_scans_mr = [wildcard for wildcard in wildcards_scans if 'MRscan' in wildcard] + if len(wildcards_scans_mr) == 0: + print("Cannot perform imaging summary for MR, no MR scan wildcard was given! ") + else: + self.perform_mr_imaging_summary( + wildcards_scans_mr, + path_data, + path_save_checks, + min_percentile, + max_percentile) + # CT imaging summary + wildcards_scans_ct = [wildcard for wildcard in wildcards_scans if 'CTscan' in wildcard] + if len(wildcards_scans_ct) == 0: + print("Cannot perform imaging summary for CT, no CT scan wildcard was given! ") + else: + self.perform_ct_imaging_summary( + wildcards_scans_ct, + path_data, + path_save_checks, + min_percentile, + max_percentile) diff --git a/MEDiml/wrangling/ProcessDICOM.py b/MEDiml/wrangling/ProcessDICOM.py new file mode 100644 index 0000000..afd7269 --- /dev/null +++ b/MEDiml/wrangling/ProcessDICOM.py @@ -0,0 +1,512 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import sys +import warnings +from typing import List, Union + +import numpy as np +import pydicom +import ray + +from ..utils.imref import imref3d + +warnings.simplefilter("ignore") + +from pathlib import Path + +from ..MEDscan import MEDscan +from ..processing.segmentation import get_roi +from ..utils.save_MEDscan import save_MEDscan + + +class ProcessDICOM(): + """ + Class to process dicom files and extract imaging volume and 3D masks from it + in order to oganize the data in a MEDscan class object. + """ + + def __init__( + self, + path_images: List[Path], + path_rs: List[Path], + path_save: Union[str, Path], + save: bool) -> None: + """ + Args: + path_images (List[Path]): List of paths to the dicom files of a single scan. + path_rs (List[Path]): List of paths to the RT struct dicom files for the same scan. + path_save (Union[str, Path]): Path to the folder where the MEDscan object will be saved. + save (bool): Whether to save the MEDscan object or not. + + Returns: + None. + """ + self.path_images = path_images + self.path_rs = path_rs + self.path_save = Path(path_save) if path_save is str else path_save + self.save = save + + def __get_dicom_scan_orientation(self, dicom_header: List[pydicom.dataset.FileDataset]) -> str: + """ + Get the orientation of the scan. + + Args: + dicom_header (List[pydicom.dataset.FileDataset]): List of dicom headers. + + Returns: + str: Orientation of the scan. + """ + n_slices = len(dicom_header) + image_patient_positions_x = [dicom_header[i].ImagePositionPatient[0] for i in range(n_slices)] + image_patient_positions_y = [dicom_header[i].ImagePositionPatient[1] for i in range(n_slices)] + image_patient_positions_z = [dicom_header[i].ImagePositionPatient[2] for i in range(n_slices)] + dist = [ + np.median(np.abs(np.diff(image_patient_positions_x))), + np.median(np.abs(np.diff(image_patient_positions_y))), + np.median(np.abs(np.diff(image_patient_positions_z))) + ] + index = dist.index(max(dist)) + if index == 0: + orientation = 'Sagittal' + elif index == 1: + orientation = 'Coronal' + else: + orientation = 'Axial' + + return orientation + + def __merge_slice_pixel_arrays(self, slice_datasets): + first_dataset = slice_datasets[0] + num_rows = first_dataset.Rows + num_columns = first_dataset.Columns + num_slices = len(slice_datasets) + + sorted_slice_datasets = self.__sort_by_slice_spacing(slice_datasets) + + if any(self.__requires_rescaling(d) for d in sorted_slice_datasets): + voxels = np.empty( + (num_columns, num_rows, num_slices), dtype=np.float32) + for k, dataset in enumerate(sorted_slice_datasets): + slope = float(getattr(dataset, 'RescaleSlope', 1)) + intercept = float(getattr(dataset, 'RescaleIntercept', 0)) + voxels[:, :, k] = dataset.pixel_array.T.astype( + np.float32)*slope + intercept + else: + dtype = first_dataset.pixel_array.dtype + voxels = np.empty((num_columns, num_rows, num_slices), dtype=dtype) + for k, dataset in enumerate(sorted_slice_datasets): + voxels[:, :, k] = dataset.pixel_array.T + + return voxels + + def __requires_rescaling(self, dataset): + return hasattr(dataset, 'RescaleSlope') or hasattr(dataset, 'RescaleIntercept') + + def __ijk_to_patient_xyz_transform_matrix(self, slice_datasets): + first_dataset = self.__sort_by_slice_spacing(slice_datasets)[0] + image_orientation = first_dataset.ImageOrientationPatient + row_cosine, column_cosine, slice_cosine = self.__extract_cosines( + image_orientation) + + row_spacing, column_spacing = first_dataset.PixelSpacing + slice_spacing = self.__slice_spacing(slice_datasets) + + transform = np.identity(4, dtype=np.float32) + rotation = np.identity(3, dtype=np.float32) + scaling = np.identity(3, dtype=np.float32) + + transform[:3, 0] = row_cosine*column_spacing + transform[:3, 1] = column_cosine*row_spacing + transform[:3, 2] = slice_cosine*slice_spacing + + transform[:3, 3] = first_dataset.ImagePositionPatient + + rotation[:3, 0] = row_cosine + rotation[:3, 1] = column_cosine + rotation[:3, 2] = slice_cosine + + rotation = np.transpose(rotation) + + scaling[0, 0] = column_spacing + scaling[1, 1] = row_spacing + scaling[2, 2] = slice_spacing + + return transform, rotation, scaling + + def __validate_slices_form_uniform_grid(self, slice_datasets): + """ + Perform various data checks to ensure that the list of slices form a + evenly-spaced grid of data. + Some of these checks are probably not required if the data follows the + DICOM specification, however it seems pertinent to check anyway. + """ + invariant_properties = [ + 'Modality', + 'SOPClassUID', + 'SeriesInstanceUID', + 'Rows', + 'Columns', + 'ImageOrientationPatient', + 'PixelSpacing', + 'PixelRepresentation', + 'BitsAllocated', + 'BitsStored', + 'HighBit', + ] + + for property_name in invariant_properties: + self.__slice_attribute_equal(slice_datasets, property_name) + + self.__validate_image_orientation(slice_datasets[0].ImageOrientationPatient) + + slice_positions = self.__slice_positions(slice_datasets) + self.__check_for_missing_slices(slice_positions) + + def __validate_image_orientation(self, image_orientation): + """ + Ensure that the image orientation is supported + - The direction cosines have magnitudes of 1 (just in case) + - The direction cosines are perpendicular + """ + + row_cosine, column_cosine, slice_cosine = self.__extract_cosines( + image_orientation) + + if not self.__almost_zero(np.dot(row_cosine, column_cosine), 1e-4): + raise ValueError( + "Non-orthogonal direction cosines: {}, {}".format(row_cosine, column_cosine)) + elif not self.__almost_zero(np.dot(row_cosine, column_cosine), 1e-8): + warnings.warn("Direction cosines aren't quite orthogonal: {}, {}".format( + row_cosine, column_cosine)) + + if not self.__almost_one(np.linalg.norm(row_cosine), 1e-4): + raise ValueError( + "The row direction cosine's magnitude is not 1: {}".format(row_cosine)) + elif not self.__almost_one(np.linalg.norm(row_cosine), 1e-8): + warnings.warn( + "The row direction cosine's magnitude is not quite 1: {}".format(row_cosine)) + + if not self.__almost_one(np.linalg.norm(column_cosine), 1e-4): + raise ValueError( + "The column direction cosine's magnitude is not 1: {}".format(column_cosine)) + elif not self.__almost_one(np.linalg.norm(column_cosine), 1e-8): + warnings.warn( + "The column direction cosine's magnitude is not quite 1: {}".format(column_cosine)) + sys.stderr.flush() + + def __is_close(self, a, b, rel_tol=1e-9, abs_tol=0.0): + return abs(a-b) <= max(rel_tol*max(abs(a), abs(b)), abs_tol) + + def __almost_zero(self, value, abs_tol): + return self.__is_close(value, 0.0, abs_tol=abs_tol) + + def __almost_one(self, value, abs_tol): + return self.__is_close(value, 1.0, abs_tol=abs_tol) + + def __extract_cosines(self, image_orientation): + row_cosine = np.array(image_orientation[:3]) + column_cosine = np.array(image_orientation[3:]) + slice_cosine = np.cross(row_cosine, column_cosine) + return row_cosine, column_cosine, slice_cosine + + def __slice_attribute_equal(self, slice_datasets, property_name): + initial_value = getattr(slice_datasets[0], property_name, None) + for slice_idx, dataset in enumerate(slice_datasets[1:]): + value = getattr(dataset, property_name, None) + if value != initial_value: + msg = f'Slice {slice_idx+1} have different value for {property_name}: {value} != {initial_value}' + warnings.warn(msg) + + def __slice_positions(self, slice_datasets): + image_orientation = slice_datasets[0].ImageOrientationPatient + row_cosine, column_cosine, slice_cosine = self.__extract_cosines( + image_orientation) + return [np.dot(slice_cosine, d.ImagePositionPatient) for d in slice_datasets] + + def __check_for_missing_slices(self, slice_positions): + slice_positions_diffs = np.diff(sorted(slice_positions)) + if not np.allclose(slice_positions_diffs, slice_positions_diffs[0], atol=0, rtol=1e-5): + msg = "The slice spacing is non-uniform. Slice spacings:\n{}" + warnings.warn(msg.format(slice_positions_diffs)) + sys.stderr.flush() + if not np.allclose(slice_positions_diffs, slice_positions_diffs[0], atol=0, rtol=1e-1): + raise ValueError('The slice spacing is non-uniform. It appears there are extra slices from another scan') + + def __slice_spacing(self, slice_datasets): + if len(slice_datasets) > 1: + slice_positions = self.__slice_positions(slice_datasets) + slice_positions_diffs = np.diff(sorted(slice_positions)) + return np.mean(slice_positions_diffs) + + return 0.0 + + def __sort_by_slice_spacing(self, slice_datasets): + slice_spacing = self.__slice_positions(slice_datasets) + return [d for (s, d) in sorted(zip(slice_spacing, slice_datasets))] + + def combine_slices(self, slice_datasets: List[pydicom.dataset.FileDataset]) -> List[np.ndarray]: + """ + Given a list of pydicom datasets for an image series, stitch them together into a + three-dimensional numpy array of iamging data. Also calculate a 4x4 affine transformation + matrix that converts the ijk-pixel-indices into the xyz-coordinates in the + DICOM patient's coordinate system and 4x4 rotation and scaling matrix. + If any of the DICOM images contain either the + `Rescale Slope `__ or the + `Rescale Intercept `__ + attributes they will be applied to each slice individually. + This function requires that the datasets: + + - Be in same series (have the same + `Series Instance UID `__, + `Modality `__, + and `SOP Class UID `__). + - The binary storage of each slice must be the same (have the same + `Bits Allocated `__, + `Bits Stored `__, + `High Bit `__, and + `Pixel Representation `__). + - The image slice must approximately form a grid. This means there can not + be any missing internal slices (missing slices on the ends of the dataset + are not detected). It also means that each slice must have the same + `Rows `__, + `Columns `__, + `Pixel Spacing `__, and + `Image Orientation (Patient) `__ + attribute values. + - The direction cosines derived from the + `Image Orientation (Patient) `__ + attribute must, within 1e-4, have a magnitude of 1. The cosines must + also be approximately perpendicular (their dot-product must be within + 1e-4 of 0). Warnings are displayed if any of theseapproximations are + below 1e-8, however, since we have seen real datasets with values up to + 1e-4, we let them pass. + - The `Image Position (Patient) `__ + values must approximately form a line. + + If any of these conditions are not met, a `dicom_numpy.DicomImportException` is raised. + + Args: + slice_datasets (List[pydicom.dataset.FileDataset]): List of dicom headers. + Returns: + List[numpy.ndarray]: List of numpy arrays containing the data extracted the dicom files + (voxels, translation, rotation and scaling matrix). + """ + + if not slice_datasets: + raise ValueError("Must provide at least one DICOM dataset") + + self.__validate_slices_form_uniform_grid(slice_datasets) + + voxels = self.__merge_slice_pixel_arrays(slice_datasets) + transform, rotation, scaling = self.__ijk_to_patient_xyz_transform_matrix( + slice_datasets) + + return voxels, transform, rotation, scaling + + def process_files(self): + """ + Reads DICOM files (imaging volume + ROIs) in the instance data path + and then organizes it in the MEDscan class. + + Args: + None. + + Returns: + medscan (MEDscan): Instance of a MEDscan class. + """ + + return self.process_files_wrapper.remote(self) + + @ray.remote + def process_files_wrapper(self) -> MEDscan: + """ + Wrapper function to process the files. + """ + + # PARTIAL PARSING OF ARGUMENTS + if self.path_images is None: + raise ValueError('At least two arguments must be provided') + + # INITIALIZATION + medscan = MEDscan() + + # IMAGING DATA AND ROI DEFINITION (if applicable) + # Reading DICOM images and headers + dicom_hi = [pydicom.dcmread(str(dicom_file), force=True) + for dicom_file in self.path_images] + + try: + # Determination of the scan orientation + medscan.data.orientation = self.__get_dicom_scan_orientation(dicom_hi) + + # IMPORTANT NOTE: extract_voxel_data using combine_slices from dicom_numpy + # missing slices and oblique restrictions apply see the reference: + # https://dicom-numpy.readthedocs.io/en/latest/index.html#dicom_numpy.combine_slices + try: + voxel_ndarray, ijk_to_xyz, rotation_m, scaling_m = self.combine_slices(dicom_hi) + except ValueError as e: + raise ValueError(f'Invalid DICOM data for combine_slices(). Error: {e}') + + # Alignment of scan coordinates for MR scans + # (inverse of ImageOrientationPatient rotation matrix) + if not np.allclose(rotation_m, np.eye(rotation_m.shape[0])): + medscan.data.volume.scan_rot = rotation_m + + medscan.data.volume.array = voxel_ndarray + medscan.type = dicom_hi[0].Modality + 'scan' + + # 7. Creation of imref3d object + pixel_x = scaling_m[0, 0] + pixel_y = scaling_m[1, 1] + slice_s = scaling_m[2, 2] + min_grid = rotation_m@ijk_to_xyz[:3, 3] + min_x_grid = min_grid[0] + min_y_grid = min_grid[1] + min_z_grid = min_grid[2] + size_image = np.shape(voxel_ndarray) + spatial_ref = imref3d(size_image, pixel_x, pixel_y, slice_s) + spatial_ref.XWorldLimits = (np.array(spatial_ref.XWorldLimits) - + (spatial_ref.XWorldLimits[0] - + (min_x_grid-pixel_x/2))).tolist() + spatial_ref.YWorldLimits = (np.array(spatial_ref.YWorldLimits) - + (spatial_ref.YWorldLimits[0] - + (min_y_grid-pixel_y/2))).tolist() + spatial_ref.ZWorldLimits = (np.array(spatial_ref.ZWorldLimits) - + (spatial_ref.ZWorldLimits[0] - + (min_z_grid-slice_s/2))).tolist() + + # Converting the results into lists + spatial_ref.ImageSize = spatial_ref.ImageSize.tolist() + spatial_ref.XIntrinsicLimits = spatial_ref.XIntrinsicLimits.tolist() + spatial_ref.YIntrinsicLimits = spatial_ref.YIntrinsicLimits.tolist() + spatial_ref.ZIntrinsicLimits = spatial_ref.ZIntrinsicLimits.tolist() + + # Update the spatial reference in the MEDscan class + medscan.data.volume.spatialRef = spatial_ref + + # DICOM HEADERS OF IMAGING DATA + dicom_h = [ + pydicom.dcmread(str(dicom_file),stop_before_pixels=True,force=True) for dicom_file in self.path_images + ] + for i in range(0, len(dicom_h)): + dicom_h[i].remove_private_tags() + medscan.dicomH = dicom_h + + # DICOM RTstruct (if applicable) + if self.path_rs is not None and len(self.path_rs) > 0: + dicom_rs_full = [ + pydicom.dcmread(str(dicom_file), + stop_before_pixels=True, + force=True) + for dicom_file in self.path_rs + ] + for i in range(0, len(dicom_rs_full)): + dicom_rs_full[i].remove_private_tags() + + # GATHER XYZ POINTS OF ROIs USING RTstruct + n_rs = len(dicom_rs_full) if type(dicom_rs_full) is list else dicom_rs_full + contour_num = 0 + for rs in range(n_rs): + n_roi = len(dicom_rs_full[rs].StructureSetROISequence) + for roi in range(n_roi): + if roi!=0: + if dicom_rs_full[rs].StructureSetROISequence[roi].ROIName == \ + dicom_rs_full[rs].StructureSetROISequence[roi-1].ROIName: + continue + points = [] + name_set_strings = ['StructureSetName', 'StructureSetDescription', + 'series_description', 'SeriesInstanceUID'] + for name_field in name_set_strings: + if name_field in dicom_rs_full[rs]: + name_set = getattr(dicom_rs_full[rs], name_field) + name_set_info = name_field + break + + medscan.data.ROI.update_roi_name(key=contour_num, + roi_name=dicom_rs_full[rs].StructureSetROISequence[roi].ROIName) + medscan.data.ROI.update_indexes(key=contour_num, + indexes=None) + medscan.data.ROI.update_name_set(key=contour_num, + name_set=name_set) + medscan.data.ROI.update_name_set_info(key=contour_num, + nameSetInfo=name_set_info) + + try: + n_closed_contour = len(dicom_rs_full[rs].ROIContourSequence[roi].ContourSequence) + ind_closed_contour = [] + for s in range(0, n_closed_contour): + # points stored in the RTstruct file for a given closed + # contour (beware: there can be multiple closed contours + # on a given slice). + pts_temp = dicom_rs_full[rs].ROIContourSequence[roi].ContourSequence[s].ContourData + n_points = int(len(pts_temp) / 3) + if len(pts_temp) > 0: + ind_closed_contour = ind_closed_contour + np.tile(s, n_points).tolist() + if type(points) == list: + points = np.reshape(np.transpose(pts_temp),(n_points, 3)) + else: + points = np.concatenate( + (points, np.reshape(np.transpose(pts_temp), (n_points, 3))), + axis=0 + ) + if n_closed_contour == 0: + print(f'Warning: no contour data found for ROI: \ + {dicom_rs_full[rs].StructureSetROISequence[roi].ROIName}') + else: + # Save the XYZ points in the MEDscan class + medscan.data.ROI.update_indexes( + key=contour_num, + indexes=np.concatenate( + (points, + np.reshape(ind_closed_contour, (len(ind_closed_contour), 1))), + axis=1) + ) + # Compute the ROI box + _, roi_obj = get_roi( + medscan, + name_roi='{' + dicom_rs_full[rs].StructureSetROISequence[roi].ROIName + '}', + box_string='full' + ) + + # Save the ROI box non-zero indexes in the MEDscan class + medscan.data.ROI.update_indexes(key=contour_num, indexes=np.nonzero(roi_obj.data.flatten())) + + except Exception as e: + if 'SeriesDescription' in dicom_h[0]: + print(f'patientID: {dicom_hi[0].PatientID} Modality: {dicom_hi[0].SeriesDescription} error: \ + {str(e)} n_roi: {str(roi)} n_rs: {str(rs)}') + else: + print(f'patientID: {dicom_hi[0].PatientID} Modality: {dicom_hi[0].Modality} error: \ + {str(e)} n_roi: {str(roi)} n_rs: {str(rs)}') + medscan.data.ROI.update_indexes(key=contour_num, indexes=np.NaN) + contour_num += 1 + + # Save additional scan information in the MEDscan class + medscan.data.set_patient_position(patient_position=dicom_h[0].PatientPosition) + medscan.patientID = str(dicom_h[0].PatientID) + medscan.format = "dicom" + if 'SeriesDescription' in dicom_h[0]: + medscan.series_description = dicom_h[0].SeriesDescription + else: + medscan.series_description = dicom_h[0].Modality + + # save MEDscan class instance as a pickle object + if self.save and self.path_save: + name_complete = save_MEDscan(medscan, self.path_save) + del medscan + else: + series_description = medscan.series_description.translate({ord(ch): '-' for ch in '/\\ ()&:*'}) + name_id = medscan.patientID.translate({ord(ch): '-' for ch in '/\\ ()&:*'}) + + # final saving name + name_complete = name_id + '__' + series_description + '.' + medscan.type + '.npy' + + except Exception as e: + if 'SeriesDescription' in dicom_hi[0]: + print(f'patientID: {dicom_hi[0].PatientID} Modality: {dicom_hi[0].SeriesDescription} error: {str(e)}') + else: + print(f'patientID: {dicom_hi[0].PatientID} Modality: {dicom_hi[0].Modality} error: {str(e)}') + return '' + + return name_complete diff --git a/MEDiml/wrangling/__init__.py b/MEDiml/wrangling/__init__.py new file mode 100644 index 0000000..240b253 --- /dev/null +++ b/MEDiml/wrangling/__init__.py @@ -0,0 +1,3 @@ +from . import * +from .DataManager import * +from .ProcessDICOM import * From 01965e35cb4d50aa2b26778d9e31cab9f35cd459 Mon Sep 17 00:00:00 2001 From: MahdiAll99 Date: Tue, 20 Jan 2026 11:42:03 -0500 Subject: [PATCH 3/7] MEDimage to MEDiml --- MEDimage/MEDscan.py | 1696 ------------- MEDimage/__init__.py | 21 - MEDimage/biomarkers/BatchExtractor.py | 806 ------ .../BatchExtractorTexturalFilters.py | 840 ------- MEDimage/biomarkers/__init__.py | 16 - MEDimage/biomarkers/diagnostics.py | 125 - MEDimage/biomarkers/get_oriented_bound_box.py | 158 -- MEDimage/biomarkers/glcm.py | 1602 ------------ MEDimage/biomarkers/gldzm.py | 523 ---- MEDimage/biomarkers/glrlm.py | 1315 ---------- MEDimage/biomarkers/glszm.py | 555 ---- MEDimage/biomarkers/int_vol_hist.py | 527 ---- MEDimage/biomarkers/intensity_histogram.py | 615 ----- MEDimage/biomarkers/local_intensity.py | 89 - MEDimage/biomarkers/morph.py | 1756 ------------- MEDimage/biomarkers/ngldm.py | 780 ------ MEDimage/biomarkers/ngtdm.py | 414 --- MEDimage/biomarkers/stats.py | 373 --- MEDimage/biomarkers/utils.py | 389 --- MEDimage/filters/TexturalFilter.py | 299 --- MEDimage/filters/__init__.py | 9 - MEDimage/filters/apply_filter.py | 134 - MEDimage/filters/gabor.py | 215 -- MEDimage/filters/laws.py | 283 --- MEDimage/filters/log.py | 147 -- MEDimage/filters/mean.py | 121 - MEDimage/filters/textural_filters_kernels.py | 1738 ------------- MEDimage/filters/utils.py | 107 - MEDimage/filters/wavelet.py | 237 -- MEDimage/learning/DataCleaner.py | 198 -- MEDimage/learning/DesignExperiment.py | 480 ---- MEDimage/learning/FSR.py | 667 ----- MEDimage/learning/Normalization.py | 112 - MEDimage/learning/RadiomicsLearner.py | 714 ------ MEDimage/learning/Results.py | 2237 ----------------- MEDimage/learning/Stats.py | 694 ----- MEDimage/learning/__init__.py | 10 - MEDimage/learning/cleaning_utils.py | 107 - MEDimage/learning/ml_utils.py | 1015 -------- MEDimage/processing/__init__.py | 6 - MEDimage/processing/compute_suv_map.py | 121 - MEDimage/processing/discretisation.py | 149 -- MEDimage/processing/interpolation.py | 275 -- MEDimage/processing/resegmentation.py | 66 - MEDimage/processing/segmentation.py | 912 ------- MEDimage/utils/__init__.py | 25 - MEDimage/utils/batch_patients.py | 45 - MEDimage/utils/create_radiomics_table.py | 131 - MEDimage/utils/data_frame_export.py | 42 - MEDimage/utils/find_process_names.py | 16 - MEDimage/utils/get_file_paths.py | 34 - MEDimage/utils/get_full_rad_names.py | 21 - MEDimage/utils/get_institutions_from_ids.py | 16 - .../utils/get_patient_id_from_scan_name.py | 22 - MEDimage/utils/get_patient_names.py | 26 - MEDimage/utils/get_radiomic_names.py | 27 - MEDimage/utils/get_scan_name_from_rad_name.py | 22 - MEDimage/utils/image_reader_SITK.py | 37 - MEDimage/utils/image_volume_obj.py | 22 - MEDimage/utils/imref.py | 340 --- MEDimage/utils/initialize_features_names.py | 62 - MEDimage/utils/inpolygon.py | 159 -- MEDimage/utils/interp3.py | 43 - MEDimage/utils/json_utils.py | 78 - MEDimage/utils/mode.py | 31 - MEDimage/utils/parse_contour_string.py | 58 - MEDimage/utils/save_MEDscan.py | 30 - MEDimage/utils/strfind.py | 32 - MEDimage/utils/textureTools.py | 188 -- MEDimage/utils/texture_features_names.py | 115 - MEDimage/utils/write_radiomics_csv.py | 47 - MEDimage/wrangling/DataManager.py | 1724 ------------- MEDimage/wrangling/ProcessDICOM.py | 512 ---- MEDimage/wrangling/__init__.py | 3 - Makefile.mk | 4 +- README.md | 42 +- docs/FAQs.rst | 2 +- docs/Installation.rst | 25 +- docs/MEDscan.rst | 2 +- docs/biomarkers.rst | 30 +- docs/conf.py | 6 +- docs/csv_file.rst | 4 +- docs/extraction_config.rst | 17 +- docs/filters.rst | 14 +- docs/index.rst | 6 +- docs/input_data.rst | 12 +- docs/learning.rst | 12 +- docs/modules.rst | 2 +- docs/processing.rst | 10 +- docs/tutorials.rst | 58 +- docs/utils.rst | 46 +- docs/wrangling.rst | 4 +- environment.yml | 2 +- notebooks/README.md | 4 +- pyproject.toml | 8 +- scripts/download_data.py | 32 +- scripts/process_dataset.py | 2 +- setup.py | 8 +- tests/test_extraction.py | 18 +- tests/test_filtering.py | 4 +- 100 files changed, 186 insertions(+), 27749 deletions(-) delete mode 100644 MEDimage/MEDscan.py delete mode 100644 MEDimage/__init__.py delete mode 100644 MEDimage/biomarkers/BatchExtractor.py delete mode 100644 MEDimage/biomarkers/BatchExtractorTexturalFilters.py delete mode 100644 MEDimage/biomarkers/__init__.py delete mode 100644 MEDimage/biomarkers/diagnostics.py delete mode 100755 MEDimage/biomarkers/get_oriented_bound_box.py delete mode 100755 MEDimage/biomarkers/glcm.py delete mode 100755 MEDimage/biomarkers/gldzm.py delete mode 100755 MEDimage/biomarkers/glrlm.py delete mode 100755 MEDimage/biomarkers/glszm.py delete mode 100755 MEDimage/biomarkers/int_vol_hist.py delete mode 100755 MEDimage/biomarkers/intensity_histogram.py delete mode 100755 MEDimage/biomarkers/local_intensity.py delete mode 100755 MEDimage/biomarkers/morph.py delete mode 100755 MEDimage/biomarkers/ngldm.py delete mode 100755 MEDimage/biomarkers/ngtdm.py delete mode 100755 MEDimage/biomarkers/stats.py delete mode 100644 MEDimage/biomarkers/utils.py delete mode 100644 MEDimage/filters/TexturalFilter.py delete mode 100644 MEDimage/filters/__init__.py delete mode 100644 MEDimage/filters/apply_filter.py delete mode 100644 MEDimage/filters/gabor.py delete mode 100644 MEDimage/filters/laws.py delete mode 100644 MEDimage/filters/log.py delete mode 100644 MEDimage/filters/mean.py delete mode 100644 MEDimage/filters/textural_filters_kernels.py delete mode 100644 MEDimage/filters/utils.py delete mode 100644 MEDimage/filters/wavelet.py delete mode 100644 MEDimage/learning/DataCleaner.py delete mode 100644 MEDimage/learning/DesignExperiment.py delete mode 100644 MEDimage/learning/FSR.py delete mode 100644 MEDimage/learning/Normalization.py delete mode 100644 MEDimage/learning/RadiomicsLearner.py delete mode 100644 MEDimage/learning/Results.py delete mode 100644 MEDimage/learning/Stats.py delete mode 100644 MEDimage/learning/__init__.py delete mode 100644 MEDimage/learning/cleaning_utils.py delete mode 100644 MEDimage/learning/ml_utils.py delete mode 100644 MEDimage/processing/__init__.py delete mode 100644 MEDimage/processing/compute_suv_map.py delete mode 100644 MEDimage/processing/discretisation.py delete mode 100644 MEDimage/processing/interpolation.py delete mode 100644 MEDimage/processing/resegmentation.py delete mode 100644 MEDimage/processing/segmentation.py delete mode 100644 MEDimage/utils/__init__.py delete mode 100644 MEDimage/utils/batch_patients.py delete mode 100644 MEDimage/utils/create_radiomics_table.py delete mode 100644 MEDimage/utils/data_frame_export.py delete mode 100644 MEDimage/utils/find_process_names.py delete mode 100644 MEDimage/utils/get_file_paths.py delete mode 100644 MEDimage/utils/get_full_rad_names.py delete mode 100644 MEDimage/utils/get_institutions_from_ids.py delete mode 100644 MEDimage/utils/get_patient_id_from_scan_name.py delete mode 100644 MEDimage/utils/get_patient_names.py delete mode 100644 MEDimage/utils/get_radiomic_names.py delete mode 100644 MEDimage/utils/get_scan_name_from_rad_name.py delete mode 100644 MEDimage/utils/image_reader_SITK.py delete mode 100644 MEDimage/utils/image_volume_obj.py delete mode 100644 MEDimage/utils/imref.py delete mode 100644 MEDimage/utils/initialize_features_names.py delete mode 100644 MEDimage/utils/inpolygon.py delete mode 100644 MEDimage/utils/interp3.py delete mode 100644 MEDimage/utils/json_utils.py delete mode 100644 MEDimage/utils/mode.py delete mode 100644 MEDimage/utils/parse_contour_string.py delete mode 100644 MEDimage/utils/save_MEDscan.py delete mode 100644 MEDimage/utils/strfind.py delete mode 100644 MEDimage/utils/textureTools.py delete mode 100644 MEDimage/utils/texture_features_names.py delete mode 100644 MEDimage/utils/write_radiomics_csv.py delete mode 100644 MEDimage/wrangling/DataManager.py delete mode 100644 MEDimage/wrangling/ProcessDICOM.py delete mode 100644 MEDimage/wrangling/__init__.py diff --git a/MEDimage/MEDscan.py b/MEDimage/MEDscan.py deleted file mode 100644 index 857a185..0000000 --- a/MEDimage/MEDscan.py +++ /dev/null @@ -1,1696 +0,0 @@ -import logging -import os -from json import dump -from pathlib import Path -from typing import Dict, List, Union - -import matplotlib.pyplot as plt -import nibabel as nib -import numpy as np -from numpyencoder import NumpyEncoder -from PIL import Image - -from .utils.image_volume_obj import image_volume_obj -from .utils.imref import imref3d -from .utils.json_utils import load_json - - -class MEDscan(object): - """Organizes all scan data (patientID, imaging data, scan type...). - - Attributes: - patientID (str): Patient ID. - type (str): Scan type (MRscan, CTscan...). - format (str): Scan file format. Either 'npy' or 'nifti'. - dicomH (pydicom.dataset.FileDataset): DICOM header. - data (MEDscan.data): Instance of MEDscan.data inner class. - - """ - - def __init__(self, medscan=None) -> None: - """Constructor of the MEDscan class - - Args: - medscan(MEDscan): A MEDscan class instance. - - Returns: - None - """ - try: - self.patientID = medscan.patientID - except: - self.patientID = "" - try: - self.type = medscan.type - except: - self.type = "" - try: - self.series_description = medscan.series_description - except: - self.series_description = "" - try: - self.format = medscan.format - except: - self.format = "" - try: - self.dicomH = medscan.dicomH - except: - self.dicomH = [] - try: - self.data = medscan.data - except: - self.data = self.data() - - self.params = self.Params() - self.radiomics = self.Radiomics() - self.skip = False - - def __init_process_params(self, im_params: Dict) -> None: - """Initializes the processing params from a given Dict. - - Args: - im_params(Dict): Dictionary of different processing params. - - Returns: - None. - """ - if self.type == 'CTscan' and 'imParamCT' in im_params: - im_params = im_params['imParamCT'] - elif self.type == 'MRscan' and 'imParamMR' in im_params: - im_params = im_params['imParamMR'] - elif self.type == 'PTscan' and 'imParamPET' in im_params: - im_params = im_params['imParamPET'] - else: - raise ValueError(f"The given parameters dict is not valid, no params found for {self.type} modality") - - # re-segmentation range processing - if(im_params['reSeg']['range'] and (im_params['reSeg']['range'][0] == "inf" or im_params['reSeg']['range'][0] == "-inf")): - im_params['reSeg']['range'][0] = -np.inf - if(im_params['reSeg']['range'] and im_params['reSeg']['range'][1] == "inf"): - im_params['reSeg']['range'][1] = np.inf - - if 'box_string' in im_params: - box_string = im_params['box_string'] - else: - # By default, we add 10 voxels in all three dimensions are added to the smallest - # bounding box. This setting is used to speed up interpolation - # processes (mostly) prior to the computation of radiomics - # features. Optional argument in the function computeRadiomics. - box_string = 'box10' - if 'compute_diag_features' in im_params: - compute_diag_features = im_params['compute_diag_features'] - else: - compute_diag_features = False - if compute_diag_features: # If compute_diag_features is true. - box_string = 'full' # This is required for proper comparison. - - self.params.process.box_string = box_string - - # get default scan parameters from im_param_scan - self.params.process.scale_non_text = im_params['interp']['scale_non_text'] - self.params.process.vol_interp = im_params['interp']['vol_interp'] - self.params.process.roi_interp = im_params['interp']['roi_interp'] - self.params.process.gl_round = im_params['interp']['gl_round'] - self.params.process.roi_pv = im_params['interp']['roi_pv'] - self.params.process.im_range = im_params['reSeg']['range'] if 'range' in im_params['reSeg'] else None - self.params.process.outliers = im_params['reSeg']['outliers'] - self.params.process.ih = im_params['discretisation']['IH'] - self.params.process.ivh = im_params['discretisation']['IVH'] - self.params.process.scale_text = im_params['interp']['scale_text'] - self.params.process.algo = im_params['discretisation']['texture']['type'] if 'type' in im_params['discretisation']['texture'] else [] - self.params.process.gray_levels = im_params['discretisation']['texture']['val'] if 'val' in im_params['discretisation']['texture'] else [[]] - self.params.process.im_type = self.type - - # Voxels dimension - self.params.process.n_scale = len(self.params.process.scale_text) - # Setting up discretisation params - self.params.process.n_algo = len(self.params.process.algo) - self.params.process.n_gl = len(self.params.process.gray_levels[0]) - self.params.process.n_exp = self.params.process.n_scale * self.params.process.n_algo * self.params.process.n_gl - - # Setting up user_set_min_value - if self.params.process.im_range is not None and type(self.params.process.im_range) is list and self.params.process.im_range: - user_set_min_value = self.params.process.im_range[0] - if user_set_min_value == -np.inf: - # In case no re-seg im_range is defined for the FBS algorithm, - # the minimum value of ROI will be used (not recommended). - user_set_min_value = [] - else: - # In case no re-seg im_range is defined for the FBS algorithm, - # the minimum value of ROI will be used (not recommended). - user_set_min_value = [] - self.params.process.user_set_min_value = user_set_min_value - - # box_string argument is optional. If not present, we use the full box. - if self.params.process.box_string is None: - self.params.process.box_string = 'full' - - # set filter type for the modality - if 'filter_type' in im_params: - self.params.filter.filter_type = im_params['filter_type'] - - # Set intensity type - if 'intensity_type' in im_params and im_params['intensity_type'] != "": - self.params.process.intensity_type = im_params['intensity_type'] - elif self.params.filter.filter_type != "": - self.params.process.intensity_type = 'filtered' - elif self.type == 'MRscan': - self.params.process.intensity_type = 'arbitrary' - else: - self.params.process.intensity_type = 'definite' - - def __init_extraction_params(self, im_params: Dict): - """Initializes the extraction params from a given Dict. - - Args: - im_params(Dict): Dictionary of different extraction params. - - Returns: - None. - """ - if self.type == 'CTscan' and 'imParamCT' in im_params: - im_params = im_params['imParamCT'] - elif self.type == 'MRscan' and 'imParamMR' in im_params: - im_params = im_params['imParamMR'] - elif self.type == 'PTscan' and 'imParamPET' in im_params: - im_params = im_params['imParamPET'] - else: - raise ValueError(f"The given parameters dict is not valid, no params found for {self.type} modality") - - # glcm features extraction params - if 'glcm' in im_params: - if 'dist_correction' in im_params['glcm']: - self.params.radiomics.glcm.dist_correction = im_params['glcm']['dist_correction'] - else: - self.params.radiomics.glcm.dist_correction = False - if 'merge_method' in im_params['glcm']: - self.params.radiomics.glcm.merge_method = im_params['glcm']['merge_method'] - else: - self.params.radiomics.glcm.merge_method = "vol_merge" - else: - self.params.radiomics.glcm.dist_correction = False - self.params.radiomics.glcm.merge_method = "vol_merge" - - # glrlm features extraction params - if 'glrlm' in im_params: - if 'dist_correction' in im_params['glrlm']: - self.params.radiomics.glrlm.dist_correction = im_params['glrlm']['dist_correction'] - else: - self.params.radiomics.glrlm.dist_correction = False - if 'merge_method' in im_params['glrlm']: - self.params.radiomics.glrlm.merge_method = im_params['glrlm']['merge_method'] - else: - self.params.radiomics.glrlm.merge_method = "vol_merge" - else: - self.params.radiomics.glrlm.dist_correction = False - self.params.radiomics.glrlm.merge_method = "vol_merge" - - - # ngtdm features extraction params - if 'ngtdm' in im_params: - if 'dist_correction' in im_params['ngtdm']: - self.params.radiomics.ngtdm.dist_correction = im_params['ngtdm']['dist_correction'] - else: - self.params.radiomics.ngtdm.dist_correction = False - else: - self.params.radiomics.ngtdm.dist_correction = False - - # Features to extract - features = [ - "Morph", "LocalIntensity", "Stats", "IntensityHistogram", "IntensityVolumeHistogram", - "GLCM", "GLRLM", "GLSZM", "GLDZM", "NGTDM", "NGLDM" - ] - if "extract" in im_params.keys(): - self.params.radiomics.extract = im_params['extract'] - for key in self.params.radiomics.extract: - if key not in features: - raise ValueError(f"Invalid key in 'extract' parameter: {key} (Modality {self.type}).") - - # Ensure each feature is in the extract dictionary with a default value of True - for feature in features: - if feature not in self.params.radiomics.extract: - self.params.radiomics.extract[feature] = True - - def __init_filter_params(self, filter_params: Dict) -> None: - """Initializes the filtering params from a given Dict. - - Args: - filter_params(Dict): Dictionary of the filtering parameters. - - Returns: - None. - """ - if 'imParamFilter' in filter_params: - filter_params = filter_params['imParamFilter'] - - # Initializae filter attribute - self.params.filter = self.params.Filter() - - # mean filter params - if 'mean' in filter_params: - self.params.filter.mean.init_from_json(filter_params['mean']) - - # log filter params - if 'log' in filter_params: - self.params.filter.log.init_from_json(filter_params['log']) - - # laws filter params - if 'laws' in filter_params: - self.params.filter.laws.init_from_json(filter_params['laws']) - - # gabor filter params - if 'gabor' in filter_params: - self.params.filter.gabor.init_from_json(filter_params['gabor']) - - # wavelet filter params - if 'wavelet' in filter_params: - self.params.filter.wavelet.init_from_json(filter_params['wavelet']) - - # Textural filter params - if 'textural' in filter_params: - self.params.filter.textural.init_from_json(filter_params['textural']) - - def init_params(self, im_param_scan: Dict) -> None: - """Initializes the Params class from a dictionary. - - Args: - im_param_scan(Dict): Dictionary of different processing, extraction and filtering params. - - Returns: - None. - """ - try: - # get default scan parameters from im_param_scan - self.__init_filter_params(im_param_scan['imParamFilter']) - self.__init_process_params(im_param_scan) - self.__init_extraction_params(im_param_scan) - - # compute suv map for PT scans - if self.type == 'PTscan': - _compute_suv_map = im_param_scan['imParamPET']['compute_suv_map'] - else : - _compute_suv_map = False - - if self.type == 'PTscan' and _compute_suv_map and self.format != 'nifti': - try: - from .processing.compute_suv_map import compute_suv_map - self.data.volume.array = compute_suv_map(self.data.volume.array, self.dicomH[0]) - except Exception as e : - message = f"\n ERROR COMPUTING SUV MAP - SOME FEATURES WILL BE INVALID: \n {e}" - logging.error(message) - print(message) - self.skip = True - - # initialize radiomics structure - self.radiomics.image = {} - self.radiomics.params = im_param_scan - self.params.radiomics.scale_name = '' - self.params.radiomics.ih_name = '' - self.params.radiomics.ivh_name = '' - - except Exception as e: - message = f"\n ERROR IN INITIALIZATION OF RADIOMICS FEATURE COMPUTATION\n {e}" - logging.error(message) - print(message) - self.skip = True - - def init_ntf_calculation(self, vol_obj: image_volume_obj) -> None: - """ - Initializes all the computation parameters for non-texture features as well as the results dict. - - Args: - vol_obj(image_volume_obj): Imaging volume. - - Returns: - None. - """ - try: - if sum(self.params.process.scale_non_text) == 0: # In case the user chose to not interpolate - self.params.process.scale_non_text = [ - vol_obj.spatialRef.PixelExtentInWorldX, - vol_obj.spatialRef.PixelExtentInWorldY, - vol_obj.spatialRef.PixelExtentInWorldZ] - else: - if len(self.params.process.scale_non_text) == 2: - # In case not interpolation is performed in - # the slice direction (e.g. 2D case) - self.params.process.scale_non_text = self.params.process.scale_non_text + \ - [vol_obj.spatialRef.PixelExtentInWorldZ] - - # Scale name - # Always isotropic resampling, so the first entry is ok. - self.params.radiomics.scale_name = 'scale' + (str(self.params.process.scale_non_text[0])).replace('.', 'dot') - - # IH name - if 'val' in self.params.process.ih: - ih_val_name = 'bin' + (str(self.params.process.ih['val'])).replace('.', 'dot') - else: - ih_val_name = 'binNone' - - # The minimum value defines the computation. - if self.params.process.ih['type'].find('FBS')>=0: - if type(self.params.process.user_set_min_value) is list and self.params.process.user_set_min_value: - min_val_name = '_min' + \ - ((str(self.params.process.user_set_min_value)).replace('.', 'dot')).replace('-', 'M') - else: - # Otherwise, minimum value of ROI will be used (not recommended), - # so no need to report it. - min_val_name = '' - else: - min_val_name = '' - self.params.radiomics.ih_name = self.params.radiomics.scale_name + \ - '_algo' + self.params.process.ih['type'] + \ - '_' + ih_val_name + min_val_name - - # IVH name - if self.params.process.im_range: # The im_range defines the computation. - min_val_name = ((str(self.params.process.im_range[0])).replace('.', 'dot')).replace('-', 'M') - max_val_name = ((str(self.params.process.im_range[1])).replace('.', 'dot')).replace('-', 'M') - if max_val_name == 'inf': - # In this case, the maximum value of the ROI is used, - # so no need to report it. - range_name = '_min' + min_val_name - elif min_val_name == '-inf' or min_val_name == 'inf': - # In this case, the minimum value of the ROI is used, - # so no need to report it. - range_name = '_max' + max_val_name - else: - range_name = '_min' + min_val_name + '_max' + max_val_name - else: - # min-max of ROI will be used, no need to report it. - range_name = '' - if not self.params.process.ivh: # CT case for example - ivh_algo_name = 'algoNone' - ivh_val_name = 'bin1' - else: - ivh_algo_name = 'algo' + self.params.process.ivh['type'] if 'type' in self.params.process.ivh else 'algoNone' - if 'val' in self.params.process.ivh and self.params.process.ivh['val']: - ivh_val_name = 'bin' + (str(self.params.process.ivh['val'])).replace('.', 'dot') - else: - ivh_val_name = 'binNone' - self.params.radiomics.ivh_name = self.params.radiomics.scale_name + '_' + ivh_algo_name + '_' + ivh_val_name + range_name - - # Now initialize the attribute that will hold the computation results - self.radiomics.image.update({ - 'morph_3D': {self.params.radiomics.scale_name: {}}, - 'locInt_3D': {self.params.radiomics.scale_name: {}}, - 'stats_3D': {self.params.radiomics.scale_name: {}}, - 'intHist_3D': {self.params.radiomics.ih_name: {}}, - 'intVolHist_3D': {self.params.radiomics.ivh_name: {}} - }) - - except Exception as e: - message = f"\n PROBLEM WITH PRE-PROCESSING OF FEATURES IN init_ntf_calculation(): \n {e}" - logging.error(message) - print(message) - self.radiomics.image.update( - {('scale' + (str(self.params.process.scale_non_text[0])).replace('.', 'dot')): 'ERROR_PROCESSING'}) - - def init_tf_calculation(self, algo:int, gl:int, scale:int) -> None: - """ - Initializes all the computation parameters for the texture-features as well as the results dict. - - Args: - algo(int): Discretisation algorithms index. - gl(int): gray-level index. - scale(int): scale-text index. - - Returns: - None. - """ - # check glcm merge method - glcm_merge_method = self.params.radiomics.glcm.merge_method - if glcm_merge_method: - if glcm_merge_method == 'average': - glcm_merge_method = '_avg' - elif glcm_merge_method == 'vol_merge': - glcm_merge_method = '_comb' - else: - error_msg = f"{glcm_merge_method} Method not supported in glcm computation, \ - only 'average' or 'vol_merge' are supported. \ - Radiomics will be saved without any specific merge method." - logging.warning(error_msg) - print(error_msg) - - # check glrlm merge method - glrlm_merge_method = self.params.radiomics.glrlm.merge_method - if glrlm_merge_method: - if glrlm_merge_method == 'average': - glrlm_merge_method = '_avg' - elif glrlm_merge_method == 'vol_merge': - glrlm_merge_method = '_comb' - else: - error_msg = f"{glcm_merge_method} Method not supported in glrlm computation, \ - only 'average' or 'vol_merge' are supported. \ - Radiomics will be saved without any specific merge method" - logging.warning(error_msg) - print(error_msg) - # set texture features names and updates radiomics dict - self.params.radiomics.name_text_types = [ - 'glcm_3D' + glcm_merge_method, - 'glrlm_3D' + glrlm_merge_method, - 'glszm_3D', - 'gldzm_3D', - 'ngtdm_3D', - 'ngldm_3D'] - n_text_types = len(self.params.radiomics.name_text_types) - if not ('texture' in self.radiomics.image): - self.radiomics.image.update({'texture': {}}) - for t in range(n_text_types): - self.radiomics.image['texture'].update({self.params.radiomics.name_text_types[t]: {}}) - - # scale name - # Always isotropic resampling, so the first entry is ok. - scale_name = 'scale' + (str(self.params.process.scale_text[scale][0])).replace('.', 'dot') - if hasattr(self.params.radiomics, "scale_name"): - setattr(self.params.radiomics, 'scale_name', scale_name) - else: - self.params.radiomics.scale_name = scale_name - - # Discretisation name - gray_levels_name = (str(self.params.process.gray_levels[algo][gl])).replace('.', 'dot') - - if 'FBS' in self.params.process.algo[algo]: # The minimum value defines the computation. - if type(self.params.process.user_set_min_value) is list and self.params.process.user_set_min_value: - min_val_name = '_min' + ((str(self.params.process.user_set_min_value)).replace('.', 'dot')).replace('-', 'M') - else: - # Otherwise, minimum value of ROI will be used (not recommended), - # so no need to report it. - min_val_name = '' - else: - min_val_name = '' - - if 'equal'in self.params.process.algo[algo]: - # The number of gray-levels used for equalization is currently - # hard-coded to 64 in equalization.m - discretisation_name = 'algo' + self.params.process.algo[algo] + '256_bin' + gray_levels_name + min_val_name - else: - discretisation_name = 'algo' + self.params.process.algo[algo] + '_bin' + gray_levels_name + min_val_name - - # Processing full name - processing_name = scale_name + '_' + discretisation_name - if hasattr(self.params.radiomics, "processing_name"): - setattr(self.params.radiomics, 'processing_name', processing_name) - else: - self.params.radiomics.processing_name = processing_name - - def init_from_nifti(self, nifti_image_path: Path) -> None: - """Initializes the MEDscan class using a NIfTI file. - - Args: - nifti_image_path (Path): NIfTI file path. - - Returns: - None. - - """ - self.patientID = os.path.basename(nifti_image_path).split("_")[0] - self.type = os.path.basename(nifti_image_path).split(".")[-3] - self.format = "nifti" - self.data.set_orientation(orientation="Axial") - self.data.set_patient_position(patient_position="HFS") - self.data.ROI.get_roi_from_path(roi_path=os.path.dirname(nifti_image_path), - id=Path(nifti_image_path).name.split("(")[0]) - self.data.volume.array = nib.load(nifti_image_path).get_fdata() - # RAS to LPS - self.data.volume.convert_to_LPS() - self.data.volume.scan_rot = None - - def update_radiomics( - self, int_vol_hist_features: Dict = {}, - morph_features: Dict = {}, loc_int_features: Dict = {}, - stats_features: Dict = {}, int_hist_features: Dict = {}, - glcm_features: Dict = {}, glrlm_features: Dict = {}, - glszm_features: Dict = {}, gldzm_features: Dict = {}, - ngtdm_features: Dict = {}, ngldm_features: Dict = {}) -> None: - """Updates the results attribute with the extracted features. - - Args: - int_vol_hist_features(Dict, optional): Dictionary of the intensity volume histogram features. - morph_features(Dict, optional): Dictionary of the morphological features. - loc_int_features(Dict, optional): Dictionary of the intensity local intensity features. - stats_features(Dict, optional): Dictionary of the statistical features. - int_hist_features(Dict, optional): Dictionary of the intensity histogram features. - glcm_features(Dict, optional): Dictionary of the GLCM features. - glrlm_features(Dict, optional): Dictionary of the GLRLM features. - glszm_features(Dict, optional): Dictionary of the GLSZM features. - gldzm_features(Dict, optional): Dictionary of the GLDZM features. - ngtdm_features(Dict, optional): Dictionary of the NGTDM features. - ngldm_features(Dict, optional): Dictionary of the NGLDM features. - Returns: - None. - """ - # check glcm merge method - glcm_merge_method = self.params.radiomics.glcm.merge_method - if glcm_merge_method: - if glcm_merge_method == 'average': - glcm_merge_method = '_avg' - elif glcm_merge_method == 'vol_merge': - glcm_merge_method = '_comb' - - # check glrlm merge method - glrlm_merge_method = self.params.radiomics.glrlm.merge_method - if glrlm_merge_method: - if glrlm_merge_method == 'average': - glrlm_merge_method = '_avg' - elif glrlm_merge_method == 'vol_merge': - glrlm_merge_method = '_comb' - - # Non-texture Features - if int_vol_hist_features: - self.radiomics.image['intVolHist_3D'][self.params.radiomics.ivh_name] = int_vol_hist_features - if morph_features: - self.radiomics.image['morph_3D'][self.params.radiomics.scale_name] = morph_features - if loc_int_features: - self.radiomics.image['locInt_3D'][self.params.radiomics.scale_name] = loc_int_features - if stats_features: - self.radiomics.image['stats_3D'][self.params.radiomics.scale_name] = stats_features - if int_hist_features: - self.radiomics.image['intHist_3D'][self.params.radiomics.ih_name] = int_hist_features - - # Texture Features - if glcm_features: - self.radiomics.image['texture'][ - 'glcm_3D' + glcm_merge_method][self.params.radiomics.processing_name] = glcm_features - if glrlm_features: - self.radiomics.image['texture'][ - 'glrlm_3D' + glrlm_merge_method][self.params.radiomics.processing_name] = glrlm_features - if glszm_features: - self.radiomics.image['texture']['glszm_3D'][self.params.radiomics.processing_name] = glszm_features - if gldzm_features: - self.radiomics.image['texture']['gldzm_3D'][self.params.radiomics.processing_name] = gldzm_features - if ngtdm_features: - self.radiomics.image['texture']['ngtdm_3D'][self.params.radiomics.processing_name] = ngtdm_features - if ngldm_features: - self.radiomics.image['texture']['ngldm_3D'][self.params.radiomics.processing_name] = ngldm_features - - def save_radiomics( - self, scan_file_name: List, - path_save: Path, roi_type: str, - roi_type_label: str, patient_num: int = None) -> None: - """ - Saves extracted radiomics features in a JSON file. - - Args: - scan_file_name(List): List of scan files. - path_save(Path): Saving path. - roi_type(str): Type of the ROI. - roi_type_label(str): Label of the ROI type. - patient_num(int): Index of scan. - - Returns: - None. - """ - if path_save.name != f'features({roi_type})': - if not (path_save / f'features({roi_type})').exists(): - (path_save / f'features({roi_type})').mkdir() - path_save = Path(path_save / f'features({roi_type})') - else: - path_save = Path(path_save) / f'features({roi_type})' - else: - path_save = Path(path_save) - params = {} - params['roi_type'] = roi_type_label - params['patientID'] = self.patientID - params['vox_dim'] = list([ - self.data.volume.spatialRef.PixelExtentInWorldX, - self.data.volume.spatialRef.PixelExtentInWorldY, - self.data.volume.spatialRef.PixelExtentInWorldZ - ]) - self.radiomics.update_params(params) - if type(scan_file_name) is str: - index_dot = scan_file_name.find('.') - ext = scan_file_name.find('.npy') - name_save = scan_file_name[:index_dot] + \ - '(' + roi_type_label + ')' + \ - scan_file_name[index_dot : ext] - elif patient_num is not None: - index_dot = scan_file_name[patient_num].find('.') - ext = scan_file_name[patient_num].find('.npy') - name_save = scan_file_name[patient_num][:index_dot] + \ - '(' + roi_type_label + ')' + \ - scan_file_name[patient_num][index_dot : ext] - else: - raise ValueError("`patient_num` must be specified or `scan_file_name` must be str") - - with open(path_save / f"{name_save}.json", "w") as fp: - dump(self.radiomics.to_json(), fp, indent=4, cls=NumpyEncoder) - - - class Params: - """Organizes all processing, filtering and features extraction parameters""" - - def __init__(self) -> None: - """ - Organizes all processing, filtering and features extraction - """ - self.process = self.Process() - self.filter = self.Filter() - self.radiomics = self.Radiomics() - - - class Process: - """Organizes all processing parameters.""" - def __init__(self, **kwargs) -> None: - """ - Constructor of the `Process` class. - """ - self.algo = kwargs['algo'] if 'algo' in kwargs else None - self.box_string = kwargs['box_string'] if 'box_string' in kwargs else None - self.gl_round = kwargs['gl_round'] if 'gl_round' in kwargs else None - self.gray_levels = kwargs['gray_levels'] if 'gray_levels' in kwargs else None - self.ih = kwargs['ih'] if 'ih' in kwargs else None - self.im_range = kwargs['im_range'] if 'im_range' in kwargs else None - self.im_type = kwargs['im_type'] if 'im_type' in kwargs else None - self.intensity_type = kwargs['intensity_type'] if 'intensity_type' in kwargs else None - self.ivh = kwargs['ivh'] if 'ivh' in kwargs else None - self.n_algo = kwargs['n_algo'] if 'n_algo' in kwargs else None - self.n_exp = kwargs['n_exp'] if 'n_exp' in kwargs else None - self.n_gl = kwargs['n_gl'] if 'n_gl' in kwargs else None - self.n_scale = kwargs['n_scale'] if 'n_scale' in kwargs else None - self.outliers = kwargs['outliers'] if 'outliers' in kwargs else None - self.scale_non_text = kwargs['scale_non_text'] if 'scale_non_text' in kwargs else None - self.scale_text = kwargs['scale_text'] if 'scale_text' in kwargs else None - self.roi_interp = kwargs['roi_interp'] if 'roi_interp' in kwargs else None - self.roi_pv = kwargs['roi_pv'] if 'roi_pv' in kwargs else None - self.user_set_min_value = kwargs['user_set_min_value'] if 'user_set_min_value' in kwargs else None - self.vol_interp = kwargs['vol_interp'] if 'vol_interp' in kwargs else None - - def init_from_json(self, path_to_json: Union[Path, str]) -> None: - """ - Updates class attributes from json file. - - Args: - path_to_json(Union[Path, str]): Path to the JSON file with processing parameters. - - Returns: - None. - """ - __params = load_json(Path(path_to_json)) - - self.algo = __params['algo'] if 'algo' in __params else self.algo - self.box_string = __params['box_string'] if 'box_string' in __params else self.box_string - self.gl_round = __params['gl_round'] if 'gl_round' in __params else self.gl_round - self.gray_levels = __params['gray_levels'] if 'gray_levels' in __params else self.gray_levels - self.ih = __params['ih'] if 'ih' in __params else self.ih - self.im_range = __params['im_range'] if 'im_range' in __params else self.im_range - self.im_type = __params['im_type'] if 'im_type' in __params else self.im_type - self.ivh = __params['ivh'] if 'ivh' in __params else self.ivh - self.n_algo = __params['n_algo'] if 'n_algo' in __params else self.n_algo - self.n_exp = __params['n_exp'] if 'n_exp' in __params else self.n_exp - self.n_gl = __params['n_gl'] if 'n_gl' in __params else self.n_gl - self.n_scale = __params['n_scale'] if 'n_scale' in __params else self.n_scale - self.outliers = __params['outliers'] if 'outliers' in __params else self.outliers - self.scale_non_text = __params['scale_non_text'] if 'scale_non_text' in __params else self.scale_non_text - self.scale_text = __params['scale_text'] if 'scale_text' in __params else self.scale_text - self.roi_interp = __params['roi_interp'] if 'roi_interp' in __params else self.roi_interp - self.roi_pv = __params['roi_pv'] if 'roi_pv' in __params else self.roi_pv - self.user_set_min_value = __params['user_set_min_value'] if 'user_set_min_value' in __params else self.user_set_min_value - self.vol_interp = __params['vol_interp'] if 'vol_interp' in __params else self.vol_interp - - - class Filter: - """Organizes all filtering parameters""" - def __init__(self, filter_type: str = "") -> None: - """ - Constructor of the Filter class. - - Args: - filter_type(str): Type of the filter that will be used (Must be 'mean', 'log', 'laws', - 'gabor' or 'wavelet'). - - Returns: - None. - """ - self.filter_type = filter_type - self.mean = self.Mean() - self.log = self.Log() - self.gabor = self.Gabor() - self.laws = self.Laws() - self.wavelet = self.Wavelet() - self.textural = self.Textural() - - - class Mean: - """Organizes the Mean filter parameters""" - def __init__( - self, ndims: int = 0, name_save: str = '', - padding: str = '', size: int = 0, orthogonal_rot: bool = False - ) -> None: - """ - Constructor of the Mean class. - - Args: - ndims(int): Filter dimension. - name_save(str): Specific name added to final extraction results file. - padding(str): padding mode. - size(int): Filter size. - - Returns: - None. - """ - self.name_save = name_save - self.ndims = ndims - self.orthogonal_rot = orthogonal_rot - self.padding = padding - self.size = size - - def init_from_json(self, params: Dict) -> None: - """ - Updates class attributes from json file. - - Args: - params(Dict): Dictionary of the Mean filter parameters. - - Returns: - None. - """ - self.name_save = params['name_save'] - self.ndims = params['ndims'] - self.padding = params['padding'] - self.size = params['size'] - self.orthogonal_rot = params['orthogonal_rot'] - - - class Log: - """Organizes the Log filter parameters""" - def __init__( - self, ndims: int = 0, sigma: float = 0.0, - padding: str = '', orthogonal_rot: bool = False, - name_save: str = '' - ) -> None: - """ - Constructor of the Log class. - - Args: - ndims(int): Filter dimension. - sigma(float): Float of the sigma value. - padding(str): padding mode. - orthogonal_rot(bool): If True will compute average response over orthogonal planes. - name_save(str): Specific name added to final extraction results file. - - Returns: - None. - """ - self.name_save = name_save - self.ndims = ndims - self.orthogonal_rot = orthogonal_rot - self.padding = padding - self.sigma = sigma - - def init_from_json(self, params: Dict) -> None: - """ - Updates class attributes from json file. - - Args: - params(Dict): Dictionary of the Log filter parameters. - - Returns: - None. - """ - self.name_save = params['name_save'] - self.ndims = params['ndims'] - self.orthogonal_rot = params['orthogonal_rot'] - self.padding = params['padding'] - self.sigma = params['sigma'] - - - class Gabor: - """Organizes the gabor filter parameters""" - def __init__( - self, sigma: float = 0.0, _lambda: float = 0.0, - gamma: float = 0.0, theta: str = '', rot_invariance: bool = False, - orthogonal_rot: bool= False, name_save: str = '', - padding: str = '' - ) -> None: - """ - Constructor of the Gabor class. - - Args: - sigma(float): Float of the sigma value. - _lambda(float): Float of the lambda value. - gamma(float): Float of the gamma value. - theta(str): String of the theta angle value. - rot_invariance(bool): If True the filter will be rotation invariant. - orthogonal_rot(bool): If True will compute average response over orthogonal planes. - name_save(str): Specific name added to final extraction results file. - padding(str): padding mode. - - Returns: - None. - """ - self._lambda = _lambda - self.gamma = gamma - self.name_save = name_save - self.orthogonal_rot = orthogonal_rot - self.padding = padding - self.rot_invariance = rot_invariance - self.sigma = sigma - self.theta = theta - - def init_from_json(self, params: Dict) -> None: - """ - Updates class attributes from json file. - - Args: - params(Dict): Dictionary of the gabor filter parameters. - - Returns: - None. - """ - self._lambda = params['lambda'] - self.gamma = params['gamma'] - self.name_save = params['name_save'] - self.orthogonal_rot = params['orthogonal_rot'] - self.padding = params['padding'] - self.rot_invariance = params['rot_invariance'] - self.sigma = params['sigma'] - if type(params["theta"]) is str: - if params["theta"].lower().startswith('pi/'): - self.theta = np.pi / int(params["theta"].split('/')[1]) - elif params["theta"].lower().startswith('-'): - if params["theta"].lower().startswith('-pi/'): - self.theta = -np.pi / int(params["theta"].split('/')[1]) - else: - nom, denom = params["theta"].replace('-', '').replace('Pi', '').split('/') - self.theta = -np.pi*int(nom) / int(denom) - else: - self.theta = float(params["theta"]) - - - class Laws: - """Organizes the laws filter parameters""" - def __init__( - self, config: List = [], energy_distance: int = 0, - energy_image: bool = False, rot_invariance: bool = False, - orthogonal_rot: bool = False, name_save: str = '', padding: str = '' - ) -> None: - """ - Constructor of the Laws class. - - Args: - config(List): Configuration of the Laws filter, for ex: ['E5', 'L5', 'E5']. - energy_distance(int): Chebyshev distance. - energy_image(bool): If True will compute the Laws texture energy image. - rot_invariance(bool): If True the filter will be rotation invariant. - orthogonal_rot(bool): If True will compute average response over orthogonal planes. - name_save(str): Specific name added to final extraction results file. - padding(str): padding mode. - - Returns: - None. - """ - self.config = config - self.energy_distance = energy_distance - self.energy_image = energy_image - self.name_save = name_save - self.orthogonal_rot = orthogonal_rot - self.padding = padding - self.rot_invariance = rot_invariance - - def init_from_json(self, params: Dict) -> None: - """ - Updates class attributes from json file. - - Args: - params(Dict): Dictionary of the laws filter parameters. - - Returns: - None. - """ - self.config = params['config'] - self.energy_distance = params['energy_distance'] - self.energy_image = params['energy_image'] - self.name_save = params['name_save'] - self.orthogonal_rot = params['orthogonal_rot'] - self.padding = params['padding'] - self.rot_invariance = params['rot_invariance'] - - - class Wavelet: - """Organizes the Wavelet filter parameters""" - def __init__( - self, ndims: int = 0, name_save: str = '', - basis_function: str = '', subband: str = '', level: int = 0, - rot_invariance: bool = False, padding: str = '' - ) -> None: - """ - Constructor of the Wavelet class. - - Args: - ndims(int): Dimension of the filter. - name_save(str): Specific name added to final extraction results file. - basis_function(str): Wavelet basis function. - subband(str): Wavelet subband. - level(int): Decomposition level. - rot_invariance(bool): If True the filter will be rotation invariant. - padding(str): padding mode. - - Returns: - None. - """ - self.basis_function = basis_function - self.level = level - self.ndims = ndims - self.name_save = name_save - self.padding = padding - self.rot_invariance = rot_invariance - self.subband = subband - - def init_from_json(self, params: Dict) -> None: - """ - Updates class attributes from json file. - - Args: - params(Dict): Dictionary of the wavelet filter parameters. - - Returns: - None. - """ - self.basis_function = params['basis_function'] - self.level = params['level'] - self.ndims = params['ndims'] - self.name_save = params['name_save'] - self.padding = params['padding'] - self.rot_invariance = params['rot_invariance'] - self.subband = params['subband'] - - - class Textural: - """Organizes the Textural filters parameters""" - def __init__( - self, - family: str = '', - size: int = 0, - discretization: dict = {}, - local: bool = False, - name_save: str = '' - ) -> None: - """ - Constructor of the Textural class. - - Args: - family (str, optional): The family of the textural filter. - size (int, optional): The filter size. - discretization (dict, optional): The discretization parameters. - local (bool, optional): If true, the discretization will be computed locally, else globally. - name_save (str, optional): Specific name added to final extraction results file. - - Returns: - None. - """ - self.family = family - self.size = size - self.discretization = discretization - self.local = local - self.name_save = name_save - - def init_from_json(self, params: Dict) -> None: - """ - Updates class attributes from json file. - - Args: - params(Dict): Dictionary of the wavelet filter parameters. - - Returns: - None. - """ - self.family = params['family'] - self.size = params['size'] - self.discretization = params['discretization'] - self.local = params['local'] - self.name_save = params['name_save'] - - - class Radiomics: - """Organizes the radiomics extraction parameters""" - def __init__(self, **kwargs) -> None: - """ - Constructor of the Radiomics class. - """ - self.ih_name = kwargs['ih_name'] if 'ih_name' in kwargs else None - self.ivh_name = kwargs['ivh_name'] if 'ivh_name' in kwargs else None - self.glcm = self.GLCM() - self.glrlm = self.GLRLM() - self.ngtdm = self.NGTDM() - self.name_text_types = kwargs['name_text_types'] if 'name_text_types' in kwargs else None - self.processing_name = kwargs['processing_name'] if 'processing_name' in kwargs else None - self.scale_name = kwargs['scale_name'] if 'scale_name' in kwargs else None - self.extract = kwargs['extract'] if 'extract' in kwargs else {} - - class GLCM: - """Organizes the GLCM features extraction parameters""" - def __init__( - self, - dist_correction: Union[bool, str] = False, - merge_method: str = "vol_merge" - ) -> None: - """ - Constructor of the GLCM class - - Args: - dist_correction(Union[bool, str]): norm for distance weighting, must be - "manhattan", "euclidean" or "chebyshev". If True the norm for distance weighting - is gonna be "euclidean". - merge_method(str): merging method which determines how features are - calculated. Must be "average", "slice_merge", "dir_merge" and "vol_merge". - - Returns: - None. - """ - self.dist_correction = dist_correction - self.merge_method = merge_method - - - class GLRLM: - """Organizes the GLRLM features extraction parameters""" - def __init__( - self, - dist_correction: Union[bool, str] = False, - merge_method: str = "vol_merge" - ) -> None: - """ - Constructor of the GLRLM class - - Args: - dist_correction(Union[bool, str]): If True the norm for distance weighting is gonna be "euclidean". - merge_method(str): merging method which determines how features are - calculated. Must be "average", "slice_merge", "dir_merge" and "vol_merge". - - Returns: - None. - """ - self.dist_correction = dist_correction - self.merge_method = merge_method - - - class NGTDM: - """Organizes the NGTDM features extraction parameters""" - def __init__( - self, - dist_correction: Union[bool, str] = None - ) -> None: - """ - Constructor of the NGTDM class - - Args: - dist_correction(Union[bool, str]): If True the norm for distance weighting is gonna be "euclidean". - - Returns: - None. - """ - self.dist_correction = dist_correction - - - class Radiomics: - """Organizes all the extracted features. - """ - def __init__(self, image: Dict = None, params: Dict = None) -> None: - """Constructor of the Radiomics class - Args: - image(Dict): Dict of the extracted features. - params(Dict): Dict of the parameters used in features extraction (roi type, voxels diemension...) - - Returns: - None - """ - self.image = image if image else {} - self.params = params if params else {} - - def update_params(self, params: Dict) -> None: - """Updates `params` attribute from a given Dict - Args: - params(Dict): Dict of the parameters used in features extraction (roi type, voxels diemension...) - - Returns: - None - """ - self.params['roi_type'] = params['roi_type'] - self.params['patientID'] = params['patientID'] - self.params['vox_dim'] = params['vox_dim'] - - def to_json(self) -> Dict: - """Summarizes the class attributes in a Dict - Args: - None - - Returns: - Dict: Dictionay of radiomics structure (extracted features and extraction params) - """ - radiomics = { - 'image': self.image, - 'params': self.params - } - return radiomics - - - class data: - """Organizes all imaging data (volume and ROI). - - Attributes: - volume (object): Instance of MEDscan.data.volume inner class. - ROI (object): Instance of MEDscan.data.ROI inner class. - orientation (str): Imaging data orientation (axial, sagittal or coronal). - patient_position (str): Patient position specifies the position of the - patient relative to the imaging equipment space (HFS, HFP...). - - """ - def __init__(self, orientation: str=None, patient_position: str=None) -> None: - """Constructor of the scan class - - Args: - orientation (str, optional): Imaging data orientation (axial, sagittal or coronal). - patient_position (str, optional): Patient position specifies the position of the - patient relative to the imaging equipment space (HFS, HFP...). - - Returns: - None. - """ - self.volume = self.volume() - self.volume_process = self.volume_process() - self.ROI = self.ROI() - self.orientation = orientation - self.patient_position = patient_position - - def set_patient_position(self, patient_position): - self.patient_position = patient_position - - def set_orientation(self, orientation): - self.orientation = orientation - - def set_volume(self, volume): - self.volume = volume - - def set_ROI(self, *args): - self.ROI = self.ROI(args) - - def get_roi_from_indexes(self, key: int) -> np.ndarray: - """ - Extracts ROI data using the saved indexes (Indexes of non-null values). - - Args: - key (int): Key of ROI indexes list (A volume can have multiple ROIs). - - Returns: - ndarray: n-dimensional array of ROI data. - - """ - roi_volume = np.zeros_like(self.volume.array).flatten() - roi_volume[self.ROI.get_indexes(key)] = 1 - return roi_volume.reshape(self.volume.array.shape) - - def get_indexes_by_roi_name(self, roi_name : str) -> np.ndarray: - """ - Extract ROI data using the ROI name. - - Args: - roi_name (str): String of the ROI name (A volume can have multiple ROIs). - - Returns: - ndarray: n-dimensional array of the ROI data. - - """ - roi_name_key = list(self.ROI.roi_names.values()).index(roi_name) - roi_volume = np.zeros_like(self.volume.array).flatten() - roi_volume[self.ROI.get_indexes(roi_name_key)] = 1 - return roi_volume.reshape(self.volume.array.shape) - - def display(self, _slice: int = None, roi: Union[str, int] = 0) -> None: - """Displays slices from imaging data with the ROI contour in XY-Plane. - - Args: - _slice (int, optional): Index of the slice you want to plot. - roi (Union[str, int], optional): ROI name or index. If not specified will use the first ROI. - - Returns: - None. - - """ - # extract slices containing ROI - size_m = self.volume.array.shape - i = np.arange(0, size_m[0]) - j = np.arange(0, size_m[1]) - k = np.arange(0, size_m[2]) - ind_mask = np.nonzero(self.get_roi_from_indexes(roi)) - J, I, K = np.meshgrid(i, j, k, indexing='ij') - I = I[ind_mask] - J = J[ind_mask] - K = K[ind_mask] - slices = np.unique(K) - - vol_data = self.volume.array.swapaxes(0, 1)[:, :, slices] - roi_data = self.get_roi_from_indexes(roi).swapaxes(0, 1)[:, :, slices] - - rows = int(np.round(np.sqrt(len(slices)))) - columns = int(np.ceil(len(slices) / rows)) - - plt.set_cmap(plt.gray()) - - # plot only one slice - if _slice: - fig, ax = plt.subplots(1, 1, figsize=(10, 5)) - ax.axis('off') - ax.set_title(_slice) - ax.imshow(vol_data[:, :, _slice]) - im = Image.fromarray((roi_data[:, :, _slice])) - ax.contour(im, colors='red', linewidths=0.4, alpha=0.45) - lps_ax = fig.add_subplot(1, columns, 1) - - # plot multiple slices containing an ROI. - else: - fig, axs = plt.subplots(rows, columns+1, figsize=(20, 10)) - s = 0 - for i in range(0,rows): - for j in range(0,columns): - axs[i,j].axis('off') - if s < len(slices): - axs[i,j].set_title(str(s)) - axs[i,j].imshow(vol_data[:, :, s]) - im = Image.fromarray((roi_data[:, :, s])) - axs[i,j].contour(im, colors='red', linewidths=0.4, alpha=0.45) - s += 1 - axs[i,columns].axis('off') - lps_ax = fig.add_subplot(1, columns+1, axs.shape[1]) - - fig.suptitle('XY-Plane') - fig.tight_layout() - - # add the coordinates system - lps_ax.axis([-1.5, 1.5, -1.5, 1.5]) - lps_ax.set_title("Coordinates system") - - lps_ax.quiver([-0.5], [0], [1.5], [0], scale_units='xy', angles='xy', scale=1.0, color='green') - lps_ax.quiver([-0.5], [0], [0], [-1.5], scale_units='xy', angles='xy', scale=3, color='blue') - lps_ax.quiver([-0.5], [0], [1.5], [1.5], scale_units='xy', angles='xy', scale=3, color='red') - lps_ax.text(1.0, 0, "L") - lps_ax.text(-0.3, -0.5, "P") - lps_ax.text(0.3, 0.4, "S") - - lps_ax.set_xticks([]) - lps_ax.set_yticks([]) - - plt.show() - - def display_process(self, _slice: int = None, roi: Union[str, int] = 0) -> None: - """Displays slices from imaging data with the ROI contour in XY-Plane. - - Args: - _slice (int, optional): Index of the slice you want to plot. - roi (Union[str, int], optional): ROI name or index. If not specified will use the first ROI. - - Returns: - None. - - """ - # extract slices containing ROI - size_m = self.volume_process.array.shape - i = np.arange(0, size_m[0]) - j = np.arange(0, size_m[1]) - k = np.arange(0, size_m[2]) - ind_mask = np.nonzero(self.get_roi_from_indexes(roi)) - J, I, K = np.meshgrid(j, i, k, indexing='ij') - I = I[ind_mask] - J = J[ind_mask] - K = K[ind_mask] - slices = np.unique(K) - - vol_data = self.volume_process.array.swapaxes(0, 1)[:, :, slices] - roi_data = self.get_roi_from_indexes(roi).swapaxes(0, 1)[:, :, slices] - - rows = int(np.round(np.sqrt(len(slices)))) - columns = int(np.ceil(len(slices) / rows)) - - plt.set_cmap(plt.gray()) - - # plot only one slice - if _slice: - fig, ax = plt.subplots(1, 1, figsize=(10, 5)) - ax.axis('off') - ax.set_title(_slice) - ax.imshow(vol_data[:, :, _slice]) - im = Image.fromarray((roi_data[:, :, _slice])) - ax.contour(im, colors='red', linewidths=0.4, alpha=0.45) - lps_ax = fig.add_subplot(1, columns, 1) - - # plot multiple slices containing an ROI. - else: - fig, axs = plt.subplots(rows, columns+1, figsize=(20, 10)) - s = 0 - for i in range(0,rows): - for j in range(0,columns): - axs[i,j].axis('off') - if s < len(slices): - axs[i,j].set_title(str(s)) - axs[i,j].imshow(vol_data[:, :, s]) - im = Image.fromarray((roi_data[:, :, s])) - axs[i,j].contour(im, colors='red', linewidths=0.4, alpha=0.45) - s += 1 - axs[i,columns].axis('off') - lps_ax = fig.add_subplot(1, columns+1, axs.shape[1]) - - fig.suptitle('XY-Plane') - fig.tight_layout() - - # add the coordinates system - lps_ax.axis([-1.5, 1.5, -1.5, 1.5]) - lps_ax.set_title("Coordinates system") - - lps_ax.quiver([-0.5], [0], [1.5], [0], scale_units='xy', angles='xy', scale=1.0, color='green') - lps_ax.quiver([-0.5], [0], [0], [-1.5], scale_units='xy', angles='xy', scale=3, color='blue') - lps_ax.quiver([-0.5], [0], [1.5], [1.5], scale_units='xy', angles='xy', scale=3, color='red') - lps_ax.text(1.0, 0, "L") - lps_ax.text(-0.3, -0.5, "P") - lps_ax.text(0.3, 0.4, "S") - - lps_ax.set_xticks([]) - lps_ax.set_yticks([]) - - plt.show() - - - class volume: - """Organizes all volume data and information related to imaging volume. - - Attributes: - spatialRef (imref3d): Imaging data orientation (axial, sagittal or coronal). - scan_rot (ndarray): Array of the rotation applied to the XYZ points of the ROI. - array (ndarray): n-dimensional of the imaging data. - - """ - def __init__(self, spatialRef: imref3d=None, scan_rot: str=None, array: np.ndarray=None) -> None: - """Organizes all volume data and information. - - Args: - spatialRef (imref3d, optional): Imaging data orientation (axial, sagittal or coronal). - scan_rot (ndarray, optional): Array of the rotation applied to the XYZ points of the ROI. - array (ndarray, optional): n-dimensional of the imaging data. - - """ - self.spatialRef = spatialRef - self.scan_rot = scan_rot - self.array = array - - def update_spatialRef(self, spatialRef_value): - self.spatialRef = spatialRef_value - - def update_scan_rot(self, scan_rot_value): - self.scan_rot = scan_rot_value - - def update_transScanToModel(self, transScanToModel_value): - self.transScanToModel = transScanToModel_value - - def update_array(self, array): - self.array = array - - def convert_to_LPS(self): - """Convert Imaging data to LPS (Left-Posterior-Superior) coordinates system. - . - - Returns: - None. - - """ - # flip x - self.array = np.flip(self.array, 0) - # flip y - self.array = np.flip(self.array, 1) - - def spatialRef_from_nifti(self, nifti_image_path: Union[Path, str]) -> None: - """Computes the imref3d spatialRef using a NIFTI file and - updates the `spatialRef` attribute. - - Args: - nifti_image_path (str): String of the NIFTI file path. - - Returns: - None. - - """ - # Loading the nifti file: - nifti_image_path = Path(nifti_image_path) - nifti = nib.load(nifti_image_path) - nifti_data = self.array - - # spatialRef Creation - pixelX = nifti.affine[0, 0] - pixelY = nifti.affine[1, 1] - sliceS = nifti.affine[2, 2] - min_grid = nifti.affine[:3, 3] - min_Xgrid = min_grid[0] - min_Ygrid = min_grid[1] - min_Zgrid = min_grid[2] - size_image = np.shape(nifti_data) - spatialRef = imref3d(size_image, abs(pixelX), abs(pixelY), abs(sliceS)) - spatialRef.XWorldLimits = (np.array(spatialRef.XWorldLimits) - - (spatialRef.XWorldLimits[0] - - (min_Xgrid-pixelX/2)) - ).tolist() - spatialRef.YWorldLimits = (np.array(spatialRef.YWorldLimits) - - (spatialRef.YWorldLimits[0] - - (min_Ygrid-pixelY/2)) - ).tolist() - spatialRef.ZWorldLimits = (np.array(spatialRef.ZWorldLimits) - - (spatialRef.ZWorldLimits[0] - - (min_Zgrid-sliceS/2)) - ).tolist() - - # Converting the results into lists - spatialRef.ImageSize = spatialRef.ImageSize.tolist() - spatialRef.XIntrinsicLimits = spatialRef.XIntrinsicLimits.tolist() - spatialRef.YIntrinsicLimits = spatialRef.YIntrinsicLimits.tolist() - spatialRef.ZIntrinsicLimits = spatialRef.ZIntrinsicLimits.tolist() - - # update spatialRef - self.update_spatialRef(spatialRef) - - def convert_spatialRef(self): - """converts the `spatialRef` attribute from RAS to LPS coordinates system. - . - - Args: - None. - - Returns: - None. - - """ - # swap x and y data - temp = self.spatialRef.ImageExtentInWorldX - self.spatialRef.ImageExtentInWorldX = self.spatialRef.ImageExtentInWorldY - self.spatialRef.ImageExtentInWorldY = temp - - temp = self.spatialRef.PixelExtentInWorldX - self.spatialRef.PixelExtentInWorldX = self.spatialRef.PixelExtentInWorldY - self.spatialRef.PixelExtentInWorldY = temp - - temp = self.spatialRef.XIntrinsicLimits - self.spatialRef.XIntrinsicLimits = self.spatialRef.YIntrinsicLimits - self.spatialRef.YIntrinsicLimits = temp - - temp = self.spatialRef.XWorldLimits - self.spatialRef.XWorldLimits = self.spatialRef.YWorldLimits - self.spatialRef.YWorldLimits = temp - del temp - - class volume_process: - """Organizes all volume data and information. - - Attributes: - spatialRef (imref3d): Imaging data orientation (axial, sagittal or coronal). - scan_rot (ndarray): Array of the rotation applied to the XYZ points of the ROI. - data (ndarray): n-dimensional of the imaging data. - - """ - def __init__(self, spatialRef: imref3d = None, - scan_rot: List = None, array: np.ndarray = None, - user_string: str = "") -> None: - """Organizes all volume data and information. - - Args: - spatialRef (imref3d, optional): Imaging data orientation (axial, sagittal or coronal). - scan_rot (ndarray, optional): Array of the rotation applied to the XYZ points of the ROI. - array (ndarray, optional): n-dimensional of the imaging data. - user_string(str, optional): string explaining the processed data in the class. - - Returns: - None. - - """ - self.array = array - self.scan_rot = scan_rot - self.spatialRef = spatialRef - self.user_string = user_string - - def update_processed_data(self, array: np.ndarray, user_string: str = "") -> None: - if user_string: - self.user_string = user_string - self.array = array - - def save(self, name_save: str, path_save: Union[Path, str])-> None: - """Saves the processed data locally. - - Args: - name_save(str): Saving name of the processed data. - path_save(Union[Path, str]): Path to where save the processed data. - - Returns: - None. - """ - path_save = Path(path_save) - if not name_save: - name_save = self.user_string - - if not name_save.endswith('.npy'): - name_save += '.npy' - - with open(path_save / name_save, 'wb') as f: - np.save(f, self.array) - - def load( - self, - file_name: str, - loading_path: Union[Path, str], - update: bool=True - ) -> Union[None, np.ndarray]: - """Saves the processed data locally. - - Args: - file_name(str): Name file of the processed data to load. - loading_path(Union[Path, str]): Path to the processed data to load. - update(bool, optional): If True, updates the class attrtibutes with loaded data. - - Returns: - None. - """ - loading_path = Path(loading_path) - - if not file_name.endswith('.npy'): - file_name += '.npy' - - with open(loading_path / file_name, 'rb') as f: - if update: - self.update_processed_data(np.load(f, allow_pickle=True)) - else: - return np.load(f, allow_pickle=True) - - - class ROI: - """Organizes all ROI data and information. - - Attributes: - indexes (Dict): Dict of the ROI indexes for each ROI name. - roi_names (Dict): Dict of the ROI names. - nameSet (Dict): Dict of the User-defined name for Structure Set for each ROI name. - nameSetInfo (Dict): Dict of the names of the structure sets that define the areas of - significance. Either 'StructureSetName', 'StructureSetDescription', 'SeriesDescription' - or 'SeriesInstanceUID'. - - """ - def __init__(self, indexes: Dict=None, roi_names: Dict=None) -> None: - """Constructor of the ROI class. - - Args: - indexes (Dict, optional): Dict of the ROI indexes for each ROI name. - roi_names (Dict, optional): Dict of the ROI names. - - Returns: - None. - """ - self.indexes = indexes if indexes else {} - self.roi_names = roi_names if roi_names else {} - self.nameSet = roi_names if roi_names else {} - self.nameSetInfo = roi_names if roi_names else {} - - def get_indexes(self, key): - if not self.indexes or key is None: - return {} - else: - return self.indexes[str(key)] - - def get_roi_name(self, key): - if not self.roi_names or key is None: - return {} - else: - return self.roi_names[str(key)] - - def get_name_set(self, key): - if not self.nameSet or key is None: - return {} - else: - return self.nameSet[str(key)] - - def get_name_set_info(self, key): - if not self.nameSetInfo or key is None: - return {} - else: - return self.nameSetInfo[str(key)] - - def update_indexes(self, key, indexes): - try: - self.indexes[str(key)] = indexes - except: - Warning.warn("Wrong key given in update_indexes()") - - def update_roi_name(self, key, roi_name): - try: - self.roi_names[str(key)] = roi_name - except: - Warning.warn("Wrong key given in update_roi_name()") - - def update_name_set(self, key, name_set): - try: - self.nameSet[str(key)] = name_set - except: - Warning.warn("Wrong key given in update_name_set()") - - def update_name_set_info(self, key, nameSetInfo): - try: - self.nameSetInfo[str(key)] = nameSetInfo - except: - Warning.warn("Wrong key given in update_name_set_info()") - - def convert_to_LPS(self, data: np.ndarray) -> np.ndarray: - """Converts the given volume to LPS coordinates system. For - more details please refer here : https://www.slicer.org/wiki/Coordinate_systems - Args: - data(ndarray) : Volume data in RAS to convert to to LPS - - Returns: - ndarray: n-dimensional of `data` in LPS. - """ - # flip x - data = np.flip(data, 0) - # flip y - data = np.flip(data, 1) - - return data - - def get_roi_from_path(self, roi_path: Union[Path, str], id: str): - """Extracts all ROI data from the given path for the given - patient ID and updates all class attributes with the new extracted data. - - Args: - roi_path(Union[Path, str]): Path where the ROI data is stored. - id(str): ID containing patient ID and the modality type, to identify the right file. - - Returns: - None. - """ - self.indexes = {} - self.roi_names = {} - self.nameSet = {} - self.nameSetInfo = {} - roi_index = 0 - list_of_patients = os.listdir(roi_path) - - for file in list_of_patients: - # Load the patient's ROI nifti files : - if file.startswith(id) and file.endswith('nii.gz') and 'ROI' in file.split("."): - roi = nib.load(roi_path + "/" + file) - roi_data = self.convert_to_LPS(data=roi.get_fdata()) - roi_name = file[file.find("(")+1 : file.find(")")] - name_set = file[file.find("_")+2 : file.find("(")] - self.update_indexes(key=roi_index, indexes=np.nonzero(roi_data.flatten())) - self.update_name_set(key=roi_index, name_set=name_set) - self.update_roi_name(key=roi_index, roi_name=roi_name) - roi_index += 1 diff --git a/MEDimage/__init__.py b/MEDimage/__init__.py deleted file mode 100644 index 6ed02d7..0000000 --- a/MEDimage/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import logging - -from . import utils -from . import processing -from . import biomarkers -from . import filters -from . import wrangling -from . import learning -from .MEDscan import MEDscan - - -stream_handler = logging.StreamHandler() -stream_handler.setLevel(logging.WARNING) -logging.getLogger(__name__).addHandler(stream_handler) - -__author__ = "MEDomicsLab consortium" -__version__ = "0.9.8" -__copyright__ = "Copyright (C) MEDomicsLab consortium" -__license__ = "GNU General Public License 3.0" -__maintainer__ = "MAHDI AIT LHAJ LOUTFI" -__email__ = "medomics.info@gmail.com" diff --git a/MEDimage/biomarkers/BatchExtractor.py b/MEDimage/biomarkers/BatchExtractor.py deleted file mode 100644 index f5d3c77..0000000 --- a/MEDimage/biomarkers/BatchExtractor.py +++ /dev/null @@ -1,806 +0,0 @@ -import logging -import math -import os -import pickle -import sys -from copy import deepcopy -from datetime import datetime -from itertools import product -from pathlib import Path -from time import time -from typing import Dict, List, Union - -import numpy as np -import pandas as pd -import ray -from tqdm import trange - -import MEDimage - - -class BatchExtractor(object): - """ - Organizes all the patients/scans in batches to extract all the radiomic features - """ - - def __init__( - self, - path_read: Union[str, Path], - path_csv: Union[str, Path], - path_params: Union[str, Path], - path_save: Union[str, Path], - n_batch: int = 4, - skip_existing: bool = False - ) -> None: - """ - constructor of the BatchExtractor class - """ - self._path_csv = Path(path_csv) - self._path_params = Path(path_params) - self._path_read = Path(path_read) - self._path_save = Path(path_save) - self.roi_types = [] - self.roi_type_labels = [] - self.n_bacth = n_batch - self.skip_existing = skip_existing - - def __load_and_process_params(self) -> Dict: - """Load and process the computing & batch parameters from JSON file""" - # Load json parameters - im_params = MEDimage.utils.json_utils.load_json(self._path_params) - - # Update class attributes - self.roi_types.extend(im_params['roi_types']) - self.roi_type_labels.extend(im_params['roi_type_labels']) - self.n_bacth = im_params['n_batch'] if 'n_batch' in im_params else self.n_bacth - - return im_params - - @ray.remote - def __compute_radiomics_one_patient( - self, - name_patient: str, - roi_name: str, - im_params: Dict, - roi_type: str, - roi_type_label: str, - log_file: Union[Path, str] - ) -> str: - """ - Computes all radiomics features (Texture & Non-texture) for one patient/scan - - Args: - name_patient(str): scan or patient full name. It has to respect the MEDimage naming convention: - PatientID__ImagingScanName.ImagingModality.npy - roi_name(str): name of the ROI that will be used in computation. - im_params(Dict): Dict of parameters/settings that will be used in the processing and computation. - roi_type(str): Type of ROI used in the processing and computation (for identification purposes) - roi_type_label(str): Label of the ROI used, to make it identifiable from other ROIs. - log_file(Union[Path, str]): Path to the logging file. - - Returns: - Union[Path, str]: Path to the updated logging file. - """ - # Setting up logging settings - logging.basicConfig(filename=log_file, level=logging.DEBUG, force=True) - - # Check if features are already computed for the current scan - if self.skip_existing: - modality = name_patient.split('.')[1] - name_save = name_patient.split('.')[0] + f'({roi_type_label})' + f'.{modality}.json' - if Path(self._path_save / f'features({roi_type})' / name_save).exists(): - logging.info("Skipping existing features for scan: {name_patient}") - return log_file - - # start timer - t_start = time() - - # Initialization - message = f"\n***************** COMPUTING FEATURES: {name_patient} *****************" - logging.info(message) - - # Load MEDscan instance - try: - with open(self._path_read / name_patient, 'rb') as f: medscan = pickle.load(f) - medscan = MEDimage.MEDscan(medscan) - except Exception as e: - print(f"\n ERROR LOADING PATIENT {name_patient}:\n {e}") - return None - - # Init processing & computation parameters - medscan.init_params(im_params) - logging.debug('Parameters parsed, json file is valid.') - - # Get ROI (region of interest) - logging.info("\n--> Extraction of ROI mask:") - try: - vol_obj_init, roi_obj_init = MEDimage.processing.get_roi_from_indexes( - medscan, - name_roi=roi_name, - box_string=medscan.params.process.box_string - ) - except: - # if for the current scan ROI is not found, computation is aborted. - return log_file - - start = time() - message = '--> Non-texture features pre-processing (interp + re-seg) for "Scale={}"'.\ - format(str(medscan.params.process.scale_non_text[0])) - logging.info(message) - - # Interpolation - # Intensity Mask - vol_obj = MEDimage.processing.interp_volume( - medscan=medscan, - vol_obj_s=vol_obj_init, - vox_dim=medscan.params.process.scale_non_text, - interp_met=medscan.params.process.vol_interp, - round_val=medscan.params.process.gl_round, - image_type='image', - roi_obj_s=roi_obj_init, - box_string=medscan.params.process.box_string - ) - # Morphological Mask - roi_obj_morph = MEDimage.processing.interp_volume( - medscan=medscan, - vol_obj_s=roi_obj_init, - vox_dim=medscan.params.process.scale_non_text, - interp_met=medscan.params.process.roi_interp, - round_val=medscan.params.process.roi_pv, - image_type='roi', - roi_obj_s=roi_obj_init, - box_string=medscan.params.process.box_string - ) - - # Re-segmentation - # Intensity mask range re-segmentation - roi_obj_int = deepcopy(roi_obj_morph) - roi_obj_int.data = MEDimage.processing.range_re_seg( - vol=vol_obj.data, - roi=roi_obj_int.data, - im_range=medscan.params.process.im_range - ) - # Intensity mask outlier re-segmentation - roi_obj_int.data = np.logical_and( - MEDimage.processing.outlier_re_seg( - vol=vol_obj.data, - roi=roi_obj_int.data, - outliers=medscan.params.process.outliers - ), - roi_obj_int.data - ).astype(int) - logging.info(f"{time() - start}\n") - - # Reset timer - start = time() - - # Preparation of computation : - medscan.init_ntf_calculation(vol_obj) - - # Image filtering: linear - if medscan.params.filter.filter_type: - if medscan.params.filter.filter_type.lower() == 'textural': - raise ValueError('For textural filtering, please use the BatchExtractorTexturalFilters class.') - try: - vol_obj = MEDimage.filters.apply_filter(medscan, vol_obj) - except Exception as e: - logging.error(f'PROBLEM WITH LINEAR FILTERING: {e}') - return log_file - - # ROI Extraction : - try: - vol_int_re = MEDimage.processing.roi_extract( - vol=vol_obj.data, - roi=roi_obj_int.data - ) - except Exception as e: - print(name_patient, e) - return log_file - - # check if ROI is empty - if math.isnan(np.nanmax(vol_int_re)) and math.isnan(np.nanmin(vol_int_re)): - logging.error(f'PROBLEM WITH INTENSITY MASK. ROI {roi_name} IS EMPTY.') - return log_file - - # Computation of non-texture features - logging.info("--> Computation of non-texture features:") - - # Morphological features extraction - try: - if medscan.params.radiomics.extract['Morph']: - morph = MEDimage.biomarkers.morph.extract_all( - vol=vol_obj.data, - mask_int=roi_obj_int.data, - mask_morph=roi_obj_morph.data, - res=medscan.params.process.scale_non_text, - intensity_type=medscan.params.process.intensity_type - ) - else: - morph = None - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF MORPHOLOGICAL FEATURES {e}') - morph = None - - # Local intensity features extraction - try: - if medscan.params.radiomics.extract['LocalIntensity']: - local_intensity = MEDimage.biomarkers.local_intensity.extract_all( - img_obj=vol_obj.data, - roi_obj=roi_obj_int.data, - res=medscan.params.process.scale_non_text, - intensity_type=medscan.params.process.intensity_type - ) - else: - local_intensity = None - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF LOCAL INTENSITY FEATURES {e}') - local_intensity = None - - # statistical features extraction - try: - if medscan.params.radiomics.extract['Stats']: - stats = MEDimage.biomarkers.stats.extract_all( - vol=vol_int_re, - intensity_type=medscan.params.process.intensity_type - ) - else: - stats = None - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF STATISTICAL FEATURES {e}') - stats = None - - # Intensity histogram equalization of the imaging volume - vol_quant_re, _ = MEDimage.processing.discretize( - vol_re=vol_int_re, - discr_type=medscan.params.process.ih['type'], - n_q=medscan.params.process.ih['val'], - user_set_min_val=medscan.params.process.user_set_min_value - ) - - # Intensity histogram features extraction - try: - if medscan.params.radiomics.extract['IntensityHistogram']: - int_hist = MEDimage.biomarkers.intensity_histogram.extract_all( - vol=vol_quant_re - ) - else: - int_hist = None - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF INTENSITY HISTOGRAM FEATURES {e}') - int_hist = None - - # Intensity histogram equalization of the imaging volume - if medscan.params.process.ivh and 'type' in medscan.params.process.ivh and 'val' in medscan.params.process.ivh: - if medscan.params.process.ivh['type'] and medscan.params.process.ivh['val']: - vol_quant_re, wd = MEDimage.processing.discretize( - vol_re=vol_int_re, - discr_type=medscan.params.process.ivh['type'], - n_q=medscan.params.process.ivh['val'], - user_set_min_val=medscan.params.process.user_set_min_value, - ivh=True - ) - else: - vol_quant_re = vol_int_re - wd = 1 - - # Intensity volume histogram features extraction - if medscan.params.radiomics.extract['IntensityVolumeHistogram']: - int_vol_hist = MEDimage.biomarkers.int_vol_hist.extract_all( - medscan=medscan, - vol=vol_quant_re, - vol_int_re=vol_int_re, - wd=wd - ) - else: - int_vol_hist = None - - # End of Non-Texture features extraction - logging.info(f"End of non-texture features extraction: {time() - start}\n") - - # Computation of texture features - logging.info("--> Computation of texture features:") - - # Compute radiomics features for each scale text - count = 0 - for s in range(medscan.params.process.n_scale): - start = time() - message = '--> Texture features: pre-processing (interp + ' \ - f'reSeg) for "Scale={str(medscan.params.process.scale_text[s][0])}": ' - logging.info(message) - - # Interpolation - # Intensity Mask - vol_obj = MEDimage.processing.interp_volume( - medscan=medscan, - vol_obj_s=vol_obj_init, - vox_dim=medscan.params.process.scale_text[s], - interp_met=medscan.params.process.vol_interp, - round_val=medscan.params.process.gl_round, - image_type='image', - roi_obj_s=roi_obj_init, - box_string=medscan.params.process.box_string - ) - # Morphological Mask - roi_obj_morph = MEDimage.processing.interp_volume( - medscan=medscan, - vol_obj_s=roi_obj_init, - vox_dim=medscan.params.process.scale_text[s], - interp_met=medscan.params.process.roi_interp, - round_val=medscan.params.process.roi_pv, - image_type='roi', - roi_obj_s=roi_obj_init, - box_string=medscan.params.process.box_string - ) - - # Re-segmentation - # Intensity mask range re-segmentation - roi_obj_int = deepcopy(roi_obj_morph) - roi_obj_int.data = MEDimage.processing.range_re_seg( - vol=vol_obj.data, - roi=roi_obj_int.data, - im_range=medscan.params.process.im_range - ) - # Intensity mask outlier re-segmentation - roi_obj_int.data = np.logical_and( - MEDimage.processing.outlier_re_seg( - vol=vol_obj.data, - roi=roi_obj_int.data, - outliers=medscan.params.process.outliers - ), - roi_obj_int.data - ).astype(int) - - # Image filtering: linear - if medscan.params.filter.filter_type: - if medscan.params.filter.filter_type.lower() == 'textural': - raise ValueError('For textural filtering, please use the BatchExtractorTexturalFilters class.') - try: - vol_obj = MEDimage.filters.apply_filter(medscan, vol_obj) - except Exception as e: - logging.error(f'PROBLEM WITH LINEAR FILTERING: {e}') - return log_file - - logging.info(f"{time() - start}\n") - - # Compute features for each discretisation algorithm and for each grey-level - for a, n in product(range(medscan.params.process.n_algo), range(medscan.params.process.n_gl)): - count += 1 - start = time() - message = '--> Computation of texture features in image ' \ - 'space for "Scale= {}", "Algo={}", "GL={}" ({}):'.format( - str(medscan.params.process.scale_text[s][1]), - medscan.params.process.algo[a], - str(medscan.params.process.gray_levels[a][n]), - str(count) + '/' + str(medscan.params.process.n_exp) - ) - logging.info(message) - - # Preparation of computation : - medscan.init_tf_calculation(algo=a, gl=n, scale=s) - - # ROI Extraction : - vol_int_re = MEDimage.processing.roi_extract( - vol=vol_obj.data, - roi=roi_obj_int.data) - - # Discretisation : - try: - vol_quant_re, _ = MEDimage.processing.discretize( - vol_re=vol_int_re, - discr_type=medscan.params.process.algo[a], - n_q=medscan.params.process.gray_levels[a][n], - user_set_min_val=medscan.params.process.user_set_min_value - ) - except Exception as e: - logging.error(f'PROBLEM WITH DISCRETIZATION: {e}') - vol_quant_re = None - - # GLCM features extraction - try: - if medscan.params.radiomics.extract['GLCM']: - glcm = MEDimage.biomarkers.glcm.extract_all( - vol=vol_quant_re, - dist_correction=medscan.params.radiomics.glcm.dist_correction) - else: - glcm = None - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF GLCM FEATURES {e}') - glcm = None - - # GLRLM features extraction - try: - if medscan.params.radiomics.extract['GLRLM']: - glrlm = MEDimage.biomarkers.glrlm.extract_all( - vol=vol_quant_re, - dist_correction=medscan.params.radiomics.glrlm.dist_correction) - else: - glrlm = None - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF GLRLM FEATURES {e}') - glrlm = None - - # GLSZM features extraction - try: - if medscan.params.radiomics.extract['GLSZM']: - glszm = MEDimage.biomarkers.glszm.extract_all( - vol=vol_quant_re) - else: - glszm = None - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF GLSZM FEATURES {e}') - glszm = None - - # GLDZM features extraction - try: - if medscan.params.radiomics.extract['GLDZM']: - gldzm = MEDimage.biomarkers.gldzm.extract_all( - vol_int=vol_quant_re, - mask_morph=roi_obj_morph.data) - else: - gldzm = None - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF GLDZM FEATURES {e}') - gldzm = None - - # NGTDM features extraction - try: - if medscan.params.radiomics.extract['NGTDM']: - ngtdm = MEDimage.biomarkers.ngtdm.extract_all( - vol=vol_quant_re, - dist_correction=medscan.params.radiomics.ngtdm.dist_correction) - else: - ngtdm = None - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF NGTDM FEATURES {e}') - ngtdm = None - - # NGLDM features extraction - try: - if medscan.params.radiomics.extract['NGLDM']: - ngldm = MEDimage.biomarkers.ngldm.extract_all( - vol=vol_quant_re) - else: - ngldm = None - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF NGLDM FEATURES {e}') - ngldm = None - - # Update radiomics results class - medscan.update_radiomics( - int_vol_hist_features=int_vol_hist, - morph_features=morph, - loc_int_features=local_intensity, - stats_features=stats, - int_hist_features=int_hist, - glcm_features=glcm, - glrlm_features=glrlm, - glszm_features=glszm, - gldzm_features=gldzm, - ngtdm_features=ngtdm, - ngldm_features=ngldm - ) - - # End of texture features extraction - logging.info(f"End of texture features extraction: {time() - start}\n") - - # Saving radiomics results - medscan.save_radiomics( - scan_file_name=name_patient, - path_save=self._path_save, - roi_type=roi_type, - roi_type_label=roi_type_label, - ) - - logging.info(f"TOTAL TIME:{time() - t_start} seconds\n\n") - - return log_file - - @ray.remote - def __compute_radiomics_tables( - self, - table_tags: List, - log_file: Union[str, Path], - im_params: Dict - ) -> None: - """ - Creates radiomic tables off of the saved dicts with the computed features and save it as CSV files - - Args: - table_tags(List): Lists of information about scans, roi type and imaging space (or filter space) - log_file(Union[str, Path]): Path to logging file. - im_params(Dict): Dictionary of parameters. - - Returns: - None. - """ - n_tables = len(table_tags) - - for t in range(0, n_tables): - scan = table_tags[t][0] - roi_type = table_tags[t][1] - roi_label = table_tags[t][2] - im_space = table_tags[t][3] - modality = table_tags[t][4] - - # extract parameters for the current modality - if modality == 'CTscan' and 'imParamCT' in im_params: - im_params_mod = im_params['imParamCT'] - elif modality== 'MRscan' and 'imParamMR' in im_params: - im_params_mod = im_params['imParamMR'] - elif modality == 'PTscan' and 'imParamPET' in im_params: - im_params_mod = im_params['imParamPET'] - # extract name save of the used filter - if 'filter_type' in im_params_mod: - filter_type = im_params_mod['filter_type'] - if filter_type in im_params['imParamFilter'] and 'name_save' in im_params['imParamFilter'][filter_type]: - name_save = im_params['imParamFilter'][filter_type]['name_save'] - else: - name_save= '' - else: - name_save= '' - - # set up table name - if name_save: - name_table = 'radiomics__' + scan + \ - '(' + roi_type + ')__' + name_save + '.npy' - else: - name_table = 'radiomics__' + scan + \ - '(' + roi_type + ')__' + im_space + '.npy' - - # Start timer - start = time() - logging.info("\n --> Computing radiomics table: {name_table}...") - - # Wildcard used to look only in the parent folder (save path), - # no need to recursively look into sub-folders using '**/'. - wildcard = '*_' + scan + '(' + roi_type + ')*.json' - - # Create radiomics table - radiomics_table_dict = MEDimage.utils.create_radiomics_table( - MEDimage.utils.get_file_paths(self._path_save / f'features({roi_label})', wildcard), - im_space, - log_file - ) - radiomics_table_dict['Properties']['Description'] = name_table - - # Save radiomics table - save_path = self._path_save / f'features({roi_label})' / name_table - np.save(save_path, [radiomics_table_dict]) - - # Create CSV table and Definitions - MEDimage.utils.write_radiomics_csv(save_path) - - logging.info(f"DONE\n {time() - start}\n") - - return log_file - - def __batch_all_patients(self, im_params: Dict) -> None: - """ - Create batches of scans to process and compute radiomics features for every single scan. - - Args: - im_params(Dict): Dict of the processing & computation parameters. - - Returns: - None - """ - # create a batch for each roi type - n_roi_types = len(self.roi_type_labels) - for r in range(0, n_roi_types): - roi_type = self.roi_types[r] - roi_type_label = self.roi_type_labels[r] - print(f'\n --> Computing features for the "{roi_type_label}" roi type ...', end = '') - - # READING CSV EXPERIMENT TABLE - tabel_roi = pd.read_csv(self._path_csv / ('roiNames_' + roi_type_label + '.csv')) - tabel_roi['under'] = '_' - tabel_roi['dot'] = '.' - tabel_roi['npy'] = '.npy' - name_patients = (pd.Series( - tabel_roi[['PatientID', 'under', 'under', - 'ImagingScanName', - 'dot', - 'ImagingModality', - 'npy']].fillna('').values.tolist()).str.join('')).tolist() - tabel_roi = tabel_roi.drop(columns=['under', 'under', 'dot', 'npy']) - roi_names = tabel_roi.ROIname.tolist() - - # INITIALIZATION - os.chdir(self._path_save) - name_bacth_log = 'batchLog_' + roi_type_label - p = Path.cwd().glob('*') - files = [x for x in p if x.is_dir()] - n_files = len(files) - exist_file = name_bacth_log in [x.name for x in files] - if exist_file and (n_files > 0): - for i in range(0, n_files): - if (files[i].name == name_bacth_log): - mod_timestamp = datetime.fromtimestamp( - Path(files[i]).stat().st_mtime) - date = mod_timestamp.strftime("%d-%b-%Y_%HH%MM%SS") - new_name = name_bacth_log+'_'+date - if sys.platform == 'win32': - os.system('move ' + name_bacth_log + ' ' + new_name) - else: - os.system('mv ' + name_bacth_log + ' ' + new_name) - - os.makedirs(name_bacth_log, 0o777, True) - path_batch = Path.cwd() / name_bacth_log - - # PRODUCE BATCH COMPUTATIONS - n_patients = len(name_patients) - n_batch = self.n_bacth - if n_batch is None or n_batch < 0: - n_batch = 1 - elif n_patients < n_batch: - n_batch = n_patients - - # Produce a list log_file path. - log_files = [path_batch / ('log_file_' + str(i) + '.log') for i in range(n_batch)] - - # Distribute the first tasks to all workers - ids = [self.__compute_radiomics_one_patient.remote( - self, - name_patient=name_patients[i], - roi_name=roi_names[i], - im_params=im_params, - roi_type=roi_type, - roi_type_label=roi_type_label, - log_file=log_files[i]) - for i in range(n_batch)] - - # Distribute the remaining tasks - nb_job_left = n_patients - n_batch - for _ in trange(n_patients): - ready, not_ready = ray.wait(ids, num_returns=1) - ids = not_ready - log_file = ray.get(ready)[0] - if nb_job_left > 0: - idx = n_patients - nb_job_left - ids.extend([self.__compute_radiomics_one_patient.remote( - self, - name_patients[idx], - roi_names[idx], - im_params, - roi_type, - roi_type_label, - log_file) - ]) - nb_job_left -= 1 - - print('DONE') - - def __batch_all_tables(self, im_params: Dict): - """ - Create batches of tables of the extracted features for every imaging scan type (CT, PET...). - - Args: - im_params(Dict): Dictionary of parameters. - - Returns: - None - """ - # GETTING COMBINATIONS OF scan, roi_type and imageSpaces - n_roi_types = len(self.roi_type_labels) - table_tags = [] - # Get all scan names present for the given roi_type_label - for r in range(0, n_roi_types): - label = self.roi_type_labels[r] - wildcard = '*' + label + '*.json' - file_paths = MEDimage.utils.get_file_paths(self._path_save / f'features({self.roi_types[r]})', wildcard) - n_files = len(file_paths) - scans = [0] * n_files - modalities = [0] * n_files - for f in range(0, n_files): - rad_file_name = file_paths[f].stem - scans[f] = MEDimage.utils.get_scan_name_from_rad_name(rad_file_name) - modalities[f] = rad_file_name.split('.')[1] - scans = s = (np.unique(np.array(scans))).tolist() - n_scans = len(scans) - # Get all scan names present for the given roi_type_label and scans - for s in range(0, n_scans): - scan = scans[s] - modality = modalities[s] - wildcard = '*' + scan + '(' + label + ')*.json' - file_paths = MEDimage.utils.get_file_paths(self._path_save / f'features({self.roi_types[r]})', wildcard) - n_files = len(file_paths) - - # Finding the images spaces for a test file (assuming that all - # files for a given scan and roi_type_label have the same image spaces - radiomics = MEDimage.utils.json_utils.load_json(file_paths[0]) - im_spaces = [key for key in radiomics.keys()] - im_spaces = im_spaces[:-1] - n_im_spaces = len(im_spaces) - # Constructing the table_tags variable - for i in range(0, n_im_spaces): - im_space = im_spaces[i] - table_tags = table_tags + [[scan, label, self.roi_types[r], im_space, modality]] - - # INITIALIZATION - os.chdir(self._path_save) - name_batch_log = 'batchLog_tables' - p = Path.cwd().glob('*') - files = [x for x in p if x.is_dir()] - n_files = len(files) - exist_file = name_batch_log in [x.name for x in files] - if exist_file and (n_files > 0): - for i in range(0, n_files): - if files[i].name == name_batch_log: - mod_timestamp = datetime.fromtimestamp( - Path(files[i]).stat().st_mtime) - date = mod_timestamp.strftime("%d-%b-%Y_%H:%M:%S") - new_name = name_batch_log+'_'+date - if sys.platform == 'win32': - os.system('move ' + name_batch_log + ' ' + new_name) - else: - os.system('mv ' + name_batch_log + ' ' + new_name) - - os.makedirs(name_batch_log, 0o777, True) - path_batch = Path.cwd() - - # PRODUCE BATCH COMPUTATIONS - n_tables = len(table_tags) - self.n_bacth = self.n_bacth - if self.n_bacth is None or self.n_bacth < 0: - self.n_bacth = 1 - elif n_tables < self.n_bacth: - self.n_bacth = n_tables - - # Produce a list log_file path. - log_files = [path_batch / ('log_file_' + str(i) + '.txt') for i in range(self.n_bacth)] - - # Distribute the first tasks to all workers - ids = [self.__compute_radiomics_tables.remote( - self, - [table_tags[i]], - log_files[i], - im_params) - for i in range(self.n_bacth)] - - nb_job_left = n_tables - self.n_bacth - - for _ in trange(n_tables): - ready, not_ready = ray.wait(ids, num_returns=1) - ids = not_ready - - # We verify if error has occur during the process - log_file = ray.get(ready)[0] - - # Distribute the remaining tasks - if nb_job_left > 0: - idx = n_tables - nb_job_left - ids.extend([self.__compute_radiomics_tables.remote( - self, - [table_tags[idx]], - log_file, - im_params)]) - nb_job_left -= 1 - - print('DONE') - - def compute_radiomics(self, create_tables: bool = True) -> None: - """Compute all radiomic features for all scans in the CSV file (set in initialization) and organize it - in JSON and CSV files - - Args: - create_tables(bool) : True to create CSV tables for the extracted features and not save it in JSON only. - - Returns: - None. - """ - - # Load and process computing parameters - im_params = self.__load_and_process_params() - - # Initialize ray - if ray.is_initialized(): - ray.shutdown() - - ray.init(local_mode=True, include_dashboard=True, num_cpus=self.n_bacth) - - # Batch all scans from CSV file and compute radiomics for each scan - self.__batch_all_patients(im_params) - - # Create a CSV file off of the computed features for all the scans - if create_tables: - self.__batch_all_tables(im_params) diff --git a/MEDimage/biomarkers/BatchExtractorTexturalFilters.py b/MEDimage/biomarkers/BatchExtractorTexturalFilters.py deleted file mode 100644 index 4403622..0000000 --- a/MEDimage/biomarkers/BatchExtractorTexturalFilters.py +++ /dev/null @@ -1,840 +0,0 @@ -import logging -import math -import os -import pickle -import sys -from copy import deepcopy -from datetime import datetime -from itertools import product -from pathlib import Path -from time import time -from typing import Dict, List, Union - -import numpy as np -import pandas as pd -import ray -from tqdm import trange - -import MEDimage - - -class BatchExtractorTexturalFilters(object): - """ - Organizes all the patients/scans in batches to extract all the radiomic features - """ - - def __init__( - self, - path_read: Union[str, Path], - path_csv: Union[str, Path], - path_params: Union[str, Path], - path_save: Union[str, Path], - n_batch: int = 4 - ) -> None: - """ - constructor of the BatchExtractor class - """ - self._path_csv = Path(path_csv) - self._path_params = Path(path_params) - self._path_read = Path(path_read) - self._path_save = Path(path_save) - self.roi_types = [] - self.roi_type_labels = [] - self.n_bacth = n_batch - self.glcm_features = [ - "Fcm_joint_max", - "Fcm_joint_avg", - "Fcm_joint_var", - "Fcm_joint_entr", - "Fcm_diff_avg", - "Fcm_diff_var", - "Fcm_diff_entr", - "Fcm_sum_avg", - "Fcm_sum_var", - "Fcm_sum_entr", - "Fcm_energy", - "Fcm_contrast", - "Fcm_dissimilarity", - "Fcm_inv_diff", - "Fcm_inv_diff_norm", - "Fcm_inv_diff_mom", - "Fcm_inv_diff_mom_norm", - "Fcm_inv_var", - "Fcm_corr", - "Fcm_auto_corr", - "Fcm_clust_tend", - "Fcm_clust_shade", - "Fcm_clust_prom", - "Fcm_info_corr1", - "Fcm_info_corr2" - ] - - def __load_and_process_params(self) -> Dict: - """Load and process the computing & batch parameters from JSON file""" - # Load json parameters - im_params = MEDimage.utils.json_utils.load_json(self._path_params) - - # Update class attributes - self.roi_types.extend(im_params['roi_types']) - self.roi_type_labels.extend(im_params['roi_type_labels']) - self.n_bacth = im_params['n_batch'] if 'n_batch' in im_params else self.n_bacth - - return im_params - - def __compute_radiomics_one_patient( - self, - name_patient: str, - roi_name: str, - im_params: Dict, - roi_type: str, - roi_type_label: str, - log_file: Union[Path, str], - skip_existing: bool - ) -> str: - """ - Computes all radiomics features (Texture & Non-texture) for one patient/scan - - Args: - name_patient(str): scan or patient full name. It has to respect the MEDimage naming convention: - PatientID__ImagingScanName.ImagingModality.npy - roi_name(str): name of the ROI that will be used in computation. - im_params(Dict): Dict of parameters/settings that will be used in the processing and computation. - roi_type(str): Type of ROI used in the processing and computation (for identification purposes) - roi_type_label(str): Label of the ROI used, to make it identifiable from other ROIs. - log_file(Union[Path, str]): Path to the logging file. - skip_existing(bool): True to skip the computation of the features for the scans that already have been computed. - - Returns: - Union[Path, str]: Path to the updated logging file. - """ - # Check if the features for the current filter have already been computed - if skip_existing: - list_feature = [] - # Find the glcm filters that have not been computed yet - for i in range(len(self.glcm_features)): - index_dot = name_patient.find('.') - ext = name_patient.find('.npy') - name_save = name_patient[:index_dot] + '(' + roi_type_label + ')' + name_patient[index_dot : ext] + ".json" - name_roi_type = roi_type + '_' + self.glcm_features[i] - path_to_check = Path(self._path_save / f'features({name_roi_type})') - if not (path_to_check / name_save).exists(): - list_feature.append(i) - # If all the features have already been computed, skip the computation - if len(list_feature) == 0: - return log_file - - # Setting up logging settings - logging.basicConfig(filename=log_file, level=logging.DEBUG, force=True) - - # start timer - t_start = time() - - # Initialization - message = f"\n***************** COMPUTING FEATURES: {name_patient} *****************" - logging.info(message) - - # Load MEDscan instance - try: - with open(self._path_read / name_patient, 'rb') as f: medscan = pickle.load(f) - medscan = MEDimage.MEDscan(medscan) - except Exception as e: - logging.error(f"\n ERROR LOADING PATIENT {name_patient}:\n {e}") - return None - - # Init processing & computation parameters - medscan.init_params(im_params) - logging.debug('Parameters parsed, json file is valid.') - - # Get ROI (region of interest) - logging.info("\n--> Extraction of ROI mask:") - try: - vol_obj_init, roi_obj_init = MEDimage.processing.get_roi_from_indexes( - medscan, - name_roi=roi_name, - box_string=medscan.params.process.box_string - ) - except: - # if for the current scan ROI is not found, computation is aborted. - return log_file - - start = time() - message = '--> Non-texture features pre-processing (interp + re-seg) for "Scale={}"'.\ - format(str(medscan.params.process.scale_non_text[0])) - logging.info(message) - - # Interpolation - # Intensity Mask - vol_obj = MEDimage.processing.interp_volume( - medscan=medscan, - vol_obj_s=vol_obj_init, - vox_dim=medscan.params.process.scale_non_text, - interp_met=medscan.params.process.vol_interp, - round_val=medscan.params.process.gl_round, - image_type='image', - roi_obj_s=roi_obj_init, - box_string=medscan.params.process.box_string - ) - # Morphological Mask - roi_obj_morph = MEDimage.processing.interp_volume( - medscan=medscan, - vol_obj_s=roi_obj_init, - vox_dim=medscan.params.process.scale_non_text, - interp_met=medscan.params.process.roi_interp, - round_val=medscan.params.process.roi_pv, - image_type='roi', - roi_obj_s=roi_obj_init, - box_string=medscan.params.process.box_string - ) - - # Re-segmentation - # Intensity mask range re-segmentation - roi_obj_int = deepcopy(roi_obj_morph) - roi_obj_int.data = MEDimage.processing.range_re_seg( - vol=vol_obj.data, - roi=roi_obj_int.data, - im_range=medscan.params.process.im_range - ) - # Intensity mask outlier re-segmentation - roi_obj_int.data = np.logical_and( - MEDimage.processing.outlier_re_seg( - vol=vol_obj.data, - roi=roi_obj_int.data, - outliers=medscan.params.process.outliers - ), - roi_obj_int.data - ).astype(int) - logging.info(f"{time() - start}\n") - - # Reset timer - start = time() - - # Image textural filtering - logging.info("--> Image textural filtering:") - - # Preparation of computation : - medscan.init_ntf_calculation(vol_obj) - - # ROI Extraction : - try: - vol_int_re = MEDimage.processing.roi_extract( - vol=vol_obj.data, - roi=roi_obj_int.data - ) - except Exception as e: - print(name_patient, e) - return log_file - - # Apply textural filter - try: - if medscan.params.process.user_set_min_value is None: - medscan.params.process.user_set_min_value = np.nanmin(vol_int_re) - vol_obj_all_features = MEDimage.filters.apply_filter( - medscan, - vol_int_re, - user_set_min_val=medscan.params.process.user_set_min_value - ) - except Exception as e: - print(e) - logging.error(f'PROBLEM WITH TEXTURAL FILTERING: {e}') - return log_file - - # Initialize ray - if ray.is_initialized(): - ray.shutdown() - - ray.init(local_mode=True, include_dashboard=True, num_cpus=self.n_bacth) - - # Loop through all the filters and extract the features for each filter - ids = [] - nb_filters = len(list_feature) - if nb_filters < self.n_bacth: - self.n_bacth = nb_filters - for i in range(self.n_bacth): - # Extract the filtered volume - filter_idx = list_feature[i] - vol_obj.data = deepcopy(vol_obj_all_features[...,filter_idx]) - - # Compute radiomics features - logging.info(f"--> Computation of radiomics features for filter {filter_idx}:") - - ids.append( - self.__compute_radiomics_filtered_volume.remote( - self, - medscan=medscan, - vol_obj=vol_obj, - roi_obj_int=roi_obj_int, - roi_obj_morph=roi_obj_morph, - name_patient=name_patient, - roi_name=roi_name, - roi_type=roi_type + '_' + self.glcm_features[filter_idx], - roi_type_label=roi_type_label, - log_file=log_file - ) - ) - # Distribute the remaining tasks - nb_job_left = nb_filters - self.n_bacth - if nb_job_left > 0: - for i in range(nb_filters - nb_job_left, nb_filters): - ready, not_ready = ray.wait(ids, num_returns=1) - ids = not_ready - try: - log_file = ray.get(ready)[0] - except: - pass - # Extract the filtered volume - filter_idx = list_feature[i] - vol_obj.data = deepcopy(vol_obj_all_features[...,filter_idx]) - - # Compute radiomics features - logging.info(f"--> Computation of radiomics features for filter {filter_idx}:") - - ids.append( - self.__compute_radiomics_filtered_volume.remote( - self, - medscan=medscan, - vol_obj=vol_obj, - roi_obj_int=roi_obj_int, - roi_obj_morph=roi_obj_morph, - name_patient=name_patient, - roi_name=roi_name, - roi_type=roi_type + '_' + self.glcm_features[filter_idx], - roi_type_label=roi_type_label, - log_file=log_file - ) - ) - - logging.info(f"TOTAL TIME:{time() - t_start} seconds\n\n") - - # Empty memory - del medscan - - @ray.remote - def __compute_radiomics_filtered_volume( - self, - medscan: MEDimage.MEDscan, - vol_obj, - roi_obj_int, - roi_obj_morph, - name_patient, - roi_name, - roi_type, - roi_type_label, - log_file - ) -> Union[Path, str]: - - # time - t_start = time() - - # ROI Extraction : - vol_int_re = deepcopy(vol_obj.data) - - # check if ROI is empty - if math.isnan(np.nanmax(vol_int_re)) and math.isnan(np.nanmin(vol_int_re)): - logging.error(f'PROBLEM WITH INTENSITY MASK. ROI {roi_name} IS EMPTY.') - return log_file - - # Computation of non-texture features - logging.info("--> Computation of non-texture features:") - - # Morphological features extraction - try: - morph = MEDimage.biomarkers.morph.extract_all( - vol=vol_obj.data, - mask_int=roi_obj_int.data, - mask_morph=roi_obj_morph.data, - res=medscan.params.process.scale_non_text, - intensity_type=medscan.params.process.intensity_type - ) - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF MORPHOLOGICAL FEATURES {e}') - morph = None - - # Local intensity features extraction - try: - local_intensity = MEDimage.biomarkers.local_intensity.extract_all( - img_obj=vol_obj.data, - roi_obj=roi_obj_int.data, - res=medscan.params.process.scale_non_text, - intensity_type=medscan.params.process.intensity_type - ) - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF LOCAL INTENSITY FEATURES {e}') - local_intensity = None - - # statistical features extraction - try: - stats = MEDimage.biomarkers.stats.extract_all( - vol=vol_int_re, - intensity_type=medscan.params.process.intensity_type - ) - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF STATISTICAL FEATURES {e}') - stats = None - - # Intensity histogram equalization of the imaging volume - vol_quant_re, _ = MEDimage.processing.discretize( - vol_re=vol_int_re, - discr_type=medscan.params.process.ih['type'], - n_q=medscan.params.process.ih['val'], - user_set_min_val=medscan.params.process.user_set_min_value - ) - - # Intensity histogram features extraction - try: - int_hist = MEDimage.biomarkers.intensity_histogram.extract_all( - vol=vol_quant_re - ) - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF INTENSITY HISTOGRAM FEATURES {e}') - int_hist = None - - # Intensity histogram equalization of the imaging volume - if medscan.params.process.ivh and 'type' in medscan.params.process.ivh and 'val' in medscan.params.process.ivh: - if medscan.params.process.ivh['type'] and medscan.params.process.ivh['val']: - vol_quant_re, wd = MEDimage.processing.discretize( - vol_re=vol_int_re, - discr_type=medscan.params.process.ivh['type'], - n_q=medscan.params.process.ivh['val'], - user_set_min_val=medscan.params.process.user_set_min_value, - ivh=True - ) - else: - vol_quant_re = vol_int_re - wd = 1 - - # Intensity volume histogram features extraction - try: - int_vol_hist = MEDimage.biomarkers.int_vol_hist.extract_all( - medscan=medscan, - vol=vol_quant_re, - vol_int_re=vol_int_re, - wd=wd - ) - except: - print("Error ivh:",name_patient) - int_vol_hist = {'Fivh_V10': [], - 'Fivh_V90': [], - 'Fivh_I10': [], - 'Fivh_I90': [], - 'Fivh_V10minusV90': [], - 'Fivh_I10minusI90': [], - 'Fivh_auc': [] - } - - # End of Non-Texture features extraction - logging.info(f"End of non-texture features extraction: {time() - t_start}\n") - - # Computation of texture features - logging.info("--> Computation of texture features:") - - # Compute radiomics features for each scale text - count = 0 - logging.info(f"{time() - t_start}\n") - - # Compute features for each discretisation algorithm and for each grey-level - for a, n in product(range(medscan.params.process.n_algo), range(medscan.params.process.n_gl)): - count += 1 - start = time() - message = '--> Computation of texture features in image ' \ - 'space for "Scale= {}", "Algo={}", "GL={}" ({}):'.format( - str(medscan.params.process.scale_text[0][1]), - medscan.params.process.algo[a], - str(medscan.params.process.gray_levels[a][n]), - str(count) + '/' + str(medscan.params.process.n_exp) - ) - logging.info(message) - - # Preparation of computation : - medscan.init_tf_calculation(algo=a, gl=n, scale=0) - - # Discretisation : - try: - vol_quant_re, _ = MEDimage.processing.discretize( - vol_re=vol_int_re, - discr_type=medscan.params.process.algo[a], - n_q=medscan.params.process.gray_levels[a][n], - user_set_min_val=medscan.params.process.user_set_min_value - ) - except Exception as e: - logging.error(f'PROBLEM WITH DISCRETIZATION: {e}') - vol_quant_re = None - - # GLCM features extraction - try: - glcm = MEDimage.biomarkers.glcm.extract_all( - vol=vol_quant_re, - dist_correction=medscan.params.radiomics.glcm.dist_correction - ) - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF GLCM FEATURES {e}') - glcm = None - - # GLRLM features extraction - try: - glrlm = MEDimage.biomarkers.glrlm.extract_all( - vol=vol_quant_re, - dist_correction=medscan.params.radiomics.glrlm.dist_correction - ) - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF GLRLM FEATURES {e}') - glrlm = None - - # GLSZM features extraction - try: - glszm = MEDimage.biomarkers.glszm.extract_all(vol=vol_quant_re) - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF GLSZM FEATURES {e}') - glszm = None - - # GLDZM features extraction - try: - gldzm = MEDimage.biomarkers.gldzm.extract_all( - vol_int=vol_quant_re, - mask_morph=roi_obj_morph.data - ) - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF GLDZM FEATURES {e}') - gldzm = None - - # NGTDM features extraction - try: - ngtdm = MEDimage.biomarkers.ngtdm.extract_all( - vol=vol_quant_re, - dist_correction=medscan.params.radiomics.ngtdm.dist_correction - ) - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF NGTDM FEATURES {e}') - ngtdm = None - - # NGLDM features extraction - try: - ngldm = MEDimage.biomarkers.ngldm.extract_all(vol=vol_quant_re) - except Exception as e: - logging.error(f'PROBLEM WITH COMPUTATION OF NGLDM FEATURES {e}') - ngldm = None - - # Update radiomics results class - medscan.update_radiomics( - int_vol_hist_features=int_vol_hist, - morph_features=morph, - loc_int_features=local_intensity, - stats_features=stats, - int_hist_features=int_hist, - glcm_features=glcm, - glrlm_features=glrlm, - glszm_features=glszm, - gldzm_features=gldzm, - ngtdm_features=ngtdm, - ngldm_features=ngldm - ) - - # End of texture features extraction - logging.info(f"End of texture features extraction: {time() - start}\n") - - # Saving radiomics results - medscan.save_radiomics( - scan_file_name=name_patient, - path_save=self._path_save, - roi_type=roi_type, - roi_type_label=roi_type_label, - ) - - logging.info(f"TOTAL TIME 1 FILTER:{time() - t_start} seconds\n\n") - - return log_file - - @ray.remote - def __compute_radiomics_tables( - self, - table_tags: List, - log_file: Union[str, Path], - im_params: Dict, - feature_name: str - ) -> None: - """ - Creates radiomic tables off of the saved dicts with the computed features and save it as CSV files - - Args: - table_tags(List): Lists of information about scans, roi type and imaging space (or filter space) - log_file(Union[str, Path]): Path to logging file. - im_params(Dict): Dictionary of parameters. - - Returns: - None. - """ - n_tables = len(table_tags) - - for t in range(0, n_tables): - scan = table_tags[t][0] - roi_type = table_tags[t][1] - roi_label = table_tags[t][2] - im_space = table_tags[t][3] - modality = table_tags[t][4] - - # extract parameters for the current modality - if modality == 'CTscan' and 'imParamCT' in im_params: - im_params_mod = im_params['imParamCT'] - elif modality== 'MRscan' and 'imParamMR' in im_params: - im_params_mod = im_params['imParamMR'] - elif modality == 'PTscan' and 'imParamPET' in im_params: - im_params_mod = im_params['imParamPET'] - # extract name save of the used filter - if 'filter_type' in im_params_mod: - filter_type = im_params_mod['filter_type'] - if filter_type in im_params['imParamFilter'] and 'name_save' in im_params['imParamFilter'][filter_type]: - name_save = im_params['imParamFilter'][filter_type]['name_save'] + '_' + feature_name - else: - name_save= feature_name - else: - name_save= feature_name - - # set up table name - if name_save: - name_table = 'radiomics__' + scan + \ - '(' + roi_type + ')__' + name_save + '.npy' - else: - name_table = 'radiomics__' + scan + \ - '(' + roi_type + ')__' + im_space + '.npy' - - # Start timer - start = time() - logging.info("\n --> Computing radiomics table: {name_table}...") - - # Wildcard used to look only in the parent folder (save path), - # no need to recursively look into sub-folders using '**/'. - wildcard = '*_' + scan + '(' + roi_type + ')*.json' - - # Create radiomics table - radiomics_table_dict = MEDimage.utils.create_radiomics_table( - MEDimage.utils.get_file_paths(self._path_save / f'features({roi_label})', wildcard), - im_space, - log_file - ) - radiomics_table_dict['Properties']['Description'] = name_table - - # Save radiomics table - save_path = self._path_save / f'features({roi_label})' / name_table - np.save(save_path, [radiomics_table_dict]) - - # Create CSV table and Definitions - MEDimage.utils.write_radiomics_csv(save_path) - - logging.info(f"DONE\n {time() - start}\n") - - return log_file - - def __batch_all_patients(self, im_params: Dict, skip_existing) -> None: - """ - Create batches of scans to process and compute radiomics features for every single scan. - - Args: - im_params(Dict): Dict of the processing & computation parameters. - skip_existing(bool) : True to skip the computation of the features for the scans that already have been computed. - - Returns: - None - """ - # create a batch for each roi type - n_roi_types = len(self.roi_type_labels) - for r in range(0, n_roi_types): - roi_type = self.roi_types[r] - roi_type_label = self.roi_type_labels[r] - print(f'\n --> Computing features for the "{roi_type_label}" roi type ...', end = '') - - # READING CSV EXPERIMENT TABLE - tabel_roi = pd.read_csv(self._path_csv / ('roiNames_' + roi_type_label + '.csv')) - tabel_roi['under'] = '_' - tabel_roi['dot'] = '.' - tabel_roi['npy'] = '.npy' - name_patients = (pd.Series( - tabel_roi[['PatientID', 'under', 'under', - 'ImagingScanName', - 'dot', - 'ImagingModality', - 'npy']].fillna('').values.tolist()).str.join('')).tolist() - tabel_roi = tabel_roi.drop(columns=['under', 'under', 'dot', 'npy']) - roi_names = tabel_roi.ROIname.tolist() - - # INITIALIZATION - os.chdir(self._path_save) - name_bacth_log = 'batchLog_' + roi_type_label - p = Path.cwd().glob('*') - files = [x for x in p if x.is_dir()] - n_files = len(files) - exist_file = name_bacth_log in [x.name for x in files] - if exist_file and (n_files > 0): - for i in range(0, n_files): - if (files[i].name == name_bacth_log): - mod_timestamp = datetime.fromtimestamp( - Path(files[i]).stat().st_mtime) - date = mod_timestamp.strftime("%d-%b-%Y_%HH%MM%SS") - new_name = name_bacth_log+'_'+date - if sys.platform == 'win32': - os.system('move ' + name_bacth_log + ' ' + new_name) - else: - os.system('mv ' + name_bacth_log + ' ' + new_name) - - os.makedirs(name_bacth_log, 0o777, True) - path_batch = Path.cwd() / name_bacth_log - - # PRODUCE BATCH COMPUTATIONS - n_patients = len(name_patients) - - # Produce a list log_file path. - log_files = [path_batch / ('log_file_' + str(i) + '.log') for i in range(n_patients)] - - # Features computation for each patient (patients loop) - for i in trange(n_patients): - self.__compute_radiomics_one_patient( - name_patients[i], - roi_names[i], - im_params, - roi_type, - roi_type_label, - log_files[i], - skip_existing - ) - - print('DONE') - - def __batch_all_tables(self, im_params: Dict): - """ - Create batches of tables of the extracted features for every imaging scan type (CT, PET...). - - Args: - im_params(Dict): Dictionary of parameters. - - Returns: - None - """ - # INITIALIZATION - os.chdir(self._path_save) - name_batch_log = 'batchLog_tables' - p = Path.cwd().glob('*') - files = [x for x in p if x.is_dir()] - n_files = len(files) - exist_file = name_batch_log in [x.name for x in files] - if exist_file and (n_files > 0): - for i in range(0, n_files): - if files[i].name == name_batch_log: - mod_timestamp = datetime.fromtimestamp( - Path(files[i]).stat().st_mtime) - date = mod_timestamp.strftime("%d-%b-%Y_%H:%M:%S") - new_name = name_batch_log+'_'+date - if sys.platform == 'win32': - os.system('move ' + name_batch_log + ' ' + new_name) - else: - os.system('mv ' + name_batch_log + ' ' + new_name) - - os.makedirs(name_batch_log, 0o777, True) - path_batch = Path.cwd() - - # GETTING COMBINATIONS OF scan, roi_type and imageSpaces - n_roi_types = len(self.roi_type_labels) - - # Get all scan names present for the given roi_type_label - for f_idx in range(0, len(self.glcm_features)): - # RE-INITIALIZATION - table_tags = [] - for r in range(0, n_roi_types): - label = self.roi_type_labels[r] - wildcard = '*' + label + '*.json' - roi_type = self.roi_types[r] + '_' + self.glcm_features[f_idx] - file_paths = MEDimage.utils.get_file_paths(self._path_save / f'features({roi_type})', wildcard) - n_files = len(file_paths) - scans = [0] * n_files - modalities = [0] * n_files - for f in range(0, n_files): - rad_file_name = file_paths[f].stem - scans[f] = MEDimage.utils.get_scan_name_from_rad_name(rad_file_name) - modalities[f] = rad_file_name.split('.')[1] - scans = s = (np.unique(np.array(scans))).tolist() - n_scans = len(scans) - # Get all scan names present for the given roi_type_label and scans - for s in range(0, n_scans): - scan = scans[s] - modality = modalities[s] - wildcard = '*' + scan + '(' + label + ')*.json' - file_paths = MEDimage.utils.get_file_paths(self._path_save / f'features({roi_type})', wildcard) - n_files = len(file_paths) - - # Finding the images spaces for a test file (assuming that all - # files for a given scan and roi_type_label have the same image spaces - radiomics = MEDimage.utils.json_utils.load_json(file_paths[0]) - im_spaces = [key for key in radiomics.keys()] - im_spaces = im_spaces[:-1] - n_im_spaces = len(im_spaces) - # Constructing the table_tags variable - for i in range(0, n_im_spaces): - im_space = im_spaces[i] - table_tags = table_tags + [[scan, label, roi_type, im_space, modality]] - - # PRODUCE BATCH COMPUTATIONS - n_tables = len(table_tags) - self.n_bacth = self.n_bacth - if self.n_bacth is None or self.n_bacth < 0: - self.n_bacth = 1 - elif n_tables < self.n_bacth: - self.n_bacth = n_tables - - # Produce a list log_file path. - log_files = [path_batch / ('log_file_' + str(i) + '.txt') for i in range(self.n_bacth)] - - # Initialize ray - if ray.is_initialized(): - ray.shutdown() - - ray.init(local_mode=True, include_dashboard=True, num_cpus=self.n_bacth) - - # Distribute the first tasks to all workers - ids = [self.__compute_radiomics_tables.remote( - self, - [table_tags[i]], - log_files[i], - im_params, - self.glcm_features[f_idx]) - for i in range(self.n_bacth)] - - nb_job_left = n_tables - self.n_bacth - - for _ in trange(n_tables): - ready, not_ready = ray.wait(ids, num_returns=1) - ids = not_ready - - # We verify if error has occur during the process - log_file = ray.get(ready)[0] - - # Distribute the remaining tasks - if nb_job_left > 0: - idx = n_tables - nb_job_left - ids.extend([self.__compute_radiomics_tables.remote( - self, - [table_tags[idx]], - log_file, - im_params, - self.glcm_features[f_idx])]) - nb_job_left -= 1 - - print('DONE') - - def compute_radiomics(self, create_tables: bool = True, skip_existing: bool = False) -> None: - """Compute all radiomic features for all scans in the CSV file (set in initialization) and organize it - in JSON and CSV files - - Args: - create_tables(bool) : True to create CSV tables for the extracted features and not save it in JSON only. - skip_existing(bool) : True to skip the computation of the features for the scans that already have been computed. - - Returns: - None. - """ - - # Load and process computing parameters - im_params = self.__load_and_process_params() - - # Batch all scans from CSV file and compute radiomics for each scan - self.__batch_all_patients(im_params, skip_existing) - - # Create a CSV file off of the computed features for all the scans - if create_tables: - self.__batch_all_tables(im_params) diff --git a/MEDimage/biomarkers/__init__.py b/MEDimage/biomarkers/__init__.py deleted file mode 100644 index 439f9c0..0000000 --- a/MEDimage/biomarkers/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -from . import * -from .BatchExtractor import * -from .BatchExtractorTexturalFilters import * -from .diagnostics import * -from .get_oriented_bound_box import * -from .glcm import * -from .gldzm import * -from .glrlm import * -from .glszm import * -from .int_vol_hist import * -from .intensity_histogram import * -from .local_intensity import * -from .morph import * -from .ngldm import * -from .ngtdm import * -from .stats import * diff --git a/MEDimage/biomarkers/diagnostics.py b/MEDimage/biomarkers/diagnostics.py deleted file mode 100644 index 492ef54..0000000 --- a/MEDimage/biomarkers/diagnostics.py +++ /dev/null @@ -1,125 +0,0 @@ -from typing import Dict - -import numpy as np - -from ..processing.segmentation import compute_bounding_box - - -def extract_all(vol_obj: np.ndarray, - roi_obj_int: np.ndarray, - roi_obj_morph: np.ndarray, - im_type: str) -> Dict: - """Computes diagnostic features - - The diagnostic features help identify issues with - the implementation of the image processing sequence. - - Args: - vol_obj (ndarray): Imaging data. - roi_obj_int (ndarray): Intensity mask data. - roi_obj_morph (ndarray): Morphological mask data. - im_type (str): Image processing step. - - - 'reSeg': Computes Diagnostic features right after the re-segmentaion step. - - 'interp' or any other arg: Computes Diagnostic features for any processing step other than re-segmentation. - - Returns: - Dict: Dictionnary containing the computed features. - """ - diag = {} - - # FOR THE IMAGE - - if im_type != 'reSeg': - # Image dimension x - diag.update({'image_' + im_type + '_dimX': - vol_obj.spatialRef.ImageSize[0]}) - - # Image dimension y - diag.update({'image_' + im_type + '_dimY': - vol_obj.spatialRef.ImageSize[1]}) - - # Image dimension z - diag.update({'image_' + im_type + '_dimz': - vol_obj.spatialRef.ImageSize[2]}) - - # Voxel dimension x - diag.update({'image_' + im_type + '_voxDimX': - vol_obj.spatialRef.PixelExtentInWorldX}) - - # Voxel dimension y - diag.update({'image_' + im_type + '_voxDimY': - vol_obj.spatialRef.PixelExtentInWorldY}) - - # Voxel dimension z - diag.update({'image_' + im_type + '_voxDimZ': - vol_obj.spatialRef.PixelExtentInWorldZ}) - - # Mean intensity - diag.update({'image_' + im_type + '_meanInt': np.mean(vol_obj.data)}) - - # Minimum intensity - diag.update({'image_' + im_type + '_minInt': np.min(vol_obj.data)}) - - # Maximum intensity - diag.update({'image_' + im_type + '_maxInt': np.max(vol_obj.data)}) - - # FOR THE ROI - box_bound_int = compute_bounding_box(roi_obj_int.data) - box_bound_morph = compute_bounding_box(roi_obj_morph.data) - - x_gl_int = vol_obj.data[roi_obj_int.data == 1] - x_gl_morph = vol_obj.data[roi_obj_morph.data == 1] - - # Map dimension x - diag.update({'roi_' + im_type + '_Int_dimX': - roi_obj_int.spatialRef.ImageSize[0]}) - - # Map dimension y - diag.update({'roi_' + im_type + '_Int_dimY': - roi_obj_int.spatialRef.ImageSize[1]}) - - # Map dimension z - diag.update({'roi_' + im_type + '_Int_dimZ': - roi_obj_int.spatialRef.ImageSize[2]}) - - # Bounding box dimension x - diag.update({'roi_' + im_type + '_Int_boxBoundDimX': - box_bound_int[0, 1] - box_bound_int[0, 0] + 1}) - - # Bounding box dimension y - diag.update({'roi_' + im_type + '_Int_boxBoundDimY': - box_bound_int[1, 1] - box_bound_int[1, 0] + 1}) - - # Bounding box dimension z - diag.update({'roi_' + im_type + '_Int_boxBoundDimZ': - box_bound_int[2, 1] - box_bound_int[2, 0] + 1}) - - # Bounding box dimension x - diag.update({'roi_' + im_type + '_Morph_boxBoundDimX': - box_bound_morph[0, 1] - box_bound_morph[0, 0] + 1}) - - # Bounding box dimension y - diag.update({'roi_' + im_type + '_Morph_boxBoundDimY': - box_bound_morph[1, 1] - box_bound_morph[1, 0] + 1}) - - # Bounding box dimension z - diag.update({'roi_' + im_type + '_Morph_boxBoundDimZ': - box_bound_morph[2, 1] - box_bound_morph[2, 0] + 1}) - - # Voxel number - diag.update({'roi_' + im_type + '_Int_voxNumb': np.size(x_gl_int)}) - - # Voxel number - diag.update({'roi_' + im_type + '_Morph_voxNumb': np.size(x_gl_morph)}) - - # Mean intensity - diag.update({'roi_' + im_type + '_meanInt': np.mean(x_gl_int)}) - - # Minimum intensity - diag.update({'roi_' + im_type + '_minInt': np.min(x_gl_int)}) - - # Maximum intensity - diag.update({'roi_' + im_type + '_maxInt': np.max(x_gl_int)}) - - return diag diff --git a/MEDimage/biomarkers/get_oriented_bound_box.py b/MEDimage/biomarkers/get_oriented_bound_box.py deleted file mode 100755 index 189b30a..0000000 --- a/MEDimage/biomarkers/get_oriented_bound_box.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from typing import List - -import numpy as np -import pandas as pd - - -def rot_matrix(theta: float, - dim: int=2, - rot_axis: int=-1) -> np.ndarray: - """Creates a 2d or 3d rotation matrix - - Args: - theta (float): angle in radian - dim (int, optional): dimension size. Defaults to 2. - rot_axis (int, optional): rotation axis value. Defaults to -1. - - Returns: - ndarray: rotation matrix - """ - - if dim == 2: - rot_mat = np.array([[np.cos(theta), -np.sin(theta)], - [np.sin(theta), np.cos(theta)]]) - - elif dim == 3: - if rot_axis == 0: - rot_mat = np.array([[1.0, 0.0, 0.0], - [0.0, np.cos(theta), -np.sin(theta)], - [0.0, np.sin(theta), np.cos(theta)]]) - elif rot_axis == 1: - rot_mat = np.array([[np.cos(theta), 0.0, np.sin(theta)], - [0.0, 1.0, 0.0], - [-np.sin(theta), 0.0, np.cos(theta)]]) - elif rot_axis == 2: - rot_mat = np.array([[np.cos(theta), -np.sin(theta), 0.0], - [np.sin(theta), np.cos(theta), 0.0], - [0.0, 0.0, 1.0]]) - else: - rot_mat = None - else: - rot_mat = None - - return rot_mat - - -def sig_proc_segmentise(x: List) -> List: - """Produces a list of segments from input x with values (0,1) - - Args: - x (List): list of values - - Returns: - List: list of segments from input x with values (0,1) - """ - - # Create a difference vector - y = np.diff(x) - - # Find start and end indices of sections with value 1 - ind_1_start = np.array(np.where(y == 1)).flatten() - if np.shape(ind_1_start)[0] > 0: - ind_1_start += 1 - ind_1_end = np.array(np.where(y == -1)).flatten() - - # Check for boundary effects - if x[0] == 1: - ind_1_start = np.insert(ind_1_start, 0, 0) - if x[-1] == 1: - ind_1_end = np.append(ind_1_end, np.shape(x)[0]-1) - - # Generate segment df for segments with value 1 - if np.shape(ind_1_start)[0] == 0: - df_one = pd.DataFrame({"i": [], - "j": [], - "val": []}) - else: - df_one = pd.DataFrame({"i": ind_1_start, - "j": ind_1_end, - "val": np.ones(np.shape(ind_1_start)[0])}) - - # Find start and end indices for section with value 0 - if np.shape(ind_1_start)[0] == 0: - ind_0_start = np.array([0]) - ind_0_end = np.array([np.shape(x)[0]-1]) - else: - ind_0_end = ind_1_start - 1 - ind_0_start = ind_1_end + 1 - - # Check for boundary effect - if x[0] == 0: - ind_0_start = np.insert(ind_0_start, 0, 0) - if x[-1] == 0: - ind_0_end = np.append(ind_0_end, np.shape(x)[0]-1) - - # Check for out-of-range boundary effects - if ind_0_end[0] < 0: - ind_0_end = np.delete(ind_0_end, 0) - if ind_0_start[-1] >= np.shape(x)[0]: - ind_0_start = np.delete(ind_0_start, -1) - - # Generate segment df for segments with value 0 - if np.shape(ind_0_start)[0] == 0: - df_zero = pd.DataFrame({"i": [], - "j": [], - "val": []}) - else: - df_zero = pd.DataFrame({"i": ind_0_start, - "j": ind_0_end, - "val": np.zeros(np.shape(ind_0_start)[0])}) - - df_segm = df_one.append(df_zero).sort_values(by="i").reset_index(drop=True) - - return df_segm - - -def sig_proc_find_peaks(x: float, - ddir: str="pos") -> pd.DataFrame: - """Determines peak positions in array of values - - Args: - x (float): value - ddir (str, optional): positive or negative value. Defaults to "pos". - - Returns: - pd.DataFrame: peak positions in array of values - """ - - # Invert when looking for local minima - if ddir == "neg": - x = -x - - # Generate segments where slope is negative - - df_segm = sig_proc_segmentise(x=(np.diff(x) < 0.0)*1) - - # Start of slope coincides with position of peak (due to index shift induced by np.diff) - ind_peak = df_segm.loc[df_segm.val == 1, "i"].values - - # Check right boundary - if x[-1] > x[-2]: - ind_peak = np.append(ind_peak, np.shape(x)[0]-1) - - # Construct dataframe with index and corresponding value - if np.shape(ind_peak)[0] == 0: - df_peak = pd.DataFrame({"ind": [], - "val": []}) - else: - if ddir == "pos": - df_peak = pd.DataFrame({"ind": ind_peak, - "val": x[ind_peak]}) - if ddir == "neg": - df_peak = pd.DataFrame({"ind": ind_peak, - "val": -x[ind_peak]}) - return df_peak diff --git a/MEDimage/biomarkers/glcm.py b/MEDimage/biomarkers/glcm.py deleted file mode 100755 index ce63dc4..0000000 --- a/MEDimage/biomarkers/glcm.py +++ /dev/null @@ -1,1602 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from copy import deepcopy -from typing import Dict, List, Union, List - -import numpy as np -import pandas as pd - -from ..utils.textureTools import (coord2index, get_neighbour_direction, - get_value, is_list_all_none) - - -def get_matrix(roi_only: np.ndarray, - levels: Union[np.ndarray, List], - dist_correction=True) -> np.ndarray: - r""" - This function computes the Gray-Level Co-occurence Matrix (GLCM) of the - region of interest (ROI) of an input volume. The input volume is assumed - to be isotropically resampled. Only one GLCM is computed per scan, - simultaneously recording (i.e. adding up) the neighboring properties of - the 26-connected neighbors of all voxels in the ROI. To account for - discretization length differences, neighbors at a distance of :math:`\sqrt{3}` - voxels around a center voxel increment the GLCM by a value of :math:`\sqrt{3}`, - neighbors at a distance of :math:`\sqrt{2}` voxels around a center voxel increment - the GLCM by a value of :math:`\sqrt{2}`, and neighbors at a distance of 1 voxels - around a center voxel increment the GLCM by a value of 1. - This matrix refers to "Grey level co-occurrence based features" (ID = LFYI) - in the `IBSI1 reference manual `_. - - Args: - roi_only (ndarray): Smallest box containing the ROI, with the imaging data - ready for texture analysis computations. Voxels outside the ROI are - set to NaNs. - levels (ndarray or List): Vector containing the quantized gray-levels in the tumor region - (or reconstruction ``levels`` of quantization). - dist_correction (bool, optional): Set this variable to true in order to use - discretization length difference corrections as used by the `Institute of Physics and - Engineering in Medicine `_. - Set this variable to false to replicate IBSI results. - - Returns: - ndarray: Gray-Level Co-occurence Matrix of ``roi_only``. - - References: - [1] Haralick, R. M., Shanmugam, K., & Dinstein, I. (1973). Textural \ - features for image classification. IEEE Transactions on Systems, \ - Man and Cybernetics, smc 3(6), 610–621. - """ - # PARSING "dist_correction" ARGUMENT - if type(dist_correction) is not bool: - # The user did not input either "true" or "false", - # so the default behavior is used. - dist_correction = True - - # PRELIMINARY - roi_only = roi_only.copy() - level_temp = np.max(levels)+1 - roi_only[np.isnan(roi_only)] = level_temp - #levels = np.append(levels, level_temp) - dim = np.shape(roi_only) - - if np.ndim(roi_only) == 2: - dim[2] = 1 - - q2 = np.reshape(roi_only, (1, np.prod(dim))) - - # QUANTIZATION EFFECTS CORRECTION (M. Vallieres) - # In case (for example) we initially wanted to have 64 levels, but due to - # quantization, only 60 resulted. - # qs = round(levels*adjust)/adjust; - # q2 = round(q2*adjust)/adjust; - - #qs = levels - qs = levels.tolist() + [level_temp] - lqs = np.size(qs) - - q3 = q2*0 - for k in range(0, lqs): - q3[q2 == qs[k]] = k - - q3 = np.reshape(q3, dim).astype(int) - GLCM = np.zeros((lqs, lqs)) - - for i in range(1, dim[0]+1): - i_min = max(1, i-1) - i_max = min(i+1, dim[0]) - for j in range(1, dim[1]+1): - j_min = max(1, j-1) - j_max = min(j+1, dim[1]) - for k in range(1, dim[2]+1): - k_min = max(1, k-1) - k_max = min(k+1, dim[2]) - val_q3 = q3[i-1, j-1, k-1] - for I2 in range(i_min, i_max+1): - for J2 in range(j_min, j_max+1): - for K2 in range(k_min, k_max+1): - if (I2 == i) & (J2 == j) & (K2 == k): - continue - else: - val_neighbor = q3[I2-1, J2-1, K2-1] - if dist_correction: - # Discretization length correction - GLCM[val_q3, val_neighbor] += \ - np.sqrt(abs(I2-i)+abs(J2-j)+abs(K2-k)) - else: - GLCM[val_q3, val_neighbor] += 1 - - GLCM = GLCM[0:-1, 0:-1] - - return GLCM - -def joint_max(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes joint maximum features. - This feature refers to "Fcm_joint_max" (ID = GYBY) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]:: List or float of the joint maximum feature(s) - """ - temp = [] - joint_max = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - temp.append(np.max(df_pij.pij)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, joint max: {sum(temp) / len(temp)}') - joint_max.append(sum(temp) / len(temp)) - return joint_max - -def joint_avg(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes joint average features. - This feature refers to "Fcm_joint_avg" (ID = 60VM) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]:: List or float of the joint average feature(s) - """ - temp = [] - joint_avg = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - temp.append(np.sum(df_pij.i * df_pij.pij)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, joint avg: {sum(temp) / len(temp)}') - joint_avg.append(sum(temp) / len(temp)) - return joint_avg - -def joint_var(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes joint variance features. - This feature refers to "Fcm_var" (ID = UR99) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: List or float of the joint variance feature(s) - """ - temp = [] - joint_var = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - m_u = np.sum(df_pij.i * df_pij.pij) - temp.append(np.sum((df_pij.i - m_u) ** 2.0 * df_pij.pij)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, joint var: {sum(temp) / len(temp)}') - joint_var.append(sum(temp) / len(temp)) - return joint_var - -def joint_entr(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes joint entropy features. - This feature refers to "Fcm_joint_entr" (ID = TU9B) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the joint entropy features - """ - temp = [] - joint_entr = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - temp.append(-np.sum(df_pij.pij * np.log2(df_pij.pij))) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, joint entr: {sum(temp) / len(temp)}') - joint_entr.append(sum(temp) / len(temp)) - return joint_entr - -def diff_avg(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes difference average features. - This feature refers to "Fcm_diff_avg" (ID = TF7R) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the difference average features - """ - temp = [] - diff_avg = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - _, _, _, df_pimj, _, _ = glcm.get_cm_data([np.nan, np.nan]) - temp.append(np.sum(df_pimj.k * df_pimj.pimj)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, diff avg: {sum(temp) / len(temp)}') - diff_avg.append(sum(temp) / len(temp)) - return diff_avg - -def diff_var(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes difference variance features. - This feature refers to "Fcm_diff_var" (ID = D3YU) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the difference variance features - """ - temp = [] - diff_var = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - _, _, _, df_pimj, _, _ = glcm.get_cm_data([np.nan, np.nan]) - m_u = np.sum(df_pimj.k * df_pimj.pimj) - temp.append(np.sum((df_pimj.k - m_u) ** 2.0 * df_pimj.pimj)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, diff var: {sum(temp) / len(temp)}') - diff_var.append(sum(temp) / len(temp)) - return diff_var - -def diff_entr(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes difference entropy features. - This feature refers to "Fcm_diff_entr" (ID = NTRS) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the difference entropy features - """ - temp = [] - diff_entr = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - _, _, _, df_pimj, _, _ = glcm.get_cm_data([np.nan, np.nan]) - temp.append(-np.sum(df_pimj.pimj * np.log2(df_pimj.pimj))) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, diff entr: {sum(temp) / len(temp)}') - diff_entr.append(sum(temp) / len(temp)) - return diff_entr - -def sum_avg(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes sum average features. - This feature refers to "Fcm_sum_avg" (ID = ZGXS) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the sum average features - """ - temp = [] - sum_avg = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - _, _, _, _, df_pipj, _ = glcm.get_cm_data([np.nan, np.nan]) - temp.append(np.sum(df_pipj.k * df_pipj.pipj)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, sum avg: {sum(temp) / len(temp)}') - sum_avg.append(sum(temp) / len(temp)) - return sum_avg - -def sum_var(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes sum variance features. - This feature refers to "Fcm_sum_var" (ID = OEEB) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the sum variance features - """ - temp = [] - sum_var = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - _, _, _, _, df_pipj, _ = glcm.get_cm_data([np.nan, np.nan]) - m_u = np.sum(df_pipj.k * df_pipj.pipj) - temp.append(np.sum((df_pipj.k - m_u) ** 2.0 * df_pipj.pipj)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, sum var: {sum(temp) / len(temp)}') - sum_var.append(sum(temp) / len(temp)) - return sum_var - -def sum_entr(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes sum entropy features. - This feature refers to "Fcm_sum_entr" (ID = P6QZ) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the sum entropy features - """ - temp = [] - sum_entr = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - _, _, _, _, df_pipj, _ = glcm.get_cm_data([np.nan, np.nan]) - temp.append(-np.sum(df_pipj.pipj * np.log2(df_pipj.pipj))) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, sum entr: {sum(temp) / len(temp)}') - sum_entr.append(sum(temp) / len(temp)) - return sum_entr - -def energy(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes angular second moment features. - This feature refers to "Fcm_energy" (ID = 8ZQL) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the angular second moment features - """ - temp = [] - energy = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - temp.append(np.sum(df_pij.pij ** 2.0)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, energy: {sum(temp) / len(temp)}') - energy.append(sum(temp) / len(temp)) - return energy - -def contrast(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes constrast features. - This feature refers to "Fcm_contrast" (ID = ACUI) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the contrast features - """ - temp = [] - contrast = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - temp.append(np.sum((df_pij.i - df_pij.j) ** 2.0 * df_pij.pij)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, contrast: {sum(temp) / len(temp)}') - contrast.append(sum(temp) / len(temp)) - return contrast - -def dissimilarity(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes dissimilarity features. - This feature refers to "Fcm_dissimilarity" (ID = 8S9J) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the dissimilarity features - """ - temp = [] - dissimilarity = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - temp.append(np.sum(np.abs(df_pij.i - df_pij.j) * df_pij.pij)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, dissimilarity: {sum(temp) / len(temp)}') - dissimilarity.append(sum(temp) / len(temp)) - return dissimilarity - -def inv_diff(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes inverse difference features. - This feature refers to "Fcm_inv_diff" (ID = IB1Z) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the inverse difference features - """ - temp = [] - inv_diff = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - temp.append(np.sum(df_pij.pij / (1.0 + np.abs(df_pij.i - df_pij.j)))) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, inv diff: {sum(temp) / len(temp)}') - inv_diff.append(sum(temp) / len(temp)) - return inv_diff - -def inv_diff_norm(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes inverse difference normalized features. - This feature refers to "Fcm_inv_diff_norm" (ID = NDRX) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the inverse difference normalized features - """ - temp = [] - inv_diff_norm = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, _, _, _, _, n_g = glcm.get_cm_data([np.nan, np.nan]) - temp.append(np.sum(df_pij.pij / (1.0 + np.abs(df_pij.i - df_pij.j) / n_g))) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, inv diff norm: {sum(temp) / len(temp)}') - inv_diff_norm.append(sum(temp) / len(temp)) - return inv_diff_norm - -def inv_diff_mom(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes inverse difference moment features. - This feature refers to "Fcm_inv_diff_mom" (ID = WF0Z) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the inverse difference moment features - """ - temp = [] - inv_diff_mom = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - temp.append(np.sum(df_pij.pij / (1.0 + (df_pij.i - df_pij.j) ** 2.0))) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, inv diff mom: {sum(temp) / len(temp)}') - inv_diff_mom.append(sum(temp) / len(temp)) - return inv_diff_mom - -def inv_diff_mom_norm(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes inverse difference moment normalized features. - This feature refers to "Fcm_inv_diff_mom_norm" (ID = 1QCO) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the inverse difference moment normalized features - """ - temp = [] - inv_diff_mom_norm = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, _, _, _, _, n_g = glcm.get_cm_data([np.nan, np.nan]) - temp.append(np.sum(df_pij.pij / (1.0 + (df_pij.i - df_pij.j)** 2.0 / n_g ** 2.0))) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, inv diff mom norm: {sum(temp) / len(temp)}') - inv_diff_mom_norm.append(sum(temp) / len(temp)) - return inv_diff_mom_norm - -def inv_var(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes inverse variance features. - This feature refers to "Fcm_inv_var" (ID = E8JP) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the inverse variance features - """ - temp = [] - inv_var = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, df_pi, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - mu_marg = np.sum(df_pi.i * df_pi.pi) - var_marg = np.sum((df_pi.i - mu_marg) ** 2.0 * df_pi.pi) - if var_marg == 0.0: - temp.append(1.0) - else: - temp.append(1.0 / var_marg * (np.sum(df_pij.i * df_pij.j * df_pij.pij) - mu_marg ** 2.0)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, inv var: {sum(temp) / len(temp)}') - inv_var.append(sum(temp) / len(temp)) - return inv_var - -def corr(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes correlation features. - This feature refers to "Fcm_corr" (ID = NI2N) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the correlation features - """ - temp = [] - corr = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, df_pi, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - mu_marg = np.sum(df_pi.i * df_pi.pi) - var_marg = np.sum((df_pi.i - mu_marg) ** 2.0 * df_pi.pi) - if var_marg == 0.0: - temp.append(1.0) - else: - temp.append(1.0 / var_marg * (np.sum(df_pij.i * df_pij.j * df_pij.pij) - mu_marg ** 2.0)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, corr: {sum(temp) / len(temp)}') - corr.append(sum(temp) / len(temp)) - return corr - - -def auto_corr(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes autocorrelation features. - This feature refers to "Fcm_auto_corr" (ID = QWB0) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the autocorrelation features - """ - temp = [] - auto_corr = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, _, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - temp.append(np.sum(df_pij.i * df_pij.j * df_pij.pij)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, auto corr: {sum(temp) / len(temp)}') - auto_corr.append(sum(temp) / len(temp)) - return auto_corr - -def info_corr1(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes information correlation 1 features. - This feature refers to "Fcm_info_corr1" (ID = R8DG) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the information correlation 1 features - """ - temp = [] - info_corr1 = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, df_pi, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - hxy = -np.sum(df_pij.pij * np.log2(df_pij.pij)) - hxy_1 = -np.sum(df_pij.pij * np.log2(df_pij.pi * df_pij.pj)) - hx = -np.sum(df_pi.pi * np.log2(df_pi.pi)) - if len(df_pij) == 1 or hx == 0.0: - temp.append(1.0) - else: - temp.append((hxy - hxy_1) / hx) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, info corr 1: {sum(temp) / len(temp)}') - info_corr1.append(sum(temp) / len(temp)) - return info_corr1 - -def info_corr2(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes information correlation 2 features - Note: iteration over combinations of i and j - This feature refers to "Fcm_info_corr2" (ID = JN9H) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the information correlation 2 features - """ - temp = [] - info_corr2 = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, df_pi, df_pj, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - hxy = - np.sum(df_pij.pij * np.log2(df_pij.pij)) - hxy_2 = - np.sum( - np.tile(df_pi.pi, len(df_pj)) * np.repeat(df_pj.pj, len(df_pi)) * \ - np.log2(np.tile(df_pi.pi, len(df_pj)) * np.repeat(df_pj.pj, len(df_pi))) - ) - if hxy_2 < hxy: - temp.append(0) - else: - temp.append(np.sqrt(1 - np.exp(-2.0 * (hxy_2 - hxy)))) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, info corr 2: {sum(temp) / len(temp)}') - info_corr2.append(sum(temp) / len(temp)) - return info_corr2 - -def clust_tend(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes cluster tendency features. - This feature refers to "Fcm_clust_tend" (ID = DG8W) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the cluster tendency features - """ - temp = [] - clust_tend = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, df_pi, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - m_u = np.sum(df_pi.i * df_pi.pi) - temp.append(np.sum((df_pij.i + df_pij.j - 2 * m_u) ** 2.0 * df_pij.pij)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, clust tend: {sum(temp) / len(temp)}') - clust_tend.append(sum(temp) / len(temp)) - return clust_tend - -def clust_shade(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes cluster shade features. - This feature refers to "Fcm_clust_shade" (ID = 7NFM) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the cluster shade features - """ - temp = [] - clust_shade = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, df_pi, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - m_u = np.sum(df_pi.i * df_pi.pi) - temp.append(np.sum((df_pij.i + df_pij.j - 2 * m_u) ** 3.0 * df_pij.pij)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, clust shade: {sum(temp) / len(temp)}') - clust_shade.append(sum(temp) / len(temp)) - return clust_shade - -def clust_prom(glcm_dict: Dict) -> Union[float, List[float]]: - """Computes cluster prominence features. - This feature refers to "Fcm_clust_prom" (ID = AE86) in the `IBSI1 reference \ - manual `__. - - Args: - glcm_dict (Dict): dictionary with glcm matrices, generated using :func:`get_glcm_matrices` - - Returns: - Union[float, List[float]]: the cluster prominence features - """ - temp = [] - clust_prom = [] - for key in glcm_dict.keys(): - for glcm in glcm_dict[key]: - df_pij, df_pi, _, _, _, _ = glcm.get_cm_data([np.nan, np.nan]) - m_u = np.sum(df_pi.i * df_pi.pi) - temp.append(np.sum((df_pij.i + df_pij.j - 2 * m_u) ** 4.0 * df_pij.pij)) - if len(glcm_dict) <= 1: - return sum(temp) / len(temp) - else: - print(f'Merge method: {key}, clust prom: {sum(temp) / len(temp)}') - clust_prom.append(sum(temp) / len(temp)) - return clust_prom - -def extract_all(vol, dist_correction=None, merge_method="vol_merge") -> Dict: - """Computes glcm features. - This features refer to Glcm family in the `IBSI1 reference \ - manual `__. - - Args: - vol (ndarray): 3D volume, isotropically resampled, quantized - (e.g. n_g = 32, levels = [1, ..., n_g]), with NaNs outside the region - of interest. - dist_correction (Union[bool, str], optional): Set this variable to true in order to use - discretization length difference corrections as used by the `Institute of Physics and - Engineering in Medicine `__. - Set this variable to false to replicate IBSI results. - Or use string and specify the norm for distance weighting. Weighting is - only performed if this argument is "manhattan", "euclidean" or "chebyshev". - merge_method (str, optional): merging ``method`` which determines how features are - calculated. One of "average", "slice_merge", "dir_merge" and "vol_merge". - Note that not all combinations of spatial and merge ``method`` are valid. - method (str, optional): Either 'old' (deprecated) or 'new' (faster) ``method``. - - Returns: - Dict: Dict of the glcm features. - - Raises: - ValueError: If `method` is not 'old' or 'new'. - - Todo: - - - Enable calculation of CM features using different spatial methods (2d, 2.5d, 3d) - - Enable calculation of CM features using different CM distance settings - - Enable calculation of CM features for different merge methods ("average", "slice_merge", "dir_merge" and "vol_merge") - - Provide the range of discretised intensities from a calling function and pass to :func:`get_cm_features`. - - Test if dist_correction works as expected. - - """ - glcm = get_cm_features( - vol=vol, - intensity_range=[np.nan, np.nan], - merge_method=merge_method, - dist_weight_norm=dist_correction - ) - - return glcm - -def get_glcm_matrices(vol, - glcm_spatial_method="3d", - glcm_dist=1.0, - merge_method="vol_merge", - dist_weight_norm=None) -> Dict: - """Extracts co-occurrence matrices from the intensity roi mask prior to features extraction. - - Note: - This code was adapted from the in-house radiomics software created at - OncoRay, Dresden, Germany. - - Args: - vol (ndarray): volume with discretised intensities as 3D numpy array (x, y, z). - intensity_range (ndarray): range of potential discretised intensities,provided as a list: - [minimal discretised intensity, maximal discretised intensity]. - If one or both values are unknown, replace the respective values with np.nan. - glcm_spatial_method (str, optional): spatial method which determines the way - co-occurrence matrices are calculated and how features are determined. - Must be "2d", "2.5d" or "3d". - glcm_dist (float, optional): Chebyshev distance for comparison between neighboring voxels. - merge_method (str, optional): merging method which determines how features are - calculated. One of "average", "slice_merge", "dir_merge" and "vol_merge". - Note that not all combinations of spatial and merge method are valid. - dist_weight_norm (Union[bool, str], optional): norm for distance weighting. Weighting is only - performed if this argument is either "manhattan","euclidean", "chebyshev" or bool. - - Returns: - Dict: Dict of co-occurrence matrices. - - Raises: - ValueError: If `glcm_spatial_method` is not "2d", "2.5d" or "3d". - """ - if type(glcm_spatial_method) is not list: - glcm_spatial_method = [glcm_spatial_method] - - if type(glcm_dist) is not list: - glcm_dist = [glcm_dist] - - if type(merge_method) is not list: - merge_method = [merge_method] - - if type(dist_weight_norm) is bool: - if dist_weight_norm: - dist_weight_norm = "euclidean" - - # Get the roi in tabular format - img_dims = vol.shape - index_id = np.arange(start=0, stop=vol.size) - coords = np.unravel_index(indices=index_id, shape=img_dims) - df_img = pd.DataFrame({"index_id": index_id, - "g": np.ravel(vol), - "x": coords[0], - "y": coords[1], - "z": coords[2], - "roi_int_mask": np.ravel(np.isfinite(vol))}) - - # Iterate over spatial arrangements - for ii_spatial in glcm_spatial_method: - # Iterate over distances - for ii_dist in glcm_dist: - # Initiate list of glcm objects - glcm_list = [] - # Perform 2D analysis - if ii_spatial.lower() in ["2d", "2.5d"]: - # Iterate over slices - for ii_slice in np.arange(0, img_dims[2]): - # Get neighbour direction and iterate over neighbours - nbrs = get_neighbour_direction( - d=1, - distance="chebyshev", - centre=False, - complete=False, - dim3=False) * int(ii_dist) - for ii_direction in np.arange(0, np.shape(nbrs)[1]): - # Add glcm matrices to list - glcm_list += [CooccurrenceMatrix(distance=int(ii_dist), - direction=nbrs[:, ii_direction], - direction_id=ii_direction, - spatial_method=ii_spatial.lower(), - img_slice=ii_slice)] - - # Perform 3D analysis - elif ii_spatial.lower() == "3d": - # Get neighbour direction and iterate over neighbours - nbrs = get_neighbour_direction(d=1, - distance="chebyshev", - centre=False, - complete=False, - dim3=True) * int(ii_dist) - - for ii_direction in np.arange(0, np.shape(nbrs)[1]): - # Add glcm matrices to list - glcm_list += [CooccurrenceMatrix(distance=int(ii_dist), - direction=nbrs[:, ii_direction], - direction_id=ii_direction, - spatial_method=ii_spatial.lower())] - - else: - raise ValueError( - "GCLM matrices can be determined in \"2d\", \"2.5d\" and \"3d\". \ - The requested method (%s) is not implemented.", ii_spatial) - - # Calculate glcm matrices - for glcm in glcm_list: - glcm.calculate_cm_matrix( - df_img=df_img, img_dims=img_dims, dist_weight_norm=dist_weight_norm) - - # Merge matrices according to the given method - upd_list = {} - for merge_method in merge_method: - upd_list[merge_method] = combine_matrices( - glcm_list=glcm_list, merge_method=merge_method, spatial_method=ii_spatial.lower()) - - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is None: - continue - return upd_list - -def get_cm_features(vol, - intensity_range, - glcm_spatial_method="3d", - glcm_dist=1.0, - merge_method="vol_merge", - dist_weight_norm=None) -> Dict: - """Extracts co-occurrence matrix-based features from the intensity roi mask. - - Note: - This code was adapted from the in-house radiomics software created at - OncoRay, Dresden, Germany. - - Args: - vol (ndarray): volume with discretised intensities as 3D numpy array (x, y, z). - intensity_range (ndarray): range of potential discretised intensities, - provided as a list: [minimal discretised intensity, maximal discretised - intensity]. If one or both values are unknown, replace the respective values - with np.nan. - glcm_spatial_method (str, optional): spatial method which determines the way - co-occurrence matrices are calculated and how features are determined. - MUST BE "2d", "2.5d" or "3d". - glcm_dist (float, optional): chebyshev distance for comparison between neighbouring - voxels. - merge_method (str, optional): merging method which determines how features are - calculated. One of "average", "slice_merge", "dir_merge" and "vol_merge". - Note that not all combinations of spatial and merge method are valid. - dist_weight_norm (Union[bool, str], optional): norm for distance weighting. Weighting is only - performed if this argument is either "manhattan", - "euclidean", "chebyshev" or bool. - - Returns: - Dict: Dict of the glcm features. - - Raises: - ValueError: If `glcm_spatial_method` is not "2d", "2.5d" or "3d". - """ - if type(glcm_spatial_method) is not list: - glcm_spatial_method = [glcm_spatial_method] - - if type(glcm_dist) is not list: - glcm_dist = [glcm_dist] - - if type(merge_method) is not list: - merge_method = [merge_method] - - if type(dist_weight_norm) is bool: - if dist_weight_norm: - dist_weight_norm = "euclidean" - - # Get the roi in tabular format - img_dims = vol.shape - index_id = np.arange(start=0, stop=vol.size) - coords = np.unravel_index(indices=index_id, shape=img_dims) - df_img = pd.DataFrame({"index_id": index_id, - "g": np.ravel(vol), - "x": coords[0], - "y": coords[1], - "z": coords[2], - "roi_int_mask": np.ravel(np.isfinite(vol))}) - - # Generate an empty feature list - feat_list = [] - - # Iterate over spatial arrangements - for ii_spatial in glcm_spatial_method: - # Iterate over distances - for ii_dist in glcm_dist: - # Initiate list of glcm objects - glcm_list = [] - # Perform 2D analysis - if ii_spatial.lower() in ["2d", "2.5d"]: - # Iterate over slices - for ii_slice in np.arange(0, img_dims[2]): - # Get neighbour direction and iterate over neighbours - nbrs = get_neighbour_direction( - d=1, - distance="chebyshev", - centre=False, - complete=False, - dim3=False) * int(ii_dist) - for ii_direction in np.arange(0, np.shape(nbrs)[1]): - # Add glcm matrices to list - glcm_list += [CooccurrenceMatrix(distance=int(ii_dist), - direction=nbrs[:, ii_direction], - direction_id=ii_direction, - spatial_method=ii_spatial.lower(), - img_slice=ii_slice)] - - # Perform 3D analysis - elif ii_spatial.lower() == "3d": - # Get neighbour direction and iterate over neighbours - nbrs = get_neighbour_direction(d=1, - distance="chebyshev", - centre=False, - complete=False, - dim3=True) * int(ii_dist) - - for ii_direction in np.arange(0, np.shape(nbrs)[1]): - # Add glcm matrices to list - glcm_list += [CooccurrenceMatrix(distance=int(ii_dist), - direction=nbrs[:, ii_direction], - direction_id=ii_direction, - spatial_method=ii_spatial.lower())] - - else: - raise ValueError( - "GCLM matrices can be determined in \"2d\", \"2.5d\" and \"3d\". \ - The requested method (%s) is not implemented.", ii_spatial) - - # Calculate glcm matrices - for glcm in glcm_list: - glcm.calculate_cm_matrix( - df_img=df_img, img_dims=img_dims, dist_weight_norm=dist_weight_norm) - - # Merge matrices according to the given method - for merge_method in merge_method: - upd_list = combine_matrices( - glcm_list=glcm_list, merge_method=merge_method, spatial_method=ii_spatial.lower()) - - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is None: - continue - - # Calculate features - feat_run_list = [] - for glcm in upd_list: - feat_run_list += [glcm.calculate_cm_features( - intensity_range=intensity_range)] - - # Average feature values - feat_list += [pd.concat(feat_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single table and return as a dictionary - df_feat = pd.concat(feat_list, axis=1).to_dict(orient="records")[0] - - return df_feat - -def combine_matrices(glcm_list: List, merge_method: str, spatial_method: str) -> List: - """Merges co-occurrence matrices prior to feature calculation. - - Note: - This code was adapted from the in-house radiomics software created at - OncoRay, Dresden, Germany. - - Args: - glcm_list (List): List of CooccurrenceMatrix objects. - merge_method (str): Merging method which determines how features are calculated. - One of "average", "slice_merge", "dir_merge" and "vol_merge". Note that not all - combinations of spatial and merge method are valid. - spatial_method (str): spatial method which determines the way co-occurrence - matrices are calculated and how features are determined. One of "2d", "2.5d" - or "3d". - - Returns: - List[CooccurrenceMatrix]: list of one or more merged CooccurrenceMatrix objects. - """ - # Initiate empty list - use_list = [] - - # For average features over direction, maintain original glcms - if merge_method == "average" and spatial_method in ["2d", "3d"]: - # Make copy of glcm_list - for glcm in glcm_list: - use_list += [glcm._copy()] - - # Set merge method to average - for glcm in use_list: - glcm.merge_method = "average" - - # Merge glcms by slice - elif merge_method == "slice_merge" and spatial_method == "2d": - # Find slice_ids - slice_id = [] - for glcm in glcm_list: - slice_id += [glcm.slice] - - # Iterate over unique slice_ids - for ii_slice in np.unique(slice_id): - slice_glcm_id = np.squeeze(np.where(slice_id == ii_slice)) - - # Select all matrices within the slice - sel_matrix_list = [] - for glcm_id in slice_glcm_id: - sel_matrix_list += [glcm_list[glcm_id].matrix] - - # Check if any matrix has been created for the currently selected slice - if is_list_all_none(sel_matrix_list): - # No matrix was created - use_list += [CooccurrenceMatrix(distance=glcm_list[slice_glcm_id[0]].distance, - direction=None, - direction_id=None, - spatial_method=spatial_method, - img_slice=ii_slice, - merge_method=merge_method, - matrix=None, - n_v=0.0)] - else: - # Merge matrices within the slice - merge_cm = pd.concat(sel_matrix_list, axis=0) - merge_cm = merge_cm.groupby(by=["i", "j"]).sum().reset_index() - - # Update the number of voxels within the merged slice - merge_n_v = 0.0 - for glcm_id in slice_glcm_id: - merge_n_v += glcm_list[glcm_id].n_v - - # Create new cooccurrence matrix - use_list += [CooccurrenceMatrix(distance=glcm_list[slice_glcm_id[0]].distance, - direction=None, - direction_id=None, - spatial_method=spatial_method, - img_slice=ii_slice, - merge_method=merge_method, - matrix=merge_cm, - n_v=merge_n_v)] - - # Merge glcms by direction - elif merge_method == "dir_merge" and spatial_method == "2.5d": - # Find slice_ids - dir_id = [] - for glcm in glcm_list: - dir_id += [glcm.direction_id] - - # Iterate over unique directions - for ii_dir in np.unique(dir_id): - dir_glcm_id = np.squeeze(np.where(dir_id == ii_dir)) - - # Select all matrices with the same direction - sel_matrix_list = [] - for glcm_id in dir_glcm_id: - sel_matrix_list += [glcm_list[glcm_id].matrix] - - # Check if any matrix has been created for the currently selected direction - if is_list_all_none(sel_matrix_list): - # No matrix was created - use_list += [CooccurrenceMatrix(distance=glcm_list[dir_glcm_id[0]].distance, - direction=glcm_list[dir_glcm_id[0]].direction, - direction_id=ii_dir, - spatial_method=spatial_method, - img_slice=None, - merge_method=merge_method, - matrix=None, n_v=0.0)] - else: - # Merge matrices with the same direction - merge_cm = pd.concat(sel_matrix_list, axis=0) - merge_cm = merge_cm.groupby(by=["i", "j"]).sum().reset_index() - - # Update the number of voxels for the merged matrices with the same direction - merge_n_v = 0.0 - for glcm_id in dir_glcm_id: - merge_n_v += glcm_list[glcm_id].n_v - - # Create new co-occurrence matrix - use_list += [CooccurrenceMatrix(distance=glcm_list[dir_glcm_id[0]].distance, - direction=glcm_list[dir_glcm_id[0]].direction, - direction_id=ii_dir, - spatial_method=spatial_method, - img_slice=None, - merge_method=merge_method, - matrix=merge_cm, - n_v=merge_n_v)] - - # Merge all glcms into a single representation - elif merge_method == "vol_merge" and spatial_method in ["2.5d", "3d"]: - # Select all matrices within the slice - sel_matrix_list = [] - for glcm_id in np.arange(len(glcm_list)): - sel_matrix_list += [glcm_list[glcm_id].matrix] - - # Check if any matrix was created - if is_list_all_none(sel_matrix_list): - # In case no matrix was created - use_list += [CooccurrenceMatrix(distance=glcm_list[0].distance, - direction=None, - direction_id=None, - spatial_method=spatial_method, - img_slice=None, - merge_method=merge_method, - matrix=None, - n_v=0.0)] - else: - # Merge co-occurrence matrices - merge_cm = pd.concat(sel_matrix_list, axis=0) - merge_cm = merge_cm.groupby(by=["i", "j"]).sum().reset_index() - - # Update the number of voxels - merge_n_v = 0.0 - for glcm_id in np.arange(len(glcm_list)): - merge_n_v += glcm_list[glcm_id].n_v - - # Create new co-occurrence matrix - use_list += [CooccurrenceMatrix(distance=glcm_list[0].distance, - direction=None, - direction_id=None, - spatial_method=spatial_method, - img_slice=None, - merge_method=merge_method, - matrix=merge_cm, - n_v=merge_n_v)] - else: - use_list = None - - return use_list - - -class CooccurrenceMatrix: - """ Class that contains a single co-occurrence ``matrix``. - - Note: - Code was adapted from the in-house radiomics software created at - OncoRay, Dresden, Germany. - - Attributes: - distance (int): Chebyshev ``distance``. - direction (ndarray): Direction along which neighbouring voxels are found. - direction_id (int): Direction index to identify unique ``direction`` vectors. - spatial_method (str): Spatial method used to calculate the co-occurrence - ``matrix``: "2d", "2.5d" or "3d". - img_slice (ndarray): Corresponding slice index (only if the co-occurrence - ``matrix`` corresponds to a 2d image slice). - merge_method (str): Method for merging the co-occurrence ``matrix`` with other - co-occurrence matrices. - matrix (pandas.DataFrame): The actual co-occurrence ``matrix`` in sparse format - (row, column, count). - n_v (int): The number of voxels in the volume. - """ - - def __init__(self, - distance: int, - direction: np.ndarray, - direction_id: int, - spatial_method: str, - img_slice: np.ndarray=None, - merge_method: str=None, - matrix: pd.DataFrame=None, - n_v: int=None) -> None: - """Constructor of the CooccurrenceMatrix class - - Args: - distance (int): Chebyshev ``distance``. - direction (ndarray): Direction along which neighbouring voxels are found. - direction_id (int): Direction index to identify unique ``direction`` vectors. - spatial_method (str): Spatial method used to calculate the co-occurrence - ``matrix``: "2d", "2.5d" or "3d". - img_slice (ndarray, optional): Corresponding slice index (only if the - co-occurrence ``matrix`` corresponds to a 2d image slice). - merge_method (str, optional): Method for merging the co-occurrence ``matrix`` - with other co-occurrence matrices. - matrix (pandas.DataFrame, optional): The actual co-occurrence ``matrix`` in - sparse format (row, column, count). - n_v (int, optional): The number of voxels in the volume. - - Returns: - None. - """ - # Distance used - self.distance = distance - - # Direction and slice for which the current matrix is extracted - self.direction = direction - self.direction_id = direction_id - self.img_slice = img_slice - - # Spatial analysis method (2d, 2.5d, 3d) and merge method (average, slice_merge, dir_merge, vol_merge) - self.spatial_method = spatial_method - self.merge_method = merge_method - - # Place holders - self.matrix = matrix - self.n_v = n_v - - def _copy(self): - """ - Returns a copy of the co-occurrence matrix object. - """ - return deepcopy(self) - - def calculate_cm_matrix(self, df_img: pd.DataFrame, img_dims: np.ndarray, dist_weight_norm: str) -> None: - """Function that calculates a co-occurrence matrix for the settings provided during - initialisation and the input image. - - Args: - df_img (pandas.DataFrame): Data table containing image intensities, x, y and z coordinates, - and mask labels corresponding to voxels in the volume. - img_dims (ndarray, List[float]): Dimensions of the image volume. - dist_weight_norm (str): Norm for distance weighting. Weighting is only - performed if this parameter is either "manhattan", "euclidean" or "chebyshev". - - Returns: - None. Assigns the created image table (cm matrix) to the `matrix` attribute. - - Raises: - ValueError: - If `self.spatial_method` is not "2d", "2.5d" or "3d". - Also, if ``dist_weight_norm`` is not "manhattan", "euclidean" or "chebyshev". - - """ - # Check if the roi contains any masked voxels. If this is not the case, don't construct the glcm. - if not np.any(df_img.roi_int_mask): - self.n_v = 0 - self.matrix = None - - return None - - # Create local copies of the image table - if self.spatial_method == "3d": - df_cm = deepcopy(df_img) - elif self.spatial_method in ["2d", "2.5d"]: - df_cm = deepcopy(df_img[df_img.z == self.img_slice]) - df_cm["index_id"] = np.arange(0, len(df_cm)) - df_cm["z"] = 0 - df_cm = df_cm.reset_index(drop=True) - else: - raise ValueError( - "The spatial method for grey level co-occurrence matrices should be one of \"2d\", \"2.5d\" or \"3d\".") - - # Set grey level of voxels outside ROI to NaN - df_cm.loc[df_cm.roi_int_mask == False, "g"] = np.nan - - # Determine potential transitions - df_cm["to_index"] = coord2index(x=df_cm.x.values + self.direction[0], - y=df_cm.y.values + self.direction[1], - z=df_cm.z.values + self.direction[2], - dims=img_dims) - - # Get grey levels from transitions - df_cm["to_g"] = get_value(x=df_cm.g.values, index=df_cm.to_index.values) - - # Check if any transitions exist. - if np.all(np.isnan(df_cm[["to_g"]])): - self.n_v = 0 - self.matrix = None - - return None - - # Count occurrences of grey level transitions - df_cm = df_cm.groupby(by=["g", "to_g"]).size().reset_index(name="n") - - # Append grey level transitions in opposite direction - df_cm_inv = pd.DataFrame({"g": df_cm.to_g, "to_g": df_cm.g, "n": df_cm.n}) - df_cm = df_cm.append(df_cm_inv, ignore_index=True) - - # Sum occurrences of grey level transitions - df_cm = df_cm.groupby(by=["g", "to_g"]).sum().reset_index() - - # Rename columns - df_cm.columns = ["i", "j", "n"] - - if dist_weight_norm in ["manhattan", "euclidean", "chebyshev"]: - if dist_weight_norm == "manhattan": - weight = sum(abs(self.direction)) - elif dist_weight_norm == "euclidean": - weight = np.sqrt(sum(np.power(self.direction, 2.0))) - elif dist_weight_norm == "chebyshev": - weight = np.max(abs(self.direction)) - df_cm.n /= weight - - # Set the number of voxels - self.n_v = np.sum(df_cm.n) - - # Add matrix and number of voxels to object - self.matrix = df_cm - - def get_cm_data(self, intensity_range: np.ndarray): - """Computes the probability distribution for the elements of the GLCM - (diagonal probability, cross-diagonal probability...) and number of gray-levels. - - Args: - intensity_range (ndarray): Range of potential discretised intensities, provided as a list: - [minimal discretised intensity, maximal discretised intensity]. - If one or both values are unknown,replace the respective values with np.nan. - - Returns: - Typle[pd.DataFrame, pd.DataFrame, pd.DataFrame, float]: - - Occurence data frame - - Diagonal probabilty - - Cross-diagonal probabilty - - Number of gray levels - """ - # Occurrence data frames - df_pij = deepcopy(self.matrix) - df_pij["pij"] = df_pij.n / sum(df_pij.n) - df_pi = df_pij.groupby(by="i")["pij"].agg(np.sum).reset_index().rename(columns={"pij": "pi"}) - df_pj = df_pij.groupby(by="j")["pij"].agg(np.sum).reset_index().rename(columns={"pij": "pj"}) - - # Diagonal probilities p(i-j) - df_pimj = deepcopy(df_pij) - df_pimj["k"] = np.abs(df_pimj.i - df_pimj.j) - df_pimj = df_pimj.groupby(by="k")["pij"].agg(np.sum).reset_index().rename(columns={"pij": "pimj"}) - - # Cross-diagonal probabilities p(i+j) - df_pipj = deepcopy(df_pij) - df_pipj["k"] = df_pipj.i + df_pipj.j - df_pipj = df_pipj.groupby(by="k")["pij"].agg(np.sum).reset_index().rename(columns={"pij": "pipj"}) - - # Merger of df.p_ij, df.p_i and df.p_j - df_pij = pd.merge(df_pij, df_pi, on="i") - df_pij = pd.merge(df_pij, df_pj, on="j") - - # Constant definitions - intensity_range_loc = deepcopy(intensity_range) - if np.isnan(intensity_range[0]): - intensity_range_loc[0] = np.min(df_pi.i) * 1.0 - if np.isnan(intensity_range[1]): - intensity_range_loc[1] = np.max(df_pi.i) * 1.0 - # Number of grey levels - n_g = intensity_range_loc[1] - intensity_range_loc[0] + 1.0 - - return df_pij, df_pi, df_pj, df_pimj, df_pipj, n_g - - def calculate_cm_features(self, intensity_range: np.ndarray) -> pd.DataFrame: - """Wrapper to json.dump function. - - Args: - intensity_range (np.ndarray): Range of potential discretised intensities, - provided as a list: [minimal discretised intensity, maximal discretised intensity]. - If one or both values are unknown,replace the respective values with np.nan. - - Returns: - pandas.DataFrame: Data frame with values for each feature. - """ - # Create feature table - feat_names = ["Fcm_joint_max", "Fcm_joint_avg", "Fcm_joint_var", "Fcm_joint_entr", - "Fcm_diff_avg", "Fcm_diff_var", "Fcm_diff_entr", - "Fcm_sum_avg", "Fcm_sum_var", "Fcm_sum_entr", - "Fcm_energy", "Fcm_contrast", "Fcm_dissimilarity", - "Fcm_inv_diff", "Fcm_inv_diff_norm", "Fcm_inv_diff_mom", - "Fcm_inv_diff_mom_norm", "Fcm_inv_var", "Fcm_corr", - "Fcm_auto_corr", "Fcm_clust_tend", "Fcm_clust_shade", - "Fcm_clust_prom", "Fcm_info_corr1", "Fcm_info_corr2"] - - df_feat = pd.DataFrame(np.full(shape=(1, len(feat_names)), fill_value=np.nan)) - df_feat.columns = feat_names - - # Don't return data for empty slices or slices without a good matrix - if self.matrix is None: - # Update names - #df_feat.columns += self._parse_names() - return df_feat - elif len(self.matrix) == 0: - # Update names - #df_feat.columns += self._parse_names() - return df_feat - - df_pij, df_pi, df_pj, df_pimj, df_pipj, n_g = self.get_cm_data(intensity_range) - - ############################################### - ###### glcm features ###### - ############################################### - # Joint maximum - df_feat.loc[0, "Fcm_joint_max"] = np.max(df_pij.pij) - - # Joint average - df_feat.loc[0, "Fcm_joint_avg"] = np.sum(df_pij.i * df_pij.pij) - - # Joint variance - m_u = np.sum(df_pij.i * df_pij.pij) - df_feat.loc[0, "Fcm_joint_var"] = np.sum((df_pij.i - m_u) ** 2.0 * df_pij.pij) - - # Joint entropy - df_feat.loc[0, "Fcm_joint_entr"] = -np.sum(df_pij.pij * np.log2(df_pij.pij)) - - # Difference average - df_feat.loc[0, "Fcm_diff_avg"] = np.sum(df_pimj.k * df_pimj.pimj) - - # Difference variance - m_u = np.sum(df_pimj.k * df_pimj.pimj) - df_feat.loc[0, "Fcm_diff_var"] = np.sum((df_pimj.k - m_u) ** 2.0 * df_pimj.pimj) - - # Difference entropy - df_feat.loc[0, "Fcm_diff_entr"] = -np.sum(df_pimj.pimj * np.log2(df_pimj.pimj)) - - # Sum average - df_feat.loc[0, "Fcm_sum_avg"] = np.sum(df_pipj.k * df_pipj.pipj) - - # Sum variance - m_u = np.sum(df_pipj.k * df_pipj.pipj) - df_feat.loc[0, "Fcm_sum_var"] = np.sum((df_pipj.k - m_u) ** 2.0 * df_pipj.pipj) - - # Sum entropy - df_feat.loc[0, "Fcm_sum_entr"] = -np.sum(df_pipj.pipj * np.log2(df_pipj.pipj)) - - # Angular second moment - df_feat.loc[0, "Fcm_energy"] = np.sum(df_pij.pij ** 2.0) - - # Contrast - df_feat.loc[0, "Fcm_contrast"] = np.sum((df_pij.i - df_pij.j) ** 2.0 * df_pij.pij) - - # Dissimilarity - df_feat.loc[0, "Fcm_dissimilarity"] = np.sum(np.abs(df_pij.i - df_pij.j) * df_pij.pij) - - # Inverse difference - df_feat.loc[0, "Fcm_inv_diff"] = np.sum(df_pij.pij / (1.0 + np.abs(df_pij.i - df_pij.j))) - - # Inverse difference normalised - df_feat.loc[0, "Fcm_inv_diff_norm"] = np.sum(df_pij.pij / (1.0 + np.abs(df_pij.i - df_pij.j) / n_g)) - - # Inverse difference moment - df_feat.loc[0, "Fcm_inv_diff_mom"] = np.sum(df_pij.pij / (1.0 + (df_pij.i - df_pij.j) ** 2.0)) - - # Inverse difference moment normalised - df_feat.loc[0, "Fcm_inv_diff_mom_norm"] = np.sum(df_pij.pij / (1.0 + (df_pij.i - df_pij.j) - ** 2.0 / n_g ** 2.0)) - - # Inverse variance - df_sel = df_pij[df_pij.i != df_pij.j] - df_feat.loc[0, "Fcm_inv_var"] = np.sum(df_sel.pij / (df_sel.i - df_sel.j) ** 2.0) - del df_sel - - # Correlation - mu_marg = np.sum(df_pi.i * df_pi.pi) - var_marg = np.sum((df_pi.i - mu_marg) ** 2.0 * df_pi.pi) - - if var_marg == 0.0: - df_feat.loc[0, "Fcm_corr"] = 1.0 - else: - df_feat.loc[0, "Fcm_corr"] = 1.0 / var_marg * (np.sum(df_pij.i * df_pij.j * df_pij.pij) - mu_marg ** 2.0) - - del mu_marg, var_marg - - # Autocorrelation - df_feat.loc[0, "Fcm_auto_corr"] = np.sum(df_pij.i * df_pij.j * df_pij.pij) - - # Information correlation 1 - hxy = -np.sum(df_pij.pij * np.log2(df_pij.pij)) - hxy_1 = -np.sum(df_pij.pij * np.log2(df_pij.pi * df_pij.pj)) - hx = -np.sum(df_pi.pi * np.log2(df_pi.pi)) - if len(df_pij) == 1 or hx == 0.0: - df_feat.loc[0, "Fcm_info_corr1"] = 1.0 - else: - df_feat.loc[0, "Fcm_info_corr1"] = (hxy - hxy_1) / hx - del hxy, hxy_1, hx - - # Information correlation 2 - Note: iteration over combinations of i and j - hxy = - np.sum(df_pij.pij * np.log2(df_pij.pij)) - hxy_2 = - np.sum( - np.tile(df_pi.pi, len(df_pj)) * np.repeat(df_pj.pj, len(df_pi)) * \ - np.log2(np.tile(df_pi.pi, len(df_pj)) * np.repeat(df_pj.pj, len(df_pi))) - ) - - if hxy_2 < hxy: - df_feat.loc[0, "Fcm_info_corr2"] = 0 - else: - df_feat.loc[0, "Fcm_info_corr2"] = np.sqrt(1 - np.exp(-2.0 * (hxy_2 - hxy))) - del hxy, hxy_2 - - # Cluster tendency - m_u = np.sum(df_pi.i * df_pi.pi) - df_feat.loc[0, "Fcm_clust_tend"] = np.sum((df_pij.i + df_pij.j - 2 * m_u) ** 2.0 * df_pij.pij) - del m_u - - # Cluster shade - m_u = np.sum(df_pi.i * df_pi.pi) - df_feat.loc[0, "Fcm_clust_shade"] = np.sum((df_pij.i + df_pij.j - 2 * m_u) ** 3.0 * df_pij.pij) - del m_u - - # Cluster prominence - m_u = np.sum(df_pi.i * df_pi.pi) - df_feat.loc[0, "Fcm_clust_prom"] = np.sum((df_pij.i + df_pij.j - 2 * m_u) ** 4.0 * df_pij.pij) - - del df_pi, df_pj, df_pij, df_pimj, df_pipj, n_g - - # Update names - # df_feat.columns += self._parse_names() - - return df_feat - - def _parse_names(self) -> str: - """"Adds additional settings-related identifiers to each feature. - Not used currently, as the use of different settings for the - co-occurrence matrix is not supported. - - Returns: - str: String of the features indetifier. - """ - parse_str = "" - - # Add distance - parse_str += "_d" + str(np.round(self.distance, 1)) - - # Add spatial method - if self.spatial_method is not None: - parse_str += "_" + self.spatial_method - - # Add merge method - if self.merge_method is not None: - if self.merge_method == "average": - parse_str += "_avg" - if self.merge_method == "slice_merge": - parse_str += "_s_mrg" - if self.merge_method == "dir_merge": - parse_str += "_d_mrg" - if self.merge_method == "vol_merge": - parse_str += "_v_mrg" - - return parse_str diff --git a/MEDimage/biomarkers/gldzm.py b/MEDimage/biomarkers/gldzm.py deleted file mode 100755 index 0277ecb..0000000 --- a/MEDimage/biomarkers/gldzm.py +++ /dev/null @@ -1,523 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from typing import Dict, List, Union - -import numpy as np -import scipy.ndimage as sc -import skimage.measure as skim - - -def get_matrix(roi_only_int: np.ndarray, - mask: np.ndarray, - levels: Union[np.ndarray, List]) -> np.ndarray: - r""" - Computes Grey level distance zone matrix. - This matrix refers to "Grey level distance zone based features" (ID = VMDZ) - in the `IBSI1 reference manual `_. - - Args: - roi_only_int (ndarray): 3D volume, isotropically resampled, - quantized (e.g. n_g = 32, levels = [1, ..., n_g]), - with NaNs outside the region of interest. - mask (ndarray): Morphological ROI ``mask``. - levels (ndarray or List): Vector containing the quantized gray-levels - in the tumor region (or reconstruction ``levels`` of quantization). - - Returns: - ndarray: Grey level distance zone Matrix. - - Todo: - ``levels`` should be removed at some point, no longer needed if we always - quantize our volume such that ``levels = 1,2,3,4,...,max(quantized Volume)``. - So simply calculate ``levels = 1:max(roi_only(~isnan(roi_only(:))))`` - directly in this function. - - """ - - roi_only_int = roi_only_int.copy() - levels = levels.copy().astype("int") - morph_voxel_grid = mask.copy().astype(np.uint8) - - # COMPUTATION OF DISTANCE MAP - morph_voxel_grid = np.pad(morph_voxel_grid, - [1,1], - 'constant', - constant_values=0) - - # Computing the smallest ROI edge possible. - # Distances are determined in 3D - binary_struct = sc.generate_binary_structure(rank=3, connectivity=1) - perimeter = morph_voxel_grid - sc.binary_erosion(morph_voxel_grid, structure=binary_struct) - perimeter = perimeter[1:-1,1:-1,1:-1] # Removing the padding. - morph_voxel_grid = morph_voxel_grid[1:-1,1:-1,1:-1] # Removing the padding - - # +1 according to the definition of the IBSI - dist_map = sc.distance_transform_cdt(np.logical_not(perimeter), metric='cityblock') + 1 - - # INITIALIZATION - # Since levels is always defined as 1,2,3,4,...,max(quantized Volume) - n_g = np.size(levels) - level_temp = np.max(levels) + 1 - roi_only_int[np.isnan(roi_only_int)] = level_temp - # Since the ROI morph always encompasses ROI int, - # using the mask as defined from ROI morph does not matter since - # we want to find the maximal possible distance. - dist_init = np.max(dist_map[morph_voxel_grid == 1]) - gldzm = np.zeros((n_g,dist_init)) - - # COMPUTATION OF gldzm - temp = roi_only_int.copy().astype('int') - for i in range(1,n_g+1): - temp[roi_only_int!=levels[i-1]] = 0 - temp[roi_only_int==levels[i-1]] = 1 - conn_objects, n_zone = skim.label(temp,return_num = True) - for j in range(1,n_zone+1): - col = np.min(dist_map[conn_objects==j]).astype("int") - gldzm[i-1,col-1] = gldzm[i-1,col-1] + 1 - - # REMOVE UNECESSARY COLUMNS - stop = np.nonzero(np.sum(gldzm,0))[0][-1] - gldzm = np.delete(gldzm, range(stop+1, np.shape(gldzm)[1]), 1) - - return gldzm - -def extract_all(vol_int: np.ndarray, - mask_morph: np.ndarray, - gldzm: np.ndarray = None) -> Dict: - """Computes gldzm features. - This feature refers to "Grey level distance zone based features" (ID = VMDZ) - in the `IBSI1 reference manual `__. - - Args: - vol_int (np.ndarray): 3D volume, isotropically resampled, quantized (e.g. n_g = 32, levels = [1, ..., n_g]), - with NaNs outside the region of interest. - mask_morph (np.ndarray): Morphological ROI mask. - gldzm (np.ndarray, optional): array of the gray level distance zone matrix. Defaults to None. - - Returns: - Dict: dict of ``gldzm`` features - """ - gldzm_features = {'Fdzm_sde': [], - 'Fdzm_lde': [], - 'Fdzm_lgze': [], - 'Fdzm_hgze': [], - 'Fdzm_sdlge': [], - 'Fdzm_sdhge': [], - 'Fdzm_ldlge': [], - 'Fdzm_ldhge': [], - 'Fdzm_glnu': [], - 'Fdzm_glnu_norm': [], - 'Fdzm_zdnu': [], - 'Fdzm_zdnu_norm': [], - 'Fdzm_z_perc': [], - 'Fdzm_gl_var': [], - 'Fdzm_zd_var': [], - 'Fdzm_zd_entr': []} - - # Correct definition, without any assumption - levels = np.arange(1, np.max(vol_int[~np.isnan(vol_int[:])])+1) - - # GET THE gldzm MATRIX - if gldzm is None: - gldzm = get_matrix(vol_int, mask_morph, levels) - n_s = np.sum(gldzm) - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - s_z = np.shape(gldzm) # Size of gldzm - c_vect = range(1, s_z[1]+1) # Row vectors - r_vect = range(1, s_z[0]+1) # Column vectors - # Column and row indicators for each entry of the gldzm - c_mat, r_mat = np.meshgrid(c_vect, r_vect) - p_g = np.transpose(np.sum(gldzm, 1)) # Gray-Level Vector - p_d = np.sum(gldzm, 0) # Distance Zone Vector - - # COMPUTING TEXTURES - - # Small distance emphasis - gldzm_features['Fdzm_sde'] = (np.matmul(p_d, np.transpose(np.power(1.0/np.array(c_vect), 2)))) - - # Large distance emphasis - gldzm_features['Fdzm_lde'] = (np.matmul(p_d, np.transpose(np.power(np.array(c_vect), 2)))) - - # Low grey level zone emphasis - gldzm_features['Fdzm_lgze'] = np.matmul(p_g, np.transpose(np.power(1.0/np.array(r_vect), 2))) - - # High grey level zone emphasis - gldzm_features['Fdzm_hgze'] = np.matmul(p_g, np.transpose(np.power(np.array(r_vect), 2))) - - # Small distance low grey level emphasis - gldzm_features['Fdzm_sdlge'] = np.sum(np.sum(gldzm*(np.power(1.0/r_mat, 2))*(np.power(1.0/c_mat, 2)))) - - # Small distance high grey level emphasis - gldzm_features['Fdzm_sdhge'] = np.sum(np.sum(gldzm*(np.power(r_mat, 2))*(np.power(1.0/c_mat, 2)))) - - # Large distance low grey level emphasis - gldzm_features['Fdzm_ldlge'] = np.sum(np.sum(gldzm*(np.power(1.0/r_mat, 2))*(np.power(c_mat, 2)))) - - # Large distance high grey level emphasis - gldzm_features['Fdzm_ldhge'] = np.sum(np.sum(gldzm*(np.power(r_mat, 2))*(np.power(c_mat, 2)))) - - # Gray level non-uniformity - gldzm_features['Fdzm_glnu'] = np.sum(np.power(p_g, 2)) * n_s - - # Gray level non-uniformity normalised - gldzm_features['Fdzm_glnu_norm'] = np.sum(np.power(p_g, 2)) - - # Zone distance non-uniformity - gldzm_features['Fdzm_zdnu'] = np.sum(np.power(p_d, 2)) * n_s - - # Zone distance non-uniformity normalised - gldzm_features['Fdzm_zdnu_norm'] = np.sum(np.power(p_d, 2)) - - # Zone percentage - # Must change the original definition here. - gldzm_features['Fdzm_z_perc'] = n_s / np.sum(~np.isnan(vol_int[:])) - - # Grey level variance - temp = r_mat * gldzm - u = np.sum(temp) - temp = (np.power(r_mat-u, 2)) * gldzm - gldzm_features['Fdzm_gl_var'] = np.sum(temp) - - # Zone distance variance - temp = c_mat * gldzm - u = np.sum(temp) - temp = (np.power(c_mat-u, 2)) * gldzm - temp = (np.power(c_mat - u, 2)) * gldzm - gldzm_features['Fdzm_zd_var'] = np.sum(temp) - - # Zone distance entropy - val_pos = gldzm[np.nonzero(gldzm)] - temp = val_pos * np.log2(val_pos) - gldzm_features['Fdzm_zd_entr'] = -np.sum(temp) - - return gldzm_features - -def get_single_matrix(vol_int: np.ndarray, mask_morph: np.ndarray) -> np.ndarray: - """Computes gray level distance zone matrix in order to compute the single features. - - Args: - vol_int (ndarray): 3D volume, isotropically resampled, - quantized (e.g. n_g = 32, levels = [1, ..., n_g]), - with NaNs outside the region of interest. - mask_morph (ndarray): Morphological ROI mask. - - Returns: - ndarray: gldzm features. - """ - # Correct definition, without any assumption - levels = np.arange(1, np.max(vol_int[~np.isnan(vol_int[:])])+1) - - # GET THE gldzm MATRIX - gldzm = get_matrix(vol_int, mask_morph, levels) - - return gldzm - -def sde(gldzm: np.ndarray) -> float: - """Computes small distance emphasis feature. - This feature refers to "Fdzm_sde" (ID = 0GBI) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the small distance emphasis feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - s_z = np.shape(gldzm) # Size of gldzm - c_vect = range(1, s_z[1]+1) # Row vectors - p_d = np.sum(gldzm, 0) # Distance Zone Vector - - # Small distance emphasis - return (np.matmul(p_d, np.transpose(np.power(1.0 / np.array(c_vect), 2)))) - -def lde(gldzm: np.ndarray) -> float: - """Computes large distance emphasis feature. - This feature refers to "Fdzm_lde" (ID = MB4I) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the large distance emphasis feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - s_z = np.shape(gldzm) # Size of gldzm - c_vect = range(1, s_z[1]+1) # Row vectors - p_d = np.sum(gldzm, 0) # Distance Zone Vector - - #Large distance emphasis - return (np.matmul(p_d, np.transpose(np.power(np.array(c_vect), 2)))) - -def lgze(gldzm: np.ndarray) -> float: - """Computes distance matrix low grey level zone emphasis feature. - This feature refers to "Fdzm_lgze" (ID = S1RA) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the low grey level zone emphasis feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - s_z = np.shape(gldzm) # Size of gldzm - r_vect = range(1, s_z[0]+1) # Column vectors - p_g = np.transpose(np.sum(gldzm, 1)) # Gray-Level Vector - - #Low grey level zone emphasisphasis - return np.matmul(p_g, np.transpose(np.power(1.0/np.array(r_vect), 2))) - -def hgze(gldzm: np.ndarray) -> float: - """Computes distance matrix high grey level zone emphasis feature. - This feature refers to "Fdzm_hgze" (ID = K26C) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the high grey level zone emphasis feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - s_z = np.shape(gldzm) # Size of gldzm - r_vect = range(1, s_z[0]+1) # Column vectors - p_g = np.transpose(np.sum(gldzm, 1)) # Gray-Level Vector - - #Low grey level zone emphasisphasis - return np.matmul(p_g, np.transpose(np.power(np.array(r_vect), 2))) - -def sdlge(gldzm: np.ndarray) -> float: - """Computes small distance low grey level emphasis feature. - This feature refers to "Fdzm_sdlge" (ID = RUVG) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the low grey level emphasis feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - s_z = np.shape(gldzm) # Size of gldzm - c_vect = range(1, s_z[1]+1) # Row vectors - r_vect = range(1, s_z[0]+1) # Column vectors - c_mat, r_mat = np.meshgrid(c_vect, r_vect) # Column and row indicators for each entry of the gldzm - - #Low grey level zone emphasisphasis - return np.sum(np.sum(gldzm*(np.power(1.0/r_mat, 2))*(np.power(1.0/c_mat, 2)))) - -def sdhge(gldzm: np.ndarray) -> float: - """Computes small distance high grey level emphasis feature. - This feature refers to "Fdzm_sdhge" (ID = DKNJ) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the distance high grey level emphasis feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - s_z = np.shape(gldzm) # Size of gldzm - c_vect = range(1, s_z[1]+1) # Row vectors - r_vect = range(1, s_z[0]+1) # Column vectors - c_mat, r_mat = np.meshgrid(c_vect, r_vect) # Column and row indicators for each entry of the gldzm - - #High grey level zone emphasisphasis - return np.sum(np.sum(gldzm*(np.power(r_mat, 2))*(np.power(1.0/c_mat, 2)))) - -def ldlge(gldzm: np.ndarray) -> float: - """Computes large distance low grey level emphasis feature. - This feature refers to "Fdzm_ldlge" (ID = A7WM) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the low grey level emphasis feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - s_z = np.shape(gldzm) # Size of gldzm - c_vect = range(1, s_z[1]+1) # Row vectors - r_vect = range(1, s_z[0]+1) # Column vectors - c_mat, r_mat = np.meshgrid(c_vect, r_vect) # Column and row indicators for each entry of the gldzm - - #Large distance low grey levels emphasis - return np.sum(np.sum(gldzm*(np.power(1.0/r_mat, 2))*(np.power(c_mat, 2)))) - -def ldhge(gldzm: np.ndarray) -> float: - """Computes large distance high grey level emphasis feature. - This feature refers to "Fdzm_ldhge" (ID = KLTH) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the high grey level emphasis feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - s_z = np.shape(gldzm) # Size of gldzm - c_vect = range(1, s_z[1]+1) # Row vectors - r_vect = range(1, s_z[0]+1) # Column vectors - c_mat, r_mat = np.meshgrid(c_vect, r_vect) # Column and row indicators for each entry of the gldzm - - #Large distance high grey levels emphasis - return np.sum(np.sum(gldzm*(np.power( - r_mat, 2))*(np.power(c_mat, 2)))) - -def glnu(gldzm: np.ndarray) -> float: - """Computes distance zone matrix gray level non-uniformity - This feature refers to "Fdzm_glnu" (ID = VFT7) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the gray level non-uniformity feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - p_g = np.transpose(np.sum(gldzm, 1)) # Gray-Level Vector - n_s = np.sum(gldzm) - - #Gray level non-uniformity - return np.sum(np.power(p_g, 2)) * n_s - -def glnu_norm(gldzm: np.ndarray) -> float: - """Computes distance zone matrix gray level non-uniformity normalised - This feature refers to "Fdzm_glnu_norm" (ID = 7HP3) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the gray level non-uniformity normalised feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - p_g = np.transpose(np.sum(gldzm, 1)) # Gray-Level Vector - - #Gray level non-uniformity normalised - return np.sum(np.power(p_g, 2)) - -def zdnu(gldzm: np.ndarray) -> float: - """Computes zone distance non-uniformity - This feature refers to "Fdzm_zdnu" (ID = V294) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the zone distance non-uniformity feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - p_d = np.sum(gldzm, 0) # Distance Zone Vector - n_s = np.sum(gldzm) - - #Zone distance non-uniformity - return np.sum(np.power(p_d, 2)) * n_s - -def zdnu_norm(gldzm: np.ndarray) -> float: - """Computes zone distance non-uniformity normalised - This feature refers to "Fdzm_zdnu_norm" (ID = IATH) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the zone distance non-uniformity normalised feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - p_d = np.sum(gldzm, 0) # Distance Zone Vector - - #Zone distance non-uniformity normalised - return np.sum(np.power(p_d, 2)) - -def z_perc(gldzm, vol_int): - """Computes zone percentage - This feature refers to "Fdzm_z_perc" (ID = VIWW) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the zone percentage feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - n_s = np.sum(gldzm) - - #Zone percentage - return n_s/np.sum(~np.isnan(vol_int[:])) - -def gl_var(gldzm: np.ndarray) -> float: - """Computes grey level variance - This feature refers to "Fdzm_gl_var" (ID = QK93) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the grey level variance feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - s_z = np.shape(gldzm) # Size of gldzm - c_vect = range(1, s_z[1]+1) # Row vectors - r_vect = range(1, s_z[0]+1) # Column vectors - _, r_mat = np.meshgrid(c_vect, r_vect) # Column and row indicators for each entry of the gldzm - temp = r_mat * gldzm - u = np.sum(temp) - temp = (np.power(r_mat-u, 2)) * gldzm - - #Grey level variance - return np.sum(temp) - -def zd_var(gldzm: np.ndarray) -> float: - """Computes zone distance variance - This feature refers to "Fdzm_zd_var" (ID = 7WT1) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the zone distance variance feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - s_z = np.shape(gldzm) # Size of gldzm - c_vect = range(1, s_z[1]+1) # Row vectors - r_vect = range(1, s_z[0]+1) # Column vectors - c_mat, _ = np.meshgrid(c_vect, r_vect) # Column and row indicators for each entry of the gldzm - temp = c_mat * gldzm - u = np.sum(temp) - temp = (np.power(c_mat-u, 2)) * gldzm - - #Zone distance variance - return np.sum(temp) - -def zd_entr(gldzm: np.ndarray) -> float: - """Computes zone distance entropy - This feature refers to "Fdzm_zd_entr" (ID = GBDU) in - the `IBSI1 reference manual `__. - - Args: - gldzm (ndarray): array of the gray level distance zone matrix - - Returns: - float: the zone distance entropy feature - """ - gldzm = gldzm / np.sum(gldzm) # Normalization of gldzm - val_pos = gldzm[np.nonzero(gldzm)] - temp = val_pos * np.log2(val_pos) - - #Zone distance entropy - return -np.sum(temp) diff --git a/MEDimage/biomarkers/glrlm.py b/MEDimage/biomarkers/glrlm.py deleted file mode 100755 index 5b2edbb..0000000 --- a/MEDimage/biomarkers/glrlm.py +++ /dev/null @@ -1,1315 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from copy import deepcopy -from typing import Dict, List, Union - -import numpy as np -import pandas as pd - -from ..utils.textureTools import (coord2index, get_neighbour_direction, - is_list_all_none) - - -def extract_all(vol: np.ndarray, - dist_correction: Union[bool, str]=None, - merge_method: str="vol_merge") -> Dict: - """Computes glrlm features. - This features refer to Grey Level Run Length Matrix family in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, isotropically resampled, quantized - (e.g. n_g = 32, levels = [1, ..., n_g]), with NaNs outside the region - of interest. - dist_correction (Union[bool, str], optional): Set this variable to true in order to use - discretization length difference corrections as used - by the `Institute of Physics and Engineering in - Medicine `__. - Set this variable to false to replicate IBSI results. - Or use string and specify the norm for distance weighting. - Weighting is only performed if this argument is - "manhattan", "euclidean" or "chebyshev". - merge_method (str, optional): merging method which determines how features are - calculated. One of "average", "slice_merge", "dir_merge" and "vol_merge". - Note that not all combinations of spatial and merge method are valid. - method (str, optional): Either 'old' (deprecated) or 'new' (faster) method. - - Returns: - Dict: Dict of the glrlm features. - - Raises: - ValueError: - If `method` is not 'old' or 'new'. - - Todo: - * Enable calculation of RLM features using different spatial methods (2d, 2.5d, 3d) - * Enable calculation of RLM features using different RLM distance settings - * Enable calculation of RLM features for different merge methods (average, slice_merge, dir_merge, vol_merge) - * Provide the range of discretised intensities from a calling function and pass to get_rlm_features. - * Test if dist_correction works as expected. - """ - - rlm_features = get_rlm_features(vol=vol, - merge_method=merge_method, - dist_weight_norm=dist_correction) - - return rlm_features - -def get_rlm_features(vol: np.ndarray, - glrlm_spatial_method: str="3d", - merge_method: str="vol_merge", - dist_weight_norm: Union[bool, str]=None) -> Dict: - """Extract run length matrix-based features from the intensity roi mask. - - Note: - This code was adapted from the in-house radiomics software created at - OncoRay, Dresden, Germany. - - Args: - vol (ndarray): volume with discretised intensities as 3D numpy array (x, y, z). - glrlm_spatial_method (str, optional): spatial method which determines the way - co-occurrence matrices are calculated and how features are determined. - must be "2d", "2.5d" or "3d". - merge_method (str, optional): merging method which determines how features are - calculated. One of "average", "slice_merge", "dir_merge" and "vol_merge". - Note that not all combinations of spatial and merge method are valid. - dist_weight_norm (Union[bool, str], optional): norm for distance weighting. Weighting is only - performed if this argument is either "manhattan", - "euclidean", "chebyshev" or bool. - - Returns: - Dict: Dict of the length matrix features. - """ - if type(glrlm_spatial_method) is not list: - glrlm_spatial_method = [glrlm_spatial_method] - - if type(merge_method) is not list: - merge_method = [merge_method] - - if type(dist_weight_norm) is bool: - if dist_weight_norm: - dist_weight_norm = "euclidean" - - # Get the roi in tabular format - img_dims = vol.shape - index_id = np.arange(start=0, stop=vol.size) - coords = np.unravel_index(indices=index_id, shape=img_dims) # Convert flat index into coordinate - df_img = pd.DataFrame({"index_id": index_id, - "g": np.ravel(vol), - "x": coords[0], - "y": coords[1], - "z": coords[2], - "roi_int_mask": np.ravel(np.isfinite(vol))}) - - # Generate an empty feature list - feat_list = [] - - # Iterate over spatial arrangements - for ii_spatial in glrlm_spatial_method: - # Initiate list of rlm objects - rlm_list = [] - - # Perform 2D analysis - if ii_spatial.lower() in ["2d", "2.5d"]: - # Iterate over slices - for ii_slice in np.arange(0, img_dims[2]): - # Get neighbour direction and iterate over neighbours - nbrs = get_neighbour_direction(d=1, - distance="chebyshev", - centre=False, - complete=False, - dim3=False) - - for ii_direction in np.arange(0, np.shape(nbrs)[1]): - # Add rlm matrices to list - rlm_list += [RunLengthMatrix(direction=nbrs[:, ii_direction], - direction_id=ii_direction, - spatial_method=ii_spatial.lower(), - img_slice=ii_slice)] - - # Perform 3D analysis - if ii_spatial.lower() == "3d": - # Get neighbour direction and iterate over neighbours - nbrs = get_neighbour_direction(d=1, - distance="chebyshev", - centre=False, - complete=False, - dim3=True) - - for ii_direction in np.arange(0, np.shape(nbrs)[1]): - # Add rlm matrices to list - rlm_list += [RunLengthMatrix(direction=nbrs[:, ii_direction], - direction_id=ii_direction, - spatial_method=ii_spatial.lower())] - - # Calculate run length matrices - for rlm in rlm_list: - rlm.calculate_rlm_matrix(df_img=df_img, - img_dims=img_dims, - dist_weight_norm=dist_weight_norm) - - # Merge matrices according to the given method - for merge_method in merge_method: - upd_list = combine_rlm_matrices(rlm_list=rlm_list, - merge_method=merge_method, - spatial_method=ii_spatial.lower()) - - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is None: - continue - - # Calculate features - feat_run_list = [] - for rlm in upd_list: - feat_run_list += [rlm.calculate_rlm_features()] - - # Average feature values - feat_list += [pd.concat(feat_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary - df_feat = pd.concat(feat_list, axis=1).to_dict(orient="records")[0] - - return df_feat - -def get_matrix(vol: np.ndarray, - glrlm_spatial_method: str="3d", - merge_method: str="vol_merge", - dist_weight_norm: Union[bool, str]=None) -> np.ndarray: - """Extract run length matrix-based features from the intensity roi mask. - - Note: - This code was adapted from the in-house radiomics software created at - OncoRay, Dresden, Germany. - - Args: - vol (ndarray): volume with discretised intensities as 3D numpy array (x, y, z). - glrlm_spatial_method (str, optional): spatial method which determines the way - co-occurrence matrices are calculated and how features are determined. - must be "2d", "2.5d" or "3d". - merge_method (str, optional): merging method which determines how features are - calculated. One of "average", "slice_merge", "dir_merge" and "vol_merge". - Note that not all combinations of spatial and merge method are valid. - dist_weight_norm (Union[bool, str], optional): norm for distance weighting. Weighting is only - performed if this argument is either "manhattan", - "euclidean", "chebyshev" or bool. - - Returns: - ndarray: Dict of the length matrix features. - """ - if type(glrlm_spatial_method) is not list: - glrlm_spatial_method = [glrlm_spatial_method] - - if type(merge_method) is not list: - merge_method = [merge_method] - - if type(dist_weight_norm) is bool: - if dist_weight_norm: - dist_weight_norm = "euclidean" - - # Get the roi in tabular format - img_dims = vol.shape - index_id = np.arange(start=0, stop=vol.size) - coords = np.unravel_index(indices=index_id, shape=img_dims) # Convert flat index into coordinate - df_img = pd.DataFrame({"index_id": index_id, - "g": np.ravel(vol), - "x": coords[0], - "y": coords[1], - "z": coords[2], - "roi_int_mask": np.ravel(np.isfinite(vol))}) - - # Iterate over spatial arrangements - for ii_spatial in glrlm_spatial_method: - # Initiate list of rlm objects - rlm_list = [] - - # Perform 2D analysis - if ii_spatial.lower() in ["2d", "2.5d"]: - # Iterate over slices - for ii_slice in np.arange(0, img_dims[2]): - # Get neighbour direction and iterate over neighbours - nbrs = get_neighbour_direction(d=1, - distance="chebyshev", - centre=False, - complete=False, - dim3=False) - - for ii_direction in np.arange(0, np.shape(nbrs)[1]): - # Add rlm matrices to list - rlm_list += [RunLengthMatrix(direction=nbrs[:, ii_direction], - direction_id=ii_direction, - spatial_method=ii_spatial.lower(), - img_slice=ii_slice)] - - # Perform 3D analysis - if ii_spatial.lower() == "3d": - # Get neighbour direction and iterate over neighbours - nbrs = get_neighbour_direction(d=1, - distance="chebyshev", - centre=False, - complete=False, - dim3=True) - - for ii_direction in np.arange(0, np.shape(nbrs)[1]): - # Add rlm matrices to list - rlm_list += [RunLengthMatrix(direction=nbrs[:, ii_direction], - direction_id=ii_direction, - spatial_method=ii_spatial.lower())] - - # Calculate run length matrices - for rlm in rlm_list: - rlm.calculate_rlm_matrix(df_img=df_img, - img_dims=img_dims, - dist_weight_norm=dist_weight_norm) - - # Merge matrices according to the given method - for merge_method in merge_method: - upd_list = combine_rlm_matrices(rlm_list=rlm_list, - merge_method=merge_method, - spatial_method=ii_spatial.lower()) - - return upd_list - -def combine_rlm_matrices(rlm_list: list, - merge_method: str, - spatial_method: str)-> List: - """Merges run length matrices prior to feature calculation. - - Note: - This code was adapted from the in-house radiomics software created at - OncoRay, Dresden, Germany. - - Args: - rlm_list (List): List of RunLengthMatrix objects. - merge_method (str): Merging method which determines how features are calculated. - One of "average", "slice_merge", "dir_merge" and "vol_merge". Note that not all - combinations of spatial and merge method are valid. - spatial_method (str): Spatial method which determines the way co-occurrence - matrices are calculated and how features are determined. One of "2d", "2.5d" - or "3d". - - Returns: - List[CooccurrenceMatrix]: List of one or more merged RunLengthMatrix objects. - """ - # Initiate empty list - use_list = [] - - # For average features over direction, maintain original run length matrices - if merge_method == "average" and spatial_method in ["2d", "3d"]: - # Make copy of rlm_list - for rlm in rlm_list: - use_list += [rlm._copy()] - - # Set merge method to average - for rlm in use_list: - rlm.merge_method = "average" - - # Merge rlms within each slice - elif merge_method == "slice_merge" and spatial_method == "2d": - # Find slice_ids - slice_id = [] - for rlm in rlm_list: - slice_id += [rlm.slice] - - # Iterate over unique slice_ids - for ii_slice in np.unique(slice_id): - slice_rlm_id = np.squeeze(np.where(slice_id == ii_slice)) - - # Select all matrices within the slice - sel_matrix_list = [] - for rlm_id in slice_rlm_id: - sel_matrix_list += [rlm_list[rlm_id].matrix] - - # Check if any matrix has been created for the currently selected slice - if is_list_all_none(sel_matrix_list): - # No matrix was created - use_list += [RunLengthMatrix(direction=None, - direction_id=None, - spatial_method=spatial_method, - img_slice=ii_slice, - merge_method=merge_method, - matrix=None, - n_v=0.0)] - else: - # Merge matrices within the slice - merge_rlm = pd.concat(sel_matrix_list, axis=0) - merge_rlm = merge_rlm.groupby(by=["i", "r"]).sum().reset_index() - - # Update the number of voxels within the merged slice - merge_n_v = 0.0 - for rlm_id in slice_rlm_id: - merge_n_v += rlm_list[rlm_id].n_v - - # Create new run length matrix - use_list += [RunLengthMatrix(direction=None, - direction_id=None, - spatial_method=spatial_method, - img_slice=ii_slice, - merge_method=merge_method, - matrix=merge_rlm, - n_v=merge_n_v)] - - # Merge rlms within each slice - elif merge_method == "dir_merge" and spatial_method == "2.5d": - # Find direction ids - dir_id = [] - for rlm in rlm_list: - dir_id += [rlm.direction_id] - - # Iterate over unique dir_ids - for ii_dir in np.unique(dir_id): - dir_rlm_id = np.squeeze(np.where(dir_id == ii_dir)) - - # Select all matrices with the same direction - sel_matrix_list = [] - for rlm_id in dir_rlm_id: - sel_matrix_list += [rlm_list[rlm_id].matrix] - - # Check if any matrix has been created for the currently selected direction - if is_list_all_none(sel_matrix_list): - # No matrix was created - use_list += [RunLengthMatrix(direction=rlm_list[dir_rlm_id[0]].direction, - direction_id=ii_dir, - spatial_method=spatial_method, - img_slice=None, - merge_method=merge_method, - matrix=None, - n_v=0.0)] - else: - # Merge matrices with the same direction - merge_rlm = pd.concat(sel_matrix_list, axis=0) - merge_rlm = merge_rlm.groupby(by=["i", "r"]).sum().reset_index() - - # Update the number of voxels within the merged slice - merge_n_v = 0.0 - for rlm_id in dir_rlm_id: - merge_n_v += rlm_list[rlm_id].n_v - - # Create new run length matrix - use_list += [RunLengthMatrix(direction=rlm_list[dir_rlm_id[0]].direction, - direction_id=ii_dir, - spatial_method=spatial_method, - img_slice=None, - merge_method=merge_method, - matrix=merge_rlm, - n_v=merge_n_v)] - - # Merge all rlms into a single representation - elif merge_method == "vol_merge" and spatial_method in ["2.5d", "3d"]: - # Select all matrices within the slice - sel_matrix_list = [] - for rlm_id in np.arange(len(rlm_list)): - sel_matrix_list += [rlm_list[rlm_id].matrix] - - # Check if any matrix has been created - if is_list_all_none(sel_matrix_list): - # No matrix was created - use_list += [RunLengthMatrix(direction=None, - direction_id=None, - spatial_method=spatial_method, - img_slice=None, - merge_method=merge_method, - matrix=None, - n_v=0.0)] - else: - # Merge run length matrices - merge_rlm = pd.concat(sel_matrix_list, axis=0) - merge_rlm = merge_rlm.groupby(by=["i", "r"]).sum().reset_index() - - # Update the number of voxels - merge_n_v = 0.0 - for rlm_id in np.arange(len(rlm_list)): - merge_n_v += rlm_list[rlm_id].n_v - - # Create new run length matrix - use_list += [RunLengthMatrix(direction=None, - direction_id=None, - spatial_method=spatial_method, - img_slice=None, - merge_method=merge_method, - matrix=merge_rlm, - n_v=merge_n_v)] - - else: - use_list = None - - # Return to new rlm list to calling function - return use_list - -class RunLengthMatrix: - """Class that contains a single run length matrix. - - Note: - This code was adapted from the in-house radiomics software created at - OncoRay, Dresden, Germany. - - Args: - direction (ndarray): Direction along which neighbouring voxels are found. - direction_id (int): Direction index to identify unique direction vectors. - spatial_method (str): Spatial method used to calculate the co-occurrence - matrix: "2d", "2.5d" or "3d". - img_slice (ndarray, optional): Corresponding slice index (only if the - co-occurrence matrix corresponds to a 2d image slice). - merge_method (str, optional): Method for merging the co-occurrence matrix - with other co-occurrence matrices. - matrix (pandas.DataFrame, optional): The actual co-occurrence matrix in - sparse format (row, column, count). - n_v (int, optional): The number of voxels in the volume. - - Attributes: - direction (ndarray): Direction along which neighbouring voxels are found. - direction_id (int): Direction index to identify unique direction vectors. - spatial_method (str): Spatial method used to calculate the co-occurrence - matrix: "2d", "2.5d" or "3d". - img_slice (ndarray): Corresponding slice index (only if the co-occurrence - matrix corresponds to a 2d image slice). - merge_method (str): Method for merging the co-occurrence matrix with other - co-occurrence matrices. - matrix (pandas.DataFrame): The actual co-occurrence matrix in sparse format - (row, column, count). - n_v (int): The number of voxels in the volume. - """ - - def __init__(self, - direction: np.ndarray, - direction_id: int, - spatial_method: str, - img_slice: np.ndarray=None, - merge_method: str=None, - matrix: pd.DataFrame=None, - n_v: int=None) -> None: - """ - Initialising function for a new run length matrix - """ - - # Direction and slice for which the current matrix is extracted - self.direction = direction - self.direction_id = direction_id - self.img_slice = img_slice - - # Spatial analysis method (2d, 2.5d, 3d) and merge method (average, slice_merge, dir_merge, vol_merge) - self.spatial_method = spatial_method - - # Place holders - self.merge_method = merge_method - self.matrix = matrix - self.n_v = n_v - - def _copy(self): - """Returns a copy of the RunLengthMatrix object.""" - - return deepcopy(self) - - def _set_empty(self): - """Creates an empty RunLengthMatrix""" - self.n_v = 0 - self.matrix = None - - def calculate_rlm_matrix(self, - df_img: pd.DataFrame, - img_dims: np.ndarray, - dist_weight_norm: str) -> None: - """Function that calculates a run length matrix for the settings provided - during initialisation and the input image. - - Args: - df_img (pandas.DataFrame): Data table containing image intensities, x, y and z coordinates, - and mask labels corresponding to voxels in the volume. - img_dims (ndarray, List[float]): Dimensions of the image volume. - dist_weight_norm (str): Norm for distance weighting. Weighting is only - performed if this parameter is either "manhattan", "euclidean" or "chebyshev". - - Returns: - None. Assigns the created image table (rlm matrix) to the `matrix` attribute. - - Raises: - ValueError: - If `self.spatial_method` is not "2d", "2.5d" or "3d". - Also, if ``dist_weight_norm`` is not "manhattan", "euclidean" or "chebyshev". - """ - # Check if the df_img actually exists - if df_img is None: - self._set_empty() - return - - # Check if the roi contains any masked voxels. If this is not the case, don't construct the glrlm. - if not np.any(df_img.roi_int_mask): - self._set_empty() - return - - # Create local copies of the image table - if self.spatial_method == "3d": - df_rlm = deepcopy(df_img) - elif self.spatial_method in ["2d", "2.5d"]: - df_rlm = deepcopy(df_img[df_img.z == self.img_slice]) - df_rlm["index_id"] = np.arange(0, len(df_rlm)) - df_rlm["z"] = 0 - df_rlm = df_rlm.reset_index(drop=True) - else: - raise ValueError("The spatial method for grey level run length matrices \ - should be one of \"2d\", \"2.5d\" or \"3d\".") - - # Set grey level of voxels outside ROI to NaN - df_rlm.loc[df_rlm.roi_int_mask == False, "g"] = np.nan - - # Set the number of voxels - self.n_v = np.sum(df_rlm.roi_int_mask.values) - - # Determine update index number for direction - if (self.direction[2] + self.direction[1] * img_dims[2] + self.direction[0] * img_dims[2] * img_dims[1]) >= 0: - curr_dir = self.direction - else: - curr_dir = - self.direction - - # Step size - ind_update = curr_dir[2] + curr_dir[1] * img_dims[2] + curr_dir[0] * img_dims[2] * img_dims[1] - - # Generate information concerning segments - n_seg = ind_update # Number of segments - - # Check if the number of segments is greater than one - if n_seg == 0: - self._set_empty() - return - - seg_len = (len(df_rlm) - 1) // ind_update + 1 # Nominal segment length - trans_seg_len = np.tile([seg_len - 1], reps=n_seg) # Initial segment length for transitions (nominal length-1) - full_len_trans = n_seg - n_seg*seg_len + len(df_rlm) # Number of full segments - trans_seg_len[0:full_len_trans] += 1 # Update full segments - - # Create transition vector - trans_vec = np.tile(np.arange(start=0, stop=len(df_rlm), step=ind_update), reps=ind_update) - trans_vec += np.repeat(np.arange(start=0, stop=n_seg), repeats=seg_len) - trans_vec = trans_vec[trans_vec < len(df_rlm)] - - # Determine valid transitions - to_index = coord2index(x=df_rlm.x.values + curr_dir[0], - y=df_rlm.y.values + curr_dir[1], - z=df_rlm.z.values + curr_dir[2], - dims=img_dims) - - # Determine which transitions are valid - end_ind = np.nonzero(to_index[trans_vec] < 0)[0] # Find transitions that form an endpoints - - # Get an interspersed array of intensities. Runs are broken up by np.nan - intensities = np.insert(df_rlm.g.values[trans_vec], end_ind + 1, np.nan) - - # Determine run length start and end indices - rle_end = np.array(np.append(np.where(intensities[1:] != intensities[:-1]), len(intensities) - 1)) - rle_start = np.cumsum(np.append(0, np.diff(np.append(-1, rle_end))))[:-1] - - # Generate dataframe - df_rltable = pd.DataFrame({"i": intensities[rle_start], - "r": rle_end - rle_start + 1}) - df_rltable = df_rltable.loc[~np.isnan(df_rltable.i), :] - df_rltable = df_rltable.groupby(by=["i", "r"]).size().reset_index(name="n") - - if dist_weight_norm in ["manhattan", "euclidean", "chebyshev"]: - if dist_weight_norm == "manhattan": - weight = sum(abs(self.direction)) - elif dist_weight_norm == "euclidean": - weight = np.sqrt(sum(np.power(self.direction, 2.0))) - elif dist_weight_norm == "chebyshev": - weight = np.max(abs(self.direction)) - df_rltable.n /= weight - - # Add matrix to object - self.matrix = df_rltable - - def calculate_rlm_features(self) -> pd.DataFrame: - """Computes run length matrix features for the current run length matrix. - - Returns: - pandas.DataFrame: Data frame with values for each feature. - """ - # Create feature table - feat_names = ["Frlm_sre", - "Frlm_lre", - "Frlm_lgre", - "Frlm_hgre", - "Frlm_srlge", - "Frlm_srhge", - "Frlm_lrlge", - "Frlm_lrhge", - "Frlm_glnu", - "Frlm_glnu_norm", - "Frlm_rlnu", - "Frlm_rlnu_norm", - "Frlm_r_perc", - "Frlm_gl_var", - "Frlm_rl_var", - "Frlm_rl_entr"] - - df_feat = pd.DataFrame(np.full(shape=(1, len(feat_names)), fill_value=np.nan)) - df_feat.columns = feat_names - - # Don't return data for empty slices or slices without a good matrix - if self.matrix is None: - # Update names - # df_feat.columns += self._parse_feature_names() - return df_feat - elif len(self.matrix) == 0: - # Update names - # df_feat.columns += self._parse_feature_names() - return df_feat - - # Create local copy of the run length matrix and set column names - df_rij = deepcopy(self.matrix) - df_rij.columns = ["i", "j", "rij"] - - # Sum over grey levels - df_ri = df_rij.groupby(by="i")["rij"].agg(np.sum).reset_index().rename(columns={"rij": "ri"}) - - # Sum over run lengths - df_rj = df_rij.groupby(by="j")["rij"].agg(np.sum).reset_index().rename(columns={"rij": "rj"}) - - # Constant definitions - n_s = np.sum(df_rij.rij) * 1.0 # Number of runs - n_v = self.n_v * 1.0 # Number of voxels - - ############################################## - ###### glrlm features ###### - ############################################## - # Short runs emphasis - df_feat.loc[0, "Frlm_sre"] = np.sum(df_rj.rj / df_rj.j ** 2.0) / n_s - - # Long runs emphasis - df_feat.loc[0, "Frlm_lre"] = np.sum(df_rj.rj * df_rj.j ** 2.0) / n_s - - # Grey level non-uniformity - df_feat.loc[0, "Frlm_glnu"] = np.sum(df_ri.ri ** 2.0) / n_s - - # Grey level non-uniformity, normalised - df_feat.loc[0, "Frlm_glnu_norm"] = np.sum(df_ri.ri ** 2.0) / n_s ** 2.0 - - # Run length non-uniformity - df_feat.loc[0, "Frlm_rlnu"] = np.sum(df_rj.rj ** 2.0) / n_s - - # Run length non-uniformity, normalised - df_feat.loc[0, "Frlm_rlnu_norm"] = np.sum(df_rj.rj ** 2.0) / n_s ** 2.0 - - # Run percentage - df_feat.loc[0, "Frlm_r_perc"] = n_s / n_v - - # Low grey level run emphasis - df_feat.loc[0, "Frlm_lgre"] = np.sum(df_ri.ri / df_ri.i ** 2.0) / n_s - - # High grey level run emphasis - df_feat.loc[0, "Frlm_hgre"] = np.sum(df_ri.ri * df_ri.i ** 2.0) / n_s - - # Short run low grey level emphasis - df_feat.loc[0, "Frlm_srlge"] = np.sum(df_rij.rij / (df_rij.i * df_rij.j) ** 2.0) / n_s - - # Short run high grey level emphasis - df_feat.loc[0, "Frlm_srhge"] = np.sum(df_rij.rij * df_rij.i ** 2.0 / df_rij.j ** 2.0) / n_s - - # Long run low grey level emphasis - df_feat.loc[0, "Frlm_lrlge"] = np.sum(df_rij.rij * df_rij.j ** 2.0 / df_rij.i ** 2.0) / n_s - - # Long run high grey level emphasis - df_feat.loc[0, "Frlm_lrhge"] = np.sum(df_rij.rij * df_rij.i ** 2.0 * df_rij.j ** 2.0) / n_s - - # Grey level variance - mu = np.sum(df_rij.rij * df_rij.i) / n_s - df_feat.loc[0, "Frlm_gl_var"] = np.sum((df_rij.i - mu) ** 2.0 * df_rij.rij) / n_s - - # Run length variance - mu = np.sum(df_rij.rij * df_rij.j) / n_s - df_feat.loc[0, "Frlm_rl_var"] = np.sum((df_rij.j - mu) ** 2.0 * df_rij.rij) / n_s - - # Zone size entropy - df_feat.loc[0, "Frlm_rl_entr"] = - np.sum(df_rij.rij * np.log2(df_rij.rij / n_s)) / n_s - - return df_feat - - def calculate_feature(self, - name: str) -> pd.DataFrame: - """Computes run length matrix features for the current run length matrix. - - Returns: - ndarray: Value of feature given as parameter - """ - df_feat = pd.DataFrame(np.full(shape=(0, 0), fill_value=np.nan)) - - # Don't return data for empty slices or slices without a good matrix - if self.matrix is None: - # Update names - # df_feat.columns += self._parse_feature_names() - return df_feat - elif len(self.matrix) == 0: - # Update names - # df_feat.columns += self._parse_feature_names() - return df_feat - - # Create local copy of the run length matrix and set column names - df_rij = deepcopy(self.matrix) - df_rij.columns = ["i", "j", "rij"] - - # Sum over grey levels - df_ri = df_rij.groupby(by="i")["rij"].agg(np.sum).reset_index().rename(columns={"rij": "ri"}) - - # Sum over run lengths - df_rj = df_rij.groupby(by="j")["rij"].agg(np.sum).reset_index().rename(columns={"rij": "rj"}) - - # Constant definitions - n_s = np.sum(df_rij.rij) * 1.0 # Number of runs - n_v = self.n_v * 1.0 # Number of voxels - - # Calculation glrlm feature - # Short runs emphasis - if name == "sre": - df_feat.loc["value", "sre"] = np.sum(df_rj.rj / df_rj.j ** 2.0) / n_s - # Long runs emphasis - elif name == "lre": - df_feat.loc["value", "lre"] = np.sum(df_rj.rj * df_rj.j ** 2.0) / n_s - # Grey level non-uniformity - elif name == "glnu": - df_feat.loc["value", "glnu"] = np.sum(df_ri.ri ** 2.0) / n_s - # Grey level non-uniformity, normalised - elif name == "glnu_norm": - df_feat.loc["value", "glnu_norm"] = np.sum(df_ri.ri ** 2.0) / n_s ** 2.0 - # Run length non-uniformity - elif name == "rlnu": - df_feat.loc["value", "rlnu"] = np.sum(df_rj.rj ** 2.0) / n_s - # Run length non-uniformity, normalised - elif name == "rlnu_norm": - df_feat.loc["value", "rlnu_norm"] = np.sum(df_rj.rj ** 2.0) / n_s ** 2.0 - # Run percentage - elif name == "r_perc": - df_feat.loc["value", "r_perc"] = n_s / n_v - # Low grey level run emphasis - elif name == "lgre": - df_feat.loc["value", "lgre"] = np.sum(df_ri.ri / df_ri.i ** 2.0) / n_s - # High grey level run emphasis - elif name == "hgre": - df_feat.loc["value", "hgre"] = np.sum(df_ri.ri * df_ri.i ** 2.0) / n_s - # Short run low grey level emphasis - elif name == "srlge": - df_feat.loc["value", "srlge"] = np.sum(df_rij.rij / (df_rij.i * df_rij.j) ** 2.0) / n_s - # Short run high grey level emphasis - elif name == "srhge": - df_feat.loc["value", "srhge"] = np.sum(df_rij.rij * df_rij.i ** 2.0 / df_rij.j ** 2.0) / n_s - # Long run low grey level emphasis - elif name == "lrlge": - df_feat.loc["value", "lrlge"] = np.sum(df_rij.rij * df_rij.j ** 2.0 / df_rij.i ** 2.0) / n_s - # Long run high grey level emphasis - elif name == "lrhge": - df_feat.loc["value", "lrhge"] = np.sum(df_rij.rij * df_rij.i ** 2.0 * df_rij.j ** 2.0) / n_s - # Grey level variance - elif name == "gl_var": - mu = np.sum(df_rij.rij * df_rij.i) / n_s - df_feat.loc["value", "gl_var"] = np.sum((df_rij.i - mu) ** 2.0 * df_rij.rij) / n_s - # Run length variance - elif name == "rl_var": - mu = np.sum(df_rij.rij * df_rij.j) / n_s - df_feat.loc["value", "rl_var"] = np.sum((df_rij.j - mu) ** 2.0 * df_rij.rij) / n_s - # Zone size entropy - elif name == "rl_entr": - df_feat.loc["value", "rl_entr"] = - np.sum(df_rij.rij * np.log2(df_rij.rij / n_s)) / n_s - else: - print("ERROR: Wrong arg. Use ones from list : (sre, lre, glnu, glnu_normn, rlnu \ - rlnu_norm, r_perc, lgre, hgre, srlge, srhge, lrlge, lrhge, gl_var, rl_var, rl_entr)") - - return df_feat - - def _parse_feature_names(self) -> str: - """"Adds additional settings-related identifiers to each feature. - Not used currently, as the use of different settings for the - run length matrix is not supported. - """ - parse_str = "" - - # Add spatial method - if self.spatial_method is not None: - parse_str += "_" + self.spatial_method - - # Add merge method - if self.merge_method is not None: - if self.merge_method == "average": - parse_str += "_avg" - if self.merge_method == "slice_merge": - parse_str += "_s_mrg" - if self.merge_method == "dir_merge": - parse_str += "_d_mrg" - if self.merge_method == "vol_merge": - parse_str += "_v_mrg" - - return parse_str - -def sre(upd_list: np.ndarray) -> float: - """Compute Short runs emphasis feature from the run length matrices list. - This feature refers to "Frlm_sre" (ID = 22OV) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Short runs emphasis feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Short runs emphasis feature - sre_list = [] - sre_run_list = [] - for rlm in upd_list: - sre_run_list += [rlm.calculate_feature("sre")] - - # Average feature values - sre_list += [pd.concat(sre_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_sre = pd.concat(sre_list, axis=1).to_dict(orient="records")[0] - sre = list(df_sre.values())[0] - - return sre - -def lre(upd_list: np.ndarray) -> float: - """Compute Long runs emphasis feature from the run length matrices list. - This feature refers to "Frlm_lre" (ID = W4KF) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Long runs emphasis feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Long runs emphasis feature - lre_list = [] - lre_run_list = [] - for rlm in upd_list: - lre_run_list += [rlm.calculate_feature("lre")] - - # Average feature values - lre_list += [pd.concat(lre_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_lre = pd.concat(lre_list, axis=1).to_dict(orient="records")[0] - lre = list(df_lre.values())[0] - - return lre - -def glnu(upd_list: np.ndarray) -> float: - """Compute Grey level non-uniformity feature from the run length matrices list. - This feature refers to "Frlm_glnu" (ID = R5YN) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Grey level non-uniformity feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Grey level non-uniformity feature - glnu_list = [] - glnu_run_list = [] - for rlm in upd_list: - glnu_run_list += [rlm.calculate_feature("glnu")] - - # Average feature values - glnu_list += [pd.concat(glnu_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_glnu = pd.concat(glnu_list, axis=1).to_dict(orient="records")[0] - glnu = list(df_glnu.values())[0] - - return glnu - -def glnu_norm(upd_list: np.ndarray) -> float: - """Compute Grey level non-uniformity normalised feature from the run length matrices list. - This feature refers to "Frlm_glnu_norm" (ID = OVBL) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Grey level non-uniformity normalised feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Grey level non-uniformity normalised feature - glnu_norm_list = [] - glnu_norm_run_list = [] - for rlm in upd_list: - glnu_norm_run_list += [rlm.calculate_feature("glnu_norm")] - - # Average feature values - glnu_norm_list += [pd.concat(glnu_norm_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_glnu_norm = pd.concat(glnu_norm_list, axis=1).to_dict(orient="records")[0] - glnu_norm = list(df_glnu_norm.values())[0] - - return glnu_norm - -def rlnu(upd_list: np.ndarray) -> float: - """Compute Run length non-uniformity feature from the run length matrices list. - This feature refers to "Frlm_rlnu" (ID = W92Y) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Run length non-uniformity feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Run length non-uniformity feature - rlnu_list = [] - rlnu_run_list = [] - for rlm in upd_list: - rlnu_run_list += [rlm.calculate_feature("rlnu")] - - # Average feature values - rlnu_list += [pd.concat(rlnu_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_rlnu = pd.concat(rlnu_list, axis=1).to_dict(orient="records")[0] - rlnu = list(df_rlnu.values())[0] - - return rlnu - -def rlnu_norm(upd_list: np.ndarray) -> float: - """Compute Run length non-uniformity normalised feature from the run length matrices list. - This feature refers to "Frlm_rlnu_norm" (ID = IC23) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Run length non-uniformity normalised feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Run length non-uniformity normalised feature - rlnu_norm_list = [] - rlnu_norm_run_list = [] - for rlm in upd_list: - rlnu_norm_run_list += [rlm.calculate_feature("rlnu_norm")] - - # Average feature values - rlnu_norm_list += [pd.concat(rlnu_norm_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_rlnu_norm = pd.concat(rlnu_norm_list, axis=1).to_dict(orient="records")[0] - rlnu_norm = list(df_rlnu_norm.values())[0] - - return rlnu_norm - -def r_perc(upd_list: np.ndarray) -> float: - """Compute Run percentage feature from the run length matrices list. - This feature refers to "Frlm_r_perc" (ID = 9ZK5) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Run percentage feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Run percentage feature - r_perc_list = [] - r_perc_run_list = [] - for rlm in upd_list: - r_perc_run_list += [rlm.calculate_feature("r_perc")] - - # Average feature values - r_perc_list += [pd.concat(r_perc_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_r_perc = pd.concat(r_perc_list, axis=1).to_dict(orient="records")[0] - r_perc = list(df_r_perc.values())[0] - - return r_perc - -def lgre(upd_list: np.ndarray) -> float: - """Compute Low grey level run emphasis feature from the run length matrices list. - This feature refers to "Frlm_lgre" (ID = V3SW) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Low grey level run emphasis feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Low grey level run emphasis feature - lgre_list = [] - lgre_run_list = [] - for rlm in upd_list: - lgre_run_list += [rlm.calculate_feature("lgre")] - - # Average feature values - lgre_list += [pd.concat(lgre_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_lgre = pd.concat(lgre_list, axis=1).to_dict(orient="records")[0] - lgre = list(df_lgre.values())[0] - - return lgre - -def hgre(upd_list: np.ndarray) -> float: - """Compute High grey level run emphasis feature from the run length matrices list. - This feature refers to "Frlm_hgre" (ID = G3QZ) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the High grey level run emphasis feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate High grey level run emphasis feature - hgre_list = [] - hgre_run_list = [] - for rlm in upd_list: - hgre_run_list += [rlm.calculate_feature("hgre")] - - # Average feature values - hgre_list += [pd.concat(hgre_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_hgre = pd.concat(hgre_list, axis=1).to_dict(orient="records")[0] - hgre = list(df_hgre.values())[0] - - return hgre - -def srlge(upd_list: np.ndarray) -> float: - """Compute Short run low grey level emphasis feature from the run length matrices list. - This feature refers to "Frlm_srlge" (ID = HTZT) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Short run low grey level emphasis feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Short run low grey level emphasis feature - srlge_list = [] - srlge_run_list = [] - for rlm in upd_list: - srlge_run_list += [rlm.calculate_feature("srlge")] - - # Average feature values - srlge_list += [pd.concat(srlge_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_srlge = pd.concat(srlge_list, axis=1).to_dict(orient="records")[0] - srlge = list(df_srlge.values())[0] - - return srlge - -def srhge(upd_list: np.ndarray) -> float: - """Compute Short run high grey level emphasis feature from the run length matrices list. - This feature refers to "Frlm_srhge" (ID = GD3A) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Short run high grey level emphasis feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Short run high grey level emphasis feature - srhge_list = [] - srhge_run_list = [] - for rlm in upd_list: - srhge_run_list += [rlm.calculate_feature("srhge")] - - # Average feature values - srhge_list += [pd.concat(srhge_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_srhge = pd.concat(srhge_list, axis=1).to_dict(orient="records")[0] - srhge = list(df_srhge.values())[0] - - return srhge - -def lrlge(upd_list: np.ndarray) -> float: - """Compute Long run low grey level emphasis feature from the run length matrices list. - This feature refers to "Frlm_lrlge" (ID = IVPO) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Long run low grey level emphasis feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Long run low grey level emphasis feature - lrlge_list = [] - lrlge_run_list = [] - for rlm in upd_list: - lrlge_run_list += [rlm.calculate_feature("lrlge")] - - # Average feature values - lrlge_list += [pd.concat(lrlge_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_lrlge = pd.concat(lrlge_list, axis=1).to_dict(orient="records")[0] - lrlge = list(df_lrlge.values())[0] - - return lrlge - -def lrhge(upd_list: np.ndarray) -> float: - """Compute Long run high grey level emphasisfeature from the run length matrices list. - This feature refers to "Frlm_lrhge" (ID = 3KUM) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Long run high grey level emphasis feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Long run high grey level emphasis feature - lrhge_list = [] - lrhge_run_list = [] - for rlm in upd_list: - lrhge_run_list += [rlm.calculate_feature("lrhge")] - - # Average feature values - lrhge_list += [pd.concat(lrhge_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_lrhge = pd.concat(lrhge_list, axis=1).to_dict(orient="records")[0] - lrhge = list(df_lrhge.values())[0] - - return lrhge - -def gl_var(upd_list: np.ndarray) -> float: - """Compute Grey level variance feature from the run length matrices list. - This feature refers to "Frlm_gl_var" (ID = 8CE5) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Grey level variance feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Grey level variance feature - gl_var_list = [] - gl_var_run_list = [] - for rlm in upd_list: - gl_var_run_list += [rlm.calculate_feature("gl_var")] - - # Average feature values - gl_var_list += [pd.concat(gl_var_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_gl_var = pd.concat(gl_var_list, axis=1).to_dict(orient="records")[0] - gl_var = list(df_gl_var.values())[0] - - return gl_var - -def rl_var(upd_list: np.ndarray) -> float: - """Compute Run length variancefeature from the run length matrices list. - This feature refers to "Frlm_rl_var" (ID = SXLW) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Run length variance feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Run length variance feature - rl_var_list = [] - rl_var_run_list = [] - for rlm in upd_list: - rl_var_run_list += [rlm.calculate_feature("rl_var")] - - # Average feature values - rl_var_list += [pd.concat(rl_var_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_rl_var = pd.concat(rl_var_list, axis=1).to_dict(orient="records")[0] - rl_var = list(df_rl_var.values())[0] - - return rl_var - -def rl_entr(upd_list: np.ndarray) -> float: - """Compute Zone size entropy feature from the run length matrices list. - This feature refers to "Frlm_rl_entr" (ID = HJ9O) in - the `IBSI1 reference manual `__. - - Args: - upd_list (ndarray): Run length matrices computed and merged according given method. - - Returns: - float: Dict of the Zone size entropy feature. - """ - # Skip if no matrices are available (due to illegal combinations of merge and spatial methods - if upd_list is not None: - - # Calculate Zone size entropyfeature - rl_entr_list = [] - rl_entr_run_list = [] - for rlm in upd_list: - rl_entr_run_list += [rlm.calculate_feature("rl_entr")] - - # Average feature values - rl_entr_list += [pd.concat(rl_entr_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary. - df_rl_entr = pd.concat(rl_entr_list, axis=1).to_dict(orient="records")[0] - rl_entr = list(df_rl_entr.values())[0] - - return rl_entr - -def merge_feature(feat_list: np.ndarray) -> float: - """Merge feature tables into a single dictionary. - - Args: - feat_list (ndarray): volume with discretised intensities as 3D numpy array (x, y, z). - - Returns: - float: Dict of the length matrix feature. - """ - df_feat = pd.concat(feat_list, axis=1).to_dict(orient="records")[0] - - return df_feat diff --git a/MEDimage/biomarkers/glszm.py b/MEDimage/biomarkers/glszm.py deleted file mode 100755 index 727dc0f..0000000 --- a/MEDimage/biomarkers/glszm.py +++ /dev/null @@ -1,555 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -from typing import Dict, List, Union - -import numpy as np -import skimage.measure as skim - - -def get_matrix(roi_only: np.ndarray, - levels: Union[np.ndarray, List]) -> Dict: - r""" - This function computes the Gray-Level Size Zone Matrix (GLSZM) of the - region of interest (ROI) of an input volume. The input volume is assumed - to be isotropically resampled. The zones of different sizes are computed - using 26-voxel connectivity. - This matrix refers to "Grey level size zone based features" (ID = 9SAK) - in the `IBSI1 reference manual `_. - - Note: - This function is compatible with 2D analysis (language not adapted in the text). - - Args: - roi_only_int (ndarray): Smallest box containing the ROI, with the imaging data ready - for texture analysis computations. Voxels outside the ROI are - set to NaNs. - levels (ndarray or List): Vector containing the quantized gray-levels - in the tumor region (or reconstruction ``levels`` of quantization). - - Returns: - ndarray: Array of Gray-Level Size Zone Matrix of ``roi_only``. - - REFERENCES: - [1] Thibault, G., Fertil, B., Navarro, C., Pereira, S., Cau, P., Levy, - N., Mari, J.-L. (2009). Texture Indexes and Gray Level Size Zone - Matrix. Application to Cell Nuclei Classification. In Pattern - Recognition and Information Processing (PRIP) (pp. 140–145). - """ - - # PRELIMINARY - roi_only = roi_only.copy() - n_max = np.sum(~np.isnan(roi_only)) - level_temp = np.max(levels) + 1 - roi_only[np.isnan(roi_only)] = level_temp - levels = np.append(levels, level_temp) - - # QUANTIZATION EFFECTS CORRECTION - # In case (for example) we initially wanted to have 64 levels, but due to - # quantization, only 60 resulted. - unique_vect = levels - n_l = np.size(levels) - 1 - - # INITIALIZATION - # THIS NEEDS TO BE CHANGED. THE ARRAY INITIALIZED COULD BE TOO BIG! - glszm = np.zeros((n_l, n_max)) - - # COMPUTATION OF glszm - temp = roi_only.copy().astype('int') - for i in range(1, n_l+1): - temp[roi_only != unique_vect[i-1]] = 0 - temp[roi_only == unique_vect[i-1]] = 1 - conn_objects, n_zone = skim.label(temp, return_num=True) - for j in range(1, n_zone+1): - col = np.sum(conn_objects == j) - glszm[i-1, col-1] = glszm[i-1, col-1] + 1 - - # REMOVE UNECESSARY COLUMNS - stop = np.nonzero(np.sum(glszm, 0))[0][-1] - glszm = np.delete(glszm, range(stop+1, np.shape(glszm)[1]), 1) - - return glszm - -def extract_all(vol: np.ndarray, - glszm: np.ndarray = None) -> Dict: - """Computes glszm features. - These features refer to "Grey level size zone based features" (ID = 9SAK) - in the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, isotropically resampled, quantized - (e.g. n_g = 32, levels = [1, ..., n_g]), - with NaNs outside the region of interest. - - Returns: - Dict: Dict of glszm features. - - """ - glszm_features = {'Fszm_sze': [], - 'Fszm_lze': [], - 'Fszm_lgze': [], - 'Fszm_hgze': [], - 'Fszm_szlge': [], - 'Fszm_szhge': [], - 'Fszm_lzlge': [], - 'Fszm_lzhge': [], - 'Fszm_glnu': [], - 'Fszm_glnu_norm': [], - 'Fszm_zsnu': [], - 'Fszm_zsnu_norm': [], - 'Fszm_z_perc': [], - 'Fszm_gl_var': [], - 'Fszm_zs_var': [], - 'Fszm_zs_entr': []} - - # GET THE GLSZM MATRIX - # Correct definition, without any assumption - vol = vol.copy() - levels = np.arange(1, np.max(vol[~np.isnan(vol[:])])+1) - if glszm is None: - glszm = get_matrix(vol, levels) - n_s = np.sum(glszm) - glszm = glszm/np.sum(glszm) # Normalization of glszm - sz = np.shape(glszm) # Size of glszm - - c_vect = range(1, sz[1]+1) # Row vectors - r_vect = range(1, sz[0]+1) # Column vectors - # Column and row indicators for each entry of the glszm - c_mat, r_mat = np.meshgrid(c_vect, r_vect) - pg = np.transpose(np.sum(glszm, 1)) # Gray-Level Vector - pz = np.sum(glszm, 0) # Zone Size Vector - - # COMPUTING TEXTURES - - # Small zone emphasis - glszm_features['Fszm_sze'] = (np.matmul(pz, np.transpose(np.power(1.0/np.array(c_vect), 2)))) - - # Large zone emphasis - glszm_features['Fszm_lze'] = (np.matmul(pz, np.transpose(np.power(np.array(c_vect), 2)))) - - # Low grey level zone emphasis - glszm_features['Fszm_lgze'] = np.matmul(pg, np.transpose(np.power( - 1.0/np.array(r_vect), 2))) - - # High grey level zone emphasis - glszm_features['Fszm_hgze'] = np.matmul(pg, np.transpose(np.power(np.array(r_vect), 2))) - - # Small zone low grey level emphasis - glszm_features['Fszm_szlge'] = np.sum(np.sum(glszm*(np.power(1.0/r_mat, 2))*(np.power(1.0/c_mat, 2)))) - - # Small zone high grey level emphasis - glszm_features['Fszm_szhge'] = np.sum(np.sum(glszm*(np.power(r_mat, 2))*(np.power(1.0/c_mat, 2)))) - - # Large zone low grey levels emphasis - glszm_features['Fszm_lzlge'] = np.sum(np.sum(glszm*(np.power(1.0/r_mat, 2))*(np.power(c_mat, 2)))) - - # Large zone high grey level emphasis - glszm_features['Fszm_lzhge'] = np.sum(np.sum(glszm*(np.power(r_mat, 2))*(np.power(c_mat, 2)))) - - # Gray level non-uniformity - glszm_features['Fszm_glnu'] = np.sum(np.power(pg, 2)) * n_s - - # Gray level non-uniformity normalised - glszm_features['Fszm_glnu_norm'] = np.sum(np.power(pg, 2)) - - # Zone size non-uniformity - glszm_features['Fszm_zsnu'] = np.sum(np.power(pz, 2)) * n_s - - # Zone size non-uniformity normalised - glszm_features['Fszm_zsnu_norm'] = np.sum(np.power(pz, 2)) - - # Zone percentage - glszm_features['Fszm_z_perc'] = np.sum(pg)/(np.matmul(pz, np.transpose(c_vect))) - - # Grey level variance - temp = r_mat * glszm - u = np.sum(temp) - temp = (np.power(r_mat - u, 2)) * glszm - glszm_features['Fszm_gl_var'] = np.sum(temp) - - # Zone size variance - temp = c_mat * glszm - u = np.sum(temp) - temp = (np.power(c_mat - u, 2)) * glszm - glszm_features['Fszm_zs_var'] = np.sum(temp) - - # Zone size entropy - val_pos = glszm[np.nonzero(glszm)] - temp = val_pos * np.log2(val_pos) - glszm_features['Fszm_zs_entr'] = -np.sum(temp) - - return glszm_features - -def get_single_matrix(vol: np.ndarray) -> np.ndarray: - """Computes gray level size zone matrix in order to compute the single features. - - Args: - vol_int: 3D volume, isotropically resampled, - quantized (e.g. n_g = 32, levels = [1, ..., n_g]), - with NaNs outside the region of interest. - levels: Vector containing the quantized gray-levels - in the tumor region (or reconstruction ``levels`` of quantization). - - Returns: - ndarray: Array of Gray-Level Size Zone Matrix of 'vol'. - - """ - # Correct definition, without any assumption - vol = vol.copy() - levels = np.arange(1, np.max(vol[~np.isnan(vol[:])])+1) - - # GET THE gldzm MATRIX - glszm = get_matrix(vol, levels) - - return glszm - -def sze(glszm: np.ndarray) -> float: - """Computes small zone emphasis feature. - This feature refers to "Fszm_sze" (ID = 5QRC) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the small zone emphasis - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - sz = np.shape(glszm) # Size of glszm - - c_vect = range(1, sz[1]+1) # Row vectors - pz = np.sum(glszm, 0) # Zone Size Vector - - # Small zone emphasis - return (np.matmul(pz, np.transpose(np.power(1.0/np.array(c_vect), 2)))) - -def lze(glszm: np.ndarray) -> float: - """Computes large zone emphasis feature. - This feature refers to "Fszm_lze" (ID = 48P8) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the large zone emphasis - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - sz = np.shape(glszm) # Size of glszm - - c_vect = range(1, sz[1]+1) # Row vectors - pz = np.sum(glszm, 0) # Zone Size Vector - - # Large zone emphasis - return (np.matmul(pz, np.transpose(np.power(np.array(c_vect), 2)))) - -def lgze(glszm: np.ndarray) -> float: - """Computes low grey zone emphasis feature. - This feature refers to "Fszm_lgze" (ID = XMSY) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the low grey zone emphasis - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - sz = np.shape(glszm) # Size of glszm - - r_vect = range(1, sz[0]+1) # Column vectors - pg = np.transpose(np.sum(glszm, 1)) # Gray-Level Vector - - # Low grey zone emphasis - return np.matmul(pg, np.transpose(np.power( - 1.0/np.array(r_vect), 2))) - -def hgze(glszm: np.ndarray) -> float: - """Computes high grey zone emphasis feature. - This feature refers to "Fszm_hgze" (ID = 5GN9) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the high grey zone emphasis - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - sz = np.shape(glszm) # Size of glszm - - r_vect = range(1, sz[0]+1) # Column vectors - pg = np.transpose(np.sum(glszm, 1)) # Gray-Level Vector - - # High grey zone emphasis - return np.matmul(pg, np.transpose(np.power(np.array(r_vect), 2))) - -def szlge(glszm: np.ndarray) -> float: - """Computes small zone low grey level emphasis feature. - This feature refers to "Fszm_szlge" (ID = 5RAI) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the small zone low grey level emphasis - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - sz = np.shape(glszm) # Size of glszm - - c_vect = range(1, sz[1]+1) # Row vectors - r_vect = range(1, sz[0]+1) # Column vectors - # Column and row indicators for each entry of the glszm - c_mat, r_mat = np.meshgrid(c_vect, r_vect) - - # Small zone low grey level emphasis - return np.sum(np.sum(glszm*(np.power(1.0/r_mat, 2))*(np.power(1.0/c_mat, 2)))) - -def szhge(glszm: np.ndarray) -> float: - """Computes small zone high grey level emphasis feature. - This feature refers to "Fszm_szhge" (ID = HW1V) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the small zone high grey level emphasis - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - sz = np.shape(glszm) # Size of glszm - - c_vect = range(1, sz[1]+1) # Row vectors - r_vect = range(1, sz[0]+1) # Column vectors - # Column and row indicators for each entry of the glszm - c_mat, r_mat = np.meshgrid(c_vect, r_vect) - - # Small zone high grey level emphasis - return np.sum(np.sum(glszm*(np.power(r_mat, 2))*(np.power(1.0/c_mat, 2)))) - -def lzlge(glszm: np.ndarray) -> float: - """Computes large zone low grey level emphasis feature. - This feature refers to "Fszm_lzlge" (ID = YH51) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the large zone low grey level emphasis - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - sz = np.shape(glszm) # Size of glszm - - c_vect = range(1, sz[1]+1) # Row vectors - r_vect = range(1, sz[0]+1) # Column vectors - # Column and row indicators for each entry of the glszm - c_mat, r_mat = np.meshgrid(c_vect, r_vect) - - # Lage zone low grey level emphasis - return np.sum(np.sum(glszm*(np.power(1.0/r_mat, 2))*(np.power(c_mat, 2)))) - -def lzhge(glszm: np.ndarray) -> float: - """Computes large zone high grey level emphasis feature. - This feature refers to "Fszm_lzhge" (ID = J17V) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the large zone high grey level emphasis - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - sz = np.shape(glszm) # Size of glszm - - c_vect = range(1, sz[1]+1) # Row vectors - r_vect = range(1, sz[0]+1) # Column vectors - # Column and row indicators for each entry of the glszm - c_mat, r_mat = np.meshgrid(c_vect, r_vect) - - # Large zone high grey level emphasis - return np.sum(np.sum(glszm*(np.power(r_mat, 2))*(np.power(c_mat, 2)))) - -def glnu(glszm: np.ndarray) -> float: - """Computes grey level non-uniformity feature. - This feature refers to "Fszm_glnu" (ID = JNSA) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the grey level non-uniformity feature - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - n_s = np.sum(glszm) - - pg = np.transpose(np.sum(glszm, 1)) # Gray-Level Vector - - # Grey level non-uniformity feature - return np.sum(np.power(pg, 2)) * n_s - -def glnu_norm(glszm: np.ndarray) -> float: - """Computes grey level non-uniformity normalised - This feature refers to "Fszm_glnu_norm" (ID = Y1RO) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the grey level non-uniformity normalised feature - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - - pg = np.transpose(np.sum(glszm, 1)) # Gray-Level Vector - - # Grey level non-uniformity normalised feature - return np.sum(np.power(pg, 2)) - -def zsnu(glszm: np.ndarray) -> float: - """Computes zone size non-uniformity - This feature refers to "Fszm_zsnu" (ID = 4JP3) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the zone size non-uniformity feature - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - n_s = np.sum(glszm) - - pz = np.sum(glszm, 0) # Zone Size Vector - - # Zone size non-uniformity feature - return np.sum(np.power(pz, 2)) * n_s - -def zsnu_norm(glszm: np.ndarray) -> float: - """Computes zone size non-uniformity normalised - This feature refers to "Fszm_zsnu_norm" (ID = VB3A) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the zone size non-uniformity normalised feature - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - - pz = np.sum(glszm, 0) # Zone Size Vector - - # Zone size non-uniformity normalised feature - return np.sum(np.power(pz, 2)) - -def z_perc(glszm: np.ndarray) -> float: - """Computes zone percentage - This feature refers to "Fszm_z_perc" (ID = P30P) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the zone percentage feature - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - sz = np.shape(glszm) # Size of glszm - - c_vect = range(1, sz[1]+1) # Row vectors - pg = np.transpose(np.sum(glszm, 1)) # Gray-Level Vector - pz = np.sum(glszm, 0) # Zone Size Vector - - # Zone percentage feature - return np.sum(pg)/(np.matmul(pz, np.transpose(c_vect))) - -def gl_var(glszm: np.ndarray) -> float: - """Computes grey level variance - This feature refers to "Fszm_gl_var" (ID = BYLV) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the grey level variance feature - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - sz = np.shape(glszm) # Size of glszm - - c_vect = range(1, sz[1]+1) # Row vectors - r_vect = range(1, sz[0]+1) # Column vectors - # Column and row indicators for each entry of the glszm - _, r_mat = np.meshgrid(c_vect, r_vect) - - temp = r_mat * glszm - u = np.sum(temp) - temp = (np.power(r_mat - u, 2)) * glszm - - # Grey level variance feature - return np.sum(temp) - -def zs_var(glszm: np.ndarray) -> float: - """Computes zone size variance - This feature refers to "Fszm_zs_var" (ID = 3NSA) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the zone size variance feature - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - sz = np.shape(glszm) # Size of glszm - - c_vect = range(1, sz[1]+1) # Row vectors - r_vect = range(1, sz[0]+1) # Column vectors - # Column and row indicators for each entry of the glszm - c_mat, _ = np.meshgrid(c_vect, r_vect) - - temp = c_mat * glszm - u = np.sum(temp) - temp = (np.power(c_mat - u, 2)) * glszm - - # Zone size variance feature - return np.sum(temp) - -def zs_entr(glszm: np.ndarray) -> float: - """Computes zone size entropy - This feature refers to "Fszm_zs_entr" (ID = GU8N) in - the `IBSI1 reference manual `__. - - Args: - glszm (ndarray): array of the gray level size zone matrix - - Returns: - float: the zone size entropy feature - - """ - glszm = glszm/np.sum(glszm) # Normalization of glszm - - val_pos = glszm[np.nonzero(glszm)] - temp = val_pos * np.log2(val_pos) - - # Zone size entropy feature - return -np.sum(temp) diff --git a/MEDimage/biomarkers/int_vol_hist.py b/MEDimage/biomarkers/int_vol_hist.py deleted file mode 100755 index a91c1bf..0000000 --- a/MEDimage/biomarkers/int_vol_hist.py +++ /dev/null @@ -1,527 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import logging -from typing import Dict, Tuple - -import numpy as np - -from ..biomarkers.utils import find_i_x, find_v_x -from ..MEDscan import MEDscan - - -def init_ivh( - vol: np.ndarray, - vol_int_re: np.ndarray, - wd: int, - ivh: Dict = None, - im_range: np.ndarray = None, - medscan: MEDscan = None - ) -> Tuple[np.ndarray, np.ndarray, int, int]: - """Computes Intensity-volume Histogram Features. - - Note: - For the input volume: - - - Naturally discretised volume can be kept as it is (e.g. HU values of CT scans) - - All other volumes with continuous intensity distribution should be \ - quantized (e.g., nBins = 100), with levels = [min, ..., max] - - Args: - vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest - vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest - wd (int): Discretisation width. - ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). - im_range (ndarray, optional): The intensity range. - medscan (MEDscan, optional): MEDscan instance containing processing parameters. - - Returns: - Dict: Dict of the Intensity Histogram Features. - """ - try: - # Retrieve relevant parameters from MEDscan instance. - if medscan is not None: - ivh = medscan.params.process.ivh - im_range = medscan.params.process.im_range - elif ivh is None or im_range is None: - raise ValueError('MEDscan instance or ivh and im_range must be provided.') - - # Initialize relevant parameters. - user_set_range = [] - if ivh and 'type' in ivh: - # PET example case (definite intensity units -- continuous case) - if ivh['type'] == 'FBS' or ivh['type'] == 'FBSequal': - range_fbs = [0, 0] - if not im_range: - range_fbs[0] = np.nanmin(vol_int_re) - range_fbs[1] = np.nanmax(vol_int_re) - else: - if im_range[0] == -np.inf: - range_fbs[0] = np.nanmin(vol_int_re) - else: - range_fbs[0] = im_range[0] - if im_range[1] == np.inf: - range_fbs[1] = np.nanmax(vol_int_re) - else: - range_fbs[1] = im_range[1] - # In this case, wd = wb (see discretisation.m) - range_fbs[0] = range_fbs[0] + 0.5*wd - # In this case, wd = wb (see discretisation.m) - range_fbs[1] = range_fbs[1] - 0.5*wd - user_set_range = range_fbs - - else: # MRI example case (arbitrary intensity units) - user_set_range = None - - else: # CT example case (definite intensity units -- discrete case) - user_set_range = im_range - - # INITIALIZATION - X = vol[~np.isnan(vol[:])] - - if (vol is not None) & (wd is not None) & (user_set_range is not None): - if user_set_range: - min_val = user_set_range[0] - max_val = user_set_range[1] - else: - min_val = np.min(X) - max_val = np.max(X) - else: - min_val = np.min(X) - max_val = np.max(X) - - if max_val == np.inf: - max_val = np.max(X) - - if min_val == -np.inf: - min_val = np.min(X) - - # Vector of grey-levels. - # Values are generated within the half-open interval [min_val,max_val+wd) - levels = np.arange(min_val, max_val + wd, wd) - n_g = levels.size - n_v = X.size - - except Exception as e: - print('PROBLEM WITH INITIALIZATION OF INTENSITY-VOLUME HISTOGRAM PARAMETERS \n {e}') - - return X, levels, n_g, n_v - -def extract_all( - vol: np.ndarray, - vol_int_re: np.ndarray, - wd: int, - ivh: Dict = None, - im_range: np.ndarray = None, - medscan: MEDscan = None - ) -> Dict: - """Computes Intensity-volume Histogram Features. - This features refer to Intensity-volume histogram family in - the `IBSI1 reference manual `__. - - Note: - For the input volume, naturally discretised volume can be kept as it is (e.g. HU values of CT scans). - All other volumes with continuous intensity distribution should be - quantized (e.g., nBins = 100), with levels = [min, ..., max] - - Args: - vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest - vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest - wd (int): Discretisation width. - ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). - im_range (ndarray, optional): The intensity range. - medscan (MEDscan, optional): MEDscan instance containing processing parameters. - - Returns: - Dict: Dict of the Intensity Histogram Features. - """ - try: - # Initialization of final structure (Dictionary) containing all features. - int_vol_hist = { - 'Fivh_V10': [], - 'Fivh_V90': [], - 'Fivh_I10': [], - 'Fivh_I90': [], - 'Fivh_V10minusV90': [], - 'Fivh_I10minusI90': [], - 'Fivh_auc': [] - } - - # Retrieve relevant parameters - X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) - - # Calculating fractional volume - fract_vol = np.zeros(n_g) - for i in range(0, n_g): - fract_vol[i] = 1 - np.sum(X < levels[i])/n_v - - # Calculating intensity fraction - fract_int = (levels - np.min(levels)) / (np.max(levels) - np.min(levels)) - - # Volume at intensity fraction 10 - v10 = find_v_x(fract_int, fract_vol, 10) - int_vol_hist['Fivh_V10'] = v10 - - # Volume at intensity fraction 90 - v90 = find_v_x(fract_int, fract_vol, 90) - int_vol_hist['Fivh_V90'] = v90 - - # Intensity at volume fraction 10 - # For initial arbitrary intensities, - # we will always be discretising (1000 bins). - # So intensities are definite here. - i10 = find_i_x(levels, fract_vol, 10) - int_vol_hist['Fivh_I10'] = i10 - - # Intensity at volume fraction 90 - # For initial arbitrary intensities, - # we will always be discretising (1000 bins). - # So intensities are definite here. - i90 = find_i_x(levels, fract_vol, 90) - int_vol_hist['Fivh_I90'] = i90 - - # Volume at intensity fraction difference v10-v90 - int_vol_hist['Fivh_V10minusV90'] = v10 - v90 - - # Intensity at volume fraction difference i10-i90 - # For initial arbitrary intensities, - # we will always be discretising (1000 bins). - # So intensities are definite here. - int_vol_hist['Fivh_I10minusI90'] = i10 - i90 - - # Area under IVH curve - int_vol_hist['Fivh_auc'] = np.trapz(fract_vol) / (n_g - 1) - - except Exception as e: - message = f'PROBLEM WITH COMPUTATION OF INTENSITY-VOLUME HISTOGRAM FEATURES \n {e}' - if medscan is not None: - medscan.radiomics.image['intVolHist_3D'][medscan.params.radiomics.ivh_name].update( - {'error': 'ERROR_COMPUTATION'}) - logging.error(message) - print(message) - - return int_vol_hist - - return int_vol_hist - -def v10( - vol: np.ndarray, - vol_int_re: np.ndarray, - wd: int, - ivh: Dict = None, - im_range: np.ndarray = None, - medscan: MEDscan = None - ) -> float: - """Computes Volume at intensity fraction 10 feature. - This feature refers to "Fivh_V10" (ID = BC2M) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest - vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest - wd (int): Discretisation width. - ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). - im_range (ndarray, optional): The intensity range. - medscan (MEDscan, optional): MEDscan instance containing processing parameters. - - Returns: - float: Volume at intensity fraction 10 feature. - """ - try: - # Retrieve relevant parameters from init_ivh() method. - X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) - - # Calculating fractional volume - fract_vol = np.zeros(n_g) - for i in range(0, n_g): - fract_vol[i] = 1 - np.sum(X < levels[i]) / n_v - - # Calculating intensity fraction - fract_int = (levels - np.min(levels))/(np.max(levels) - np.min(levels)) - - # Volume at intensity fraction 10 - v10 = find_v_x(fract_int, fract_vol, 10) - - except Exception as e: - print(f'PROBLEM WITH COMPUTATION OF V10 FEATURE \n {e}') - return None - - return v10 - -def v90( - vol: np.ndarray, - vol_int_re: np.ndarray, - wd: int, - ivh: Dict = None, - im_range: np.ndarray = None, - medscan: MEDscan = None - ) -> float: - """Computes Volume at intensity fraction 90 feature. - This feature refers to "Fivh_V90" (ID = BC2M) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest - vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest - wd (int): Discretisation width. - ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). - im_range (ndarray, optional): The intensity range. - medscan (MEDscan, optional): MEDscan instance containing processing parameters. - - Returns: - float: Volume at intensity fraction 90 feature. - """ - try: - # Retrieve relevant parameters from init_ivh() method. - X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) - - # Calculating fractional volume - fract_vol = np.zeros(n_g) - for i in range(0, n_g): - fract_vol[i] = 1 - np.sum(X < levels[i]) / n_v - - # Calculating intensity fraction - fract_int = (levels - np.min(levels)) / (np.max(levels) - np.min(levels)) - - # Volume at intensity fraction 90 - v90 = find_v_x(fract_int, fract_vol, 90) - - except Exception as e: - print(f'PROBLEM WITH COMPUTATION OF V90 FEATURE \n {e}') - return None - - return v90 - -def i10( - vol: np.ndarray, - vol_int_re: np.ndarray, - wd: int, - ivh: Dict = None, - im_range: np.ndarray = None, - medscan: MEDscan = None - ) -> float: - """Computes Intensity at volume fraction 10 feature. - This feature refers to "Fivh_I10" (ID = GBPN) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest - vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest - wd (int): Discretisation width. - ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). - im_range (ndarray, optional): The intensity range. - medscan (MEDscan, optional): MEDscan instance containing processing parameters. - - Returns: - float: Intensity at volume fraction 10 feature. - """ - try: - # Retrieve relevant parameters from init_ivh() method. - X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) - - # Calculating fractional volume - fract_vol = np.zeros(n_g) - for i in range(0, n_g): - fract_vol[i] = 1 - np.sum(X < levels[i]) / n_v - - # Intensity at volume fraction 10 - # For initial arbitrary intensities, - # we will always be discretising (1000 bins). - # So intensities are definite here. - i10 = find_i_x(levels, fract_vol, 10) - - except Exception as e: - print(f'PROBLEM WITH COMPUTATION OF I10 FEATURE \n {e}') - return None - - return i10 - -def i90( - vol: np.ndarray, - vol_int_re: np.ndarray, - wd: int, - ivh: Dict = None, - im_range: np.ndarray = None, - medscan: MEDscan = None - ) -> float: - """Computes Intensity at volume fraction 90 feature. - This feature refers to "Fivh_I90" (ID = GBPN) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest - vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest - wd (int): Discretisation width. - ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). - im_range (ndarray, optional): The intensity range. - medscan (MEDscan, optional): MEDscan instance containing processing parameters. - - Returns: - float: Intensity at volume fraction 90 feature. - """ - try: - # Retrieve relevant parameters from init_ivh() method. - X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) - - # Calculating fractional volume - fract_vol = np.zeros(n_g) - for i in range(0, n_g): - fract_vol[i] = 1 - np.sum(X < levels[i]) / n_v - - # Intensity at volume fraction 90 - # For initial arbitrary intensities, - # we will always be discretising (1000 bins). - # So intensities are definite here. - i90 = find_i_x(levels, fract_vol, 90) - - except Exception as e: - print(f'PROBLEM WITH COMPUTATION OF I90 FEATURE \n {e}') - return None - - return i90 - -def v10_minus_v90( - vol: np.ndarray, - vol_int_re: np.ndarray, - wd: int, - ivh: Dict = None, - im_range: np.ndarray = None, - medscan: MEDscan = None - ) -> float: - """Computes Volume at intensity fraction difference v10-v90 - This feature refers to "Fivh_V10minusV90" (ID = DDTU) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest - vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest - wd (int): Discretisation width. - ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). - im_range (ndarray, optional): The intensity range. - medscan (MEDscan, optional): MEDscan instance containing processing parameters. - - Returns: - float: Volume at intensity fraction difference v10-v90 feature. - """ - try: - # Retrieve relevant parameters from init_ivh() method. - X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) - - # Calculating fractional volume - fract_vol = np.zeros(n_g) - for i in range(0, n_g): - fract_vol[i] = 1 - np.sum(X < levels[i]) / n_v - - # Calculating intensity fraction - fract_int = (levels - np.min(levels)) / (np.max(levels) - np.min(levels)) - - # Volume at intensity fraction 10 - v10 = find_v_x(fract_int, fract_vol, 10) - - # Volume at intensity fraction 90 - v90 = find_v_x(fract_int, fract_vol, 90) - - except Exception as e: - print(f'PROBLEM WITH COMPUTATION OF V10minusV90 FEATURE \n {e}') - return None - - return v10 - v90 - -def i10_minus_i90( - vol: np.ndarray, - vol_int_re: np.ndarray, - wd: int, - ivh: Dict = None, - im_range: np.ndarray = None, - medscan: MEDscan = None - ) -> float: - """Computes Intensity at volume fraction difference i10-i90 - This feature refers to "Fivh_I10minusI90" (ID = CNV2) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest - vol_int_re (ndarray): 3D volume, with NaNs outside the region of interest - wd (int): Discretisation width. - ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). - im_range (ndarray, optional): The intensity range. - medscan (MEDscan, optional): MEDscan instance containing processing parameters. - - Returns: - float: Intensity at volume fraction difference i10-i90 feature. - """ - try: - # Retrieve relevant parameters from init_ivh() method. - X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) - - # Calculating fractional volume - fract_vol = np.zeros(n_g) - for i in range(0, n_g): - fract_vol[i] = 1 - np.sum(X < levels[i]) / n_v - - # Intensity at volume fraction 10 - # For initial arbitrary intensities, - # we will always be discretising (1000 bins). - # So intensities are definite here. - i10 = find_i_x(levels, fract_vol, 10) - - # Intensity at volume fraction 90 - # For initial arbitrary intensities, - # we will always be discretising (1000 bins). - # So intensities are definite here. - i90 = find_i_x(levels, fract_vol, 90) - - except Exception as e: - print(f'PROBLEM WITH COMPUTATION OF I10minusI90 FEATURE \n {e}') - return None - - return i10 - i90 - -def auc( - vol: np.ndarray, - vol_int_re: np.ndarray, - wd: int, - ivh: Dict = None, - im_range: np.ndarray = None, - medscan: MEDscan = None - ) -> float: - """ - Computes Area under IVH curve. - This feature refers to "Fivh_auc" (ID = 9CMM) in - the `IBSI1 reference manual `__. - - Note: - For the input volume: - - * Naturally discretised volume can be kept as it is (e.g. HU values of CT scans) - * All other volumes with continuous intensity distribution should be \ - quantized (e.g., nBins = 100), with levels = [min, ..., max] - - Args: - vol(ndarray): 3D volume, QUANTIZED, with NaNs outside the region of interest - vol_int_re(ndarray): 3D volume, with NaNs outside the region of interest - wd(int): Discretisation width. - ivh (Dict, optional): Dict of the Intensity-volume Histogram parameters (Discretization algo and value). - im_range (ndarray, optional): The intensity range. - medscan (MEDscan, optional): MEDscan instance containing processing parameters. - - Returns: - float: Area under IVH curve feature. - """ - try: - # Retrieve relevant parameters from init_ivh() method. - X, levels, n_g, n_v = init_ivh(vol, vol_int_re, wd, ivh, im_range, medscan) - - # Calculating fractional volume - fract_vol = np.zeros(n_g) - for i in range(0, n_g): - fract_vol[i] = 1 - np.sum(X < levels[i]) / n_v - - # Area under IVH curve - auc = np.trapz(fract_vol) / (n_g - 1) - - except Exception as e: - print(f'PROBLEM WITH COMPUTATION OF AUC FEATURE \n {e}') - return None - - return auc diff --git a/MEDimage/biomarkers/intensity_histogram.py b/MEDimage/biomarkers/intensity_histogram.py deleted file mode 100755 index 2c8da64..0000000 --- a/MEDimage/biomarkers/intensity_histogram.py +++ /dev/null @@ -1,615 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import warnings -from typing import Dict, Tuple - -import numpy as np -from scipy.stats import scoreatpercentile, variation - - -def init_IH(vol: np.ndarray) -> Tuple[np.ndarray, np.ndarray, int, np.ndarray, np.ndarray]: - """Initialize Intensity Histogram Features. - - Args: - vol (ndarray): 3D volume, QUANTIZED (e.g. nBins = 100, - levels = [1, ..., max]), with NaNs outside the region of interest. - - Returns: - Dict: Dict of the Intensity Histogram Features. - """ - warnings.simplefilter("ignore") - - # INITIALIZATION - - x = vol[~np.isnan(vol[:])] - n_v = x.size - - # CONSTRUCTION OF HISTOGRAM AND ASSOCIATED NUMBER OF GRAY-LEVELS - - # Always defined from 1 to the maximum value of - # the volume to remove any ambiguity - levels = np.arange(1, np.max(x) + 100*np.finfo(float).eps) - n_g = levels.size # Number of gray-levels - h = np.zeros(n_g) # The histogram of x - - for i in np.arange(0, n_g): - # == i or == levels(i) is equivalent since levels = 1:max(x), - # and n_g = numel(levels) - h[i] = np.sum(x == i + 1) # h[i] = sum(x == i+1) - - p = (h / n_v) # Occurence probability for each grey level bin i - pt = p.transpose() - - return x, levels, n_g, h, p, pt - -def extract_all(vol: np.ndarray) -> Dict: - """Computes Intensity Histogram Features. - These features refer to Intensity histogram family in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, QUANTIZED (e.g. nBins = 100, - levels = [1, ..., max]), with NaNs outside the region of interest. - - Returns: - Dict: Dict of the Intensity Histogram Features. - """ - warnings.simplefilter("ignore") - - # INITIALIZATION - x, levels, n_g, h, p, pt = init_IH(vol) - - # Initialization of final structure (Dictionary) containing all features. - int_hist = {'Fih_mean': [], - 'Fih_var': [], - 'Fih_skew': [], - 'Fih_kurt': [], - 'Fih_median': [], - 'Fih_min': [], - 'Fih_P10': [], - 'Fih_P90': [], - 'Fih_max': [], - 'Fih_mode': [], - 'Fih_iqr': [], - 'Fih_range': [], - 'Fih_mad': [], - 'Fih_rmad': [], - 'Fih_medad': [], - 'Fih_cov': [], - 'Fih_qcod': [], - 'Fih_entropy': [], - 'Fih_uniformity': [], - 'Fih_max_grad': [], - 'Fih_max_grad_gl': [], - 'Fih_min_grad': [], - 'Fih_min_grad_gl': [] - } - - # STARTING COMPUTATION - # Intensity histogram mean - u = np.matmul(levels, pt) - int_hist['Fih_mean'] = u - - # Intensity histogram variance - var = np.matmul(np.power(levels - u, 2), pt) - int_hist['Fih_var'] = var - - # Intensity histogram skewness and kurtosis - skew = 0 - kurt = 0 - - if var != 0: - skew = np.matmul(np.power(levels - u, 3), pt) / np.power(var, 3/2) - kurt = np.matmul(np.power(levels - u, 4), pt) / np.power(var, 2) - 3 - - int_hist['Fih_skew'] = skew - int_hist['Fih_kurt'] = kurt - - # Intensity histogram median - int_hist['Fih_median'] = np.median(x) - - # Intensity histogram minimum grey level - int_hist['Fih_min'] = np.min(x) - - # Intensity histogram 10th percentile - p10 = scoreatpercentile(x, 10) - int_hist['Fih_P10'] = p10 - - # Intensity histogram 90th percentile - p90 = scoreatpercentile(x, 90) - int_hist['Fih_P90'] = p90 - - # Intensity histogram maximum grey level - int_hist['Fih_max'] = np.max(x) - - # Intensity histogram mode - # levels = 1:max(x), so the index of the ith bin of h is the same as i - mh = np.max(h) - mode = np.where(h == mh)[0] + 1 - - if np.size(mode) > 1: - dist = np.abs(mode - u) - ind_min = np.argmin(dist) - int_hist['Fih_mode'] = mode[ind_min] - else: - int_hist['Fih_mode'] = mode[0] - - # Intensity histogram interquantile range - # Since x goes from 1:max(x), all with integer values, - # the result is an integer - int_hist['Fih_iqr'] = scoreatpercentile(x, 75) - scoreatpercentile(x, 25) - - # Intensity histogram range - int_hist['Fih_range'] = np.max(x) - np.min(x) - - # Intensity histogram mean absolute deviation - int_hist['Fih_mad'] = np.mean(abs(x - u)) - - # Intensity histogram robust mean absolute deviation - x_10_90 = x[np.where((x >= p10) & (x <= p90), True, False)] - int_hist['Fih_rmad'] = np.mean(np.abs(x_10_90 - np.mean(x_10_90))) - - # Intensity histogram median absolute deviation - int_hist['Fih_medad'] = np.mean(np.absolute(x - np.median(x))) - - # Intensity histogram coefficient of variation - int_hist['Fih_cov'] = variation(x) - - # Intensity histogram quartile coefficient of dispersion - x_75_25 = scoreatpercentile(x, 75) + scoreatpercentile(x, 25) - int_hist['Fih_qcod'] = int_hist['Fih_iqr'] / x_75_25 - - # Intensity histogram entropy - p = p[p > 0] - int_hist['Fih_entropy'] = -np.sum(p * np.log2(p)) - - # Intensity histogram uniformity - int_hist['Fih_uniformity'] = np.sum(np.power(p, 2)) - - # Calculation of histogram gradient - hist_grad = np.zeros(n_g) - hist_grad[0] = h[1] - h[0] - hist_grad[-1] = h[-1] - h[-2] - - for i in np.arange(1, n_g-1): - hist_grad[i] = (h[i+1] - h[i-1])/2 - - # Maximum histogram gradient - int_hist['Fih_max_grad'] = np.max(hist_grad) - - # Maximum histogram gradient grey level - ind_max = np.where(hist_grad == int_hist['Fih_max_grad'])[0][0] - int_hist['Fih_max_grad_gl'] = levels[ind_max] - - # Minimum histogram gradient - int_hist['Fih_min_grad'] = np.min(hist_grad) - - # Minimum histogram gradient grey level - ind_min = np.where(hist_grad == int_hist['Fih_min_grad'])[0][0] - int_hist['Fih_min_grad_gl'] = levels[ind_min] - - return int_hist - -def mean(vol: np.ndarray) -> float: - """Compute Intensity histogram mean feature of the input dataset (3D Array). - This feature refers to "Fih_mean" (ID = X6K6) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Intensity histogram mean - """ - _, levels, _, _, _, pt = init_IH(vol) # Initialization - - return np.matmul(levels, pt) # Intensity histogram mean - -def var(vol: np.ndarray) -> float: - """Compute Intensity histogram variance feature of the input dataset (3D Array). - This feature refers to "Fih_var" (ID = CH89) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Intensity histogram variance - """ - _, levels, _, _, _, pt = init_IH(vol) # Initialization - u = np.matmul(levels, pt) # Intensity histogram mean - - return np.matmul(np.power(levels - u, 2), pt) # Intensity histogram variance - -def skewness(vol: np.ndarray) -> float: - """Compute Intensity histogram skewness feature of the input dataset (3D Array). - This feature refers to "Fih_skew" (ID = 88K1) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Intensity histogram skewness. - """ - _, levels, _, _, _, pt = init_IH(vol) # Initialization - u = np.matmul(levels, pt) # Intensity histogram mean - var = np.matmul(np.power(levels - u, 2), pt) # Intensity histogram variance - if var != 0: - skew = np.matmul(np.power(levels - u, 3), pt) / np.power(var, 3/2) - - return skew # Skewness - -def kurt(vol: np.ndarray) -> float: - """Compute Intensity histogram kurtosis feature of the input dataset (3D Array). - This feature refers to "Fih_kurt" (ID = C3I7) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: The Intensity histogram kurtosis feature - """ - _, levels, _, _, _, pt = init_IH(vol) # Initialization - u = np.matmul(levels, pt) # Intensity histogram mean - var = np.matmul(np.power(levels - u, 2), pt) # Intensity histogram variance - if var != 0: - kurt = np.matmul(np.power(levels - u, 4), pt) / np.power(var, 2) - 3 - - return kurt # Kurtosis - -def median(vol: np.ndarray) -> float: - """Compute Intensity histogram median feature along the specified axis of the input dataset (3D Array). - This feature refers to "Fih_median" (ID = WIFQ) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Intensity histogram median feature. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.median(x) # Median - -def min(vol: np.ndarray) -> float: - """Compute Intensity histogram minimum grey level feature of the input dataset (3D Array). - This feature refers to "Fih_min" (ID = 1PR8) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Intensity histogram minimum grey level feature. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.min(x) # Minimum grey level - -def p10(vol: np.ndarray) -> float: - """Compute Intensity histogram 10th percentile feature of the input dataset (3D Array). - This feature refers to "Fih_P10" (ID = GPMT) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Intensity histogram 10th percentile feature. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return scoreatpercentile(x, 10) # 10th percentile - -def p90(vol: np.ndarray) -> float: - """Compute Intensity histogram 90th percentile feature of the input dataset (3D Array). - This feature refers to "Fih_P90" (ID = OZ0C) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - Returns: - float: Intensity histogram 90th percentile feature. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return scoreatpercentile(x, 90) # 90th percentile - -def max(vol: np.ndarray) -> float: - """Compute Intensity histogram maximum grey level feature of the input dataset (3D Array). - This feature refers to "Fih_max" (ID = 3NCY) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Intensity histogram maximum grey level feature. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.max(x) # Maximum grey level - -def mode(vol: np.ndarray) -> int: - """Compute Intensity histogram mode feature of the input dataset (3D Array). - This feature refers to "Fih_mode" (ID = AMMC) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - integer: Intensity histogram mode. - levels = 1:max(x), so the index of the ith bin of h is the same as i - """ - _, levels, _, h, _, pt = init_IH(vol) # Initialization - u = np.matmul(levels, pt) - mh = np.max(h) - mode = np.where(h == mh)[0] + 1 - - if np.size(mode) > 1: - dist = np.abs(mode - u) - ind_min = np.argmin(dist) - - return mode[ind_min] # Intensity histogram mode. - else: - - return mode[0] # Intensity histogram mode. - -def iqrange(vol: np.ndarray) -> float: - r"""Compute Intensity histogram interquantile range feature of the input dataset (3D Array). - This feature refers to "Fih_iqr" (ID = WR0O) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Interquartile range. If :math:`axis ≠ None` , the output data-type is the same as that of the input. - Since x goes from :math:`1:max(x)` , all with integer values, the result is an integer. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return scoreatpercentile(x, 75) - scoreatpercentile(x, 25) # Intensity histogram interquantile range - -def range(vol: np.ndarray) -> float: - """Compute Intensity histogram range of values (maximum - minimum) feature of the input dataset (3D Array). - This feature refers to "Fih_range" (ID = 5Z3W) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Intensity histogram range. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.max(x) - np.min(x) # Intensity histogram range - -def mad(vol: np.ndarray) -> float: - """Compute Intensity histogram mean absolute deviation feature of the input dataset (3D Array). - This feature refers to "Fih_mad" (ID = D2ZX) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float : Intensity histogram mean absolute deviation feature. - """ - x, levels, _, _, _, pt = init_IH(vol) # Initialization - u = np.matmul(levels, pt) - - return np.mean(abs(x - u)) # Intensity histogram mean absolute deviation - -def rmad(vol: np.ndarray) -> float: - """Compute Intensity histogram robust mean absolute deviation feature of the input dataset (3D Array). - This feature refers to "Fih_rmad" (ID = WRZB) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - P10(ndarray): Score at 10th percentil. - P90(ndarray): Score at 90th percentil. - - Returns: - float: Intensity histogram robust mean absolute deviation - """ - x = vol[~np.isnan(vol[:])] # Initialization - P10 = scoreatpercentile(x, 10) # 10th percentile - P90 = scoreatpercentile(x, 90) # 90th percentile - x_10_90 = x[np.where((x >= P10) & - (x <= P90), True, False)] # Holding x for (x >= P10) and (x<= P90) - - return np.mean(np.abs(x_10_90 - np.mean(x_10_90))) # Intensity histogram robust mean absolute deviation - -def medad(vol: np.ndarray) -> float: - """Intensity histogram median absolute deviation feature of the input dataset (3D Array). - This feature refers to "Fih_medad" (ID = 4RNL) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Intensity histogram median absolute deviation feature. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.mean(np.absolute(x - np.median(x))) # Intensity histogram median absolute deviation - -def cov(vol: np.ndarray) -> float: - """Compute Intensity histogram coefficient of variation feature of the input dataset (3D Array). - This feature refers to "Fih_cov" (ID = CWYJ) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Intensity histogram coefficient of variation feature. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return variation(x) # Intensity histogram coefficient of variation - -def qcod(vol: np.ndarray) -> float: - """Compute the quartile coefficient of dispersion feature of the input dataset (3D Array). - This feature refers to "Fih_qcod" (ID = SLWD) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - ndarray: A new array holding the quartile coefficient of dispersion feature. - """ - x = vol[~np.isnan(vol[:])] # Initialization - x_75_25 = scoreatpercentile(x, 75) + scoreatpercentile(x, 25) - - return iqrange(x) / x_75_25 # Quartile coefficient of dispersion - -def entropy(vol: np.ndarray) -> float: - """Compute Intensity histogram entropy feature of the input dataset (3D Array). - This feature refers to "Fih_entropy" (ID = TLU2) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Intensity histogram entropy feature. - """ - x, _, _, _, p, _ = init_IH(vol) # Initialization - p = p[p > 0] - - return -np.sum(p * np.log2(p)) # Intensity histogram entropy - -def uniformity(vol: np.ndarray) -> float: - """Compute Intensity histogram uniformity feature of the input dataset (3D Array). - This feature refers to "Fih_uniformity" (ID = BJ5W) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Intensity histogram uniformity feature. - """ - x, _, _, _, p, _ = init_IH(vol) # Initialization - p = p[p > 0] - - return np.sum(np.power(p, 2)) # Intensity histogram uniformity - -def hist_grad_calc(vol: np.ndarray) -> np.ndarray: - """Calculation of histogram gradient. - This feature refers to "Fih_hist_grad_calc" (ID = 12CE) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - ndarray: Histogram gradient - """ - _, _, n_g, h, _, _ = init_IH(vol) # Initialization - hist_grad = np.zeros(n_g) - hist_grad[0] = h[1] - h[0] - hist_grad[-1] = h[-1] - h[-2] - for i in np.arange(1, n_g-1): - hist_grad[i] = (h[i+1] - h[i-1])/2 - - return hist_grad # Intensity histogram uniformity - -def max_grad(vol: np.ndarray) -> float: - """Calculation of Maximum histogram gradient feature. - This feature refers to "Fih_max_grad" (ID = 12CE) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Maximum histogram gradient feature. - """ - hist_grad = hist_grad_calc(vol) # Initialization - - return np.max(hist_grad) # Maximum histogram gradient - -def max_grad_gl(vol: np.ndarray) -> float: - """Calculation of Maximum histogram gradient grey level feature. - This feature refers to "Fih_max_grad_gl" (ID = 8E6O) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Maximum histogram gradient grey level feature. - """ - _, levels, _, _, _, _ = init_IH(vol) # Initialization - hist_grad = hist_grad_calc(vol) - ind_max = np.where(hist_grad == np.max(hist_grad))[0][0] - - return levels[ind_max] # Maximum histogram gradient grey level - -def min_grad(vol: np.ndarray) -> float: - """Calculation of Minimum histogram gradient feature. - This feature refers to "Fih_min_grad" (ID = VQB3) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Minimum histogram gradient feature. - """ - hist_grad = hist_grad_calc(vol) # Initialization - - return np.min(hist_grad) # Minimum histogram gradient - -def min_grad_gl(vol: np.ndarray) -> float: - """Calculation of Minimum histogram gradient grey level feature. - This feature refers to "Fih_min_grad_gl" (ID = RHQZ) in - the `IBSI1 reference manual `__. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Minimum histogram gradient grey level feature. - """ - _, levels, _, _, _, _ = init_IH(vol) # Initialization - hist_grad = hist_grad_calc(vol) - ind_min = np.where(hist_grad == np.min(hist_grad))[0][0] - - return levels[ind_min] # Minimum histogram gradient grey level diff --git a/MEDimage/biomarkers/local_intensity.py b/MEDimage/biomarkers/local_intensity.py deleted file mode 100755 index 13d809a..0000000 --- a/MEDimage/biomarkers/local_intensity.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -from typing import Dict - -from numpy import ndarray - -from ..biomarkers.utils import get_glob_peak, get_loc_peak - - -def extract_all(img_obj: ndarray, - roi_obj: ndarray, - res: ndarray, - intensity_type: str, - compute_global: bool = False) -> Dict: - """Compute Local Intensity Features. - This features refer to Local Intensity family in - the `IBSI1 reference manual `__. - - Args: - img_obj (ndarray): Continuous image intensity distribution, with no NaNs - outside the ROI. - roi_obj (ndarray): Array of the mask defining the ROI. - res (List[float]): [a,b,c] vector specifying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - intensity_type (str): Type of intensity to compute. Can be "arbitrary", "definite" or "filtered". - Will compute features only for "definite" intensity type. - compute_global (bool, optional): If True, will compute global intensity peak, we - recommend you don't set it to True if not necessary in your study or analysis as it - takes too much time for calculation. Default: False. - - Returns: - Dict: Dict of the Local Intensity Features. - - Raises: - ValueError: If `intensity_type` is not "arbitrary", "definite" or "filtered". - """ - assert intensity_type in ["arbitrary", "definite", "filtered"], \ - "intensity_type must be 'arbitrary', 'definite' or 'filtered'" - - loc_int = {'Floc_peak_local': [], 'Floc_peak_global': []} - - if intensity_type == "definite": - loc_int['Floc_peak_local'] = (get_loc_peak(img_obj, roi_obj, res)) - - # NEEDS TO BE VECTORIZED FOR FASTER CALCULATION! OR - # SIMPLY JUST CONVOLUTE A 3D AVERAGING FILTER! - if compute_global: - loc_int['Floc_peak_global'] = (get_glob_peak(img_obj,roi_obj, res)) - - return loc_int - -def peak_local(img_obj: ndarray, - roi_obj: ndarray, - res: ndarray) -> float: - """Computes local intensity peak. - This feature refers to "Floc_peak_local" (ID = VJGA) in - the `IBSI1 reference manual `__. - - Args: - img_obj (ndarray): Continuous image intensity distribution, with no NaNs - outside the ROI. - roi_obj (ndarray): Array of the mask defining the ROI. - res (List[float]): [a,b,c] vector specifying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Local intensity peak. - """ - return get_loc_peak(img_obj, roi_obj, res) - -def peak_global(img_obj: ndarray, - roi_obj: ndarray, - res: ndarray) -> float: - """Computes global intensity peak. - This feature refers to "Floc_peak_global" (ID = 0F91) in - the `IBSI1 reference manual `__. - - Args: - img_obj (ndarray): Continuous image intensity distribution, with no NaNs - outside the ROI. - roi_obj (ndarray): Array of the mask defining the ROI. - res (List[float]): [a,b,c] vector specifying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Global intensity peak. - """ - return get_glob_peak(img_obj, roi_obj, res) diff --git a/MEDimage/biomarkers/morph.py b/MEDimage/biomarkers/morph.py deleted file mode 100755 index 35cda15..0000000 --- a/MEDimage/biomarkers/morph.py +++ /dev/null @@ -1,1756 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import copy -import logging -from typing import Dict, List, Tuple, Union - -import numpy as np -import pandas as pd -import scipy.spatial as sc -from scipy.spatial import ConvexHull -from skimage.measure import marching_cubes - -from ..biomarkers.get_oriented_bound_box import rot_matrix, sig_proc_find_peaks - - -def get_mesh(mask: np.ndarray, - res: Union[np.ndarray, List]) -> Tuple[np.ndarray, - np.ndarray, - np.ndarray]: - """Compute Mesh. - - Note: - Make sure the `mask` is padded with a layer of 0's in all - dimensions to reduce potential isosurface computation errors. - - Args: - mask (ndarray): Contains only 0's and 1's. - res (ndarray or List): [a,b,c] vector specifying the resolution of the volume in mm. - xyz resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - Tuple[np.ndarray, np.ndarray, np.ndarray]: - - Array of the [X,Y,Z] positions of the ROI. - - Array of the spatial coordinates for `mask` unique mesh vertices. - - Array of triangular faces via referencing vertex indices from vertices. - """ - # Getting the grid of X,Y,Z positions, where the coordinate reference - # system (0,0,0) is located at the upper left corner of the first voxel - # (-0.5: half a voxel distance). For the whole volume defining the mask, - # no matter if it is a 1 or a 0. - mask = mask.copy() - res = res.copy() - - x = res[0]*((np.arange(1, np.shape(mask)[0]+1))-0.5) - y = res[1]*((np.arange(1, np.shape(mask)[1]+1))-0.5) - z = res[2]*((np.arange(1, np.shape(mask)[2]+1))-0.5) - X, Y, Z = np.meshgrid(x, y, z, indexing='ij') - - # Getting the isosurface of the mask - vertices, faces, _, _ = marching_cubes(volume=mask, level=0.5, spacing=res) - - # Getting the X,Y,Z positions of the ROI (i.e. 1's) of the mask - X = np.reshape(X, (np.size(X), 1), order='F') - Y = np.reshape(Y, (np.size(Y), 1), order='F') - Z = np.reshape(Z, (np.size(Z), 1), order='F') - - xyz = np.concatenate((X, Y, Z), axis=1) - xyz = xyz[np.where(np.reshape(mask, np.size(mask), order='F') == 1)[0], :] - - return xyz, faces, vertices - -def get_com(xgl_int: np.ndarray, - xgl_morph: np.ndarray, - xyz_int: np.ndarray, - xyz_morph: np.ndarray) -> Union[float, - np.ndarray]: - """Calculates center of mass shift (in mm, since resolution is in mm). - - Note: - Row positions of "x_gl" and "xyz" must correspond for each point. - - Args: - xgl_int (ndarray): Vector of intensity values in the volume to analyze - (only values in the intensity mask). - xgl_morph (ndarray): Vector of intensity values in the volume to analyze - (only values in the morphological mask). - xyz_int (ndarray): [n_points X 3] matrix of three column vectors, defining the [X,Y,Z] - positions of the points in the ROI (1's) of the mask volume (In mm). - (Mesh-based volume calculated from the ROI intensity mesh) - xyz_morph (ndarray): [n_points X 3] matrix of three column vectors, defining the [X,Y,Z] - positions of the points in the ROI (1's) of the mask volume (In mm). - (Mesh-based volume calculated from the ROI morphological mesh) - - Returns: - Union[float, np.ndarray]: The ROI volume centre of mass. - - """ - - # Getting the geometric centre of mass - n_v = np.size(xgl_morph) - - com_geom = np.sum(xyz_morph, 0)/n_v # [1 X 3] vector - - # Getting the density centre of mass - xyz_int[:, 0] = xgl_int*xyz_int[:, 0] - xyz_int[:, 1] = xgl_int*xyz_int[:, 1] - xyz_int[:, 2] = xgl_int*xyz_int[:, 2] - com_gl = np.sum(xyz_int, 0)/np.sum(xgl_int, 0) # [1 X 3] vector - - # Calculating the shift - com = np.linalg.norm(com_geom - com_gl) - - return com - -def get_area_dens_approx(a: float, - b: float, - c: float, - n: float) -> float: - """Computes area density - minimum volume enclosing ellipsoid - - Args: - a (float): Major semi-axis length. - b (float): Minor semi-axis length. - c (float): Least semi-axis length. - n (int): Number of iterations. - - Returns: - float: Area density - minimum volume enclosing ellipsoid. - - """ - alpha = np.sqrt(1 - b**2/a**2) - beta = np.sqrt(1 - c**2/a**2) - ab = alpha * beta - point = (alpha**2+beta**2) / (2*ab) - a_ell = 0 - - for v in range(0, n+1): - coef = [0]*v + [1] - legen = np.polynomial.legendre.legval(x=point, c=coef) - a_ell = a_ell + ab**v / (1-4*v**2) * legen - - a_ell = a_ell * 4 * np.pi * a * b - - return a_ell - -def get_axis_lengths(xyz: np.ndarray) -> Tuple[float, float, float]: - """Computes AxisLengths. - - Args: - xyz (ndarray): Array of three column vectors, defining the [X,Y,Z] - positions of the points in the ROI (1's) of the mask volume. In mm. - - Returns: - Tuple[float, float, float]: Array of three column vectors - [Major axis lengths, Minor axis lengths, Least axis lengths]. - - """ - xyz = xyz.copy() - - # Getting the geometric centre of mass - com_geom = np.sum(xyz, 0)/np.shape(xyz)[0] # [1 X 3] vector - - # Subtracting the centre of mass - xyz[:, 0] = xyz[:, 0] - com_geom[0] - xyz[:, 1] = xyz[:, 1] - com_geom[1] - xyz[:, 2] = xyz[:, 2] - com_geom[2] - - # Getting the covariance matrix - cov_mat = np.cov(xyz, rowvar=False) - - # Getting the eigenvalues - eig_val, _ = np.linalg.eig(cov_mat) - eig_val = np.sort(eig_val) - - major = eig_val[2] - minor = eig_val[1] - least = eig_val[0] - - return major, minor, least - -def min_oriented_bound_box(pos_mat: np.ndarray) -> np.ndarray: - """Computes the minimum bounding box of an arbitrary solid: an iterative approach. - This feature refers to "Volume density (oriented minimum bounding box)" (ID = ZH1A) - in the `IBSI1 reference manual `_. - - Args: - pos_mat (ndarray): matrix position - - Returns: - ndarray: return bounding box dimensions - """ - - ########################## - # Internal functions - ########################## - - def calc_rot_aabb_surface(theta: float, - hull_mat: np.ndarray) -> np.ndarray: - """Function to calculate surface of the axis-aligned bounding box of a rotated 2D contour - - Args: - theta (float): angle in radian - hull_mat (nddarray): convex hull matrix - - Returns: - ndarray: the surface of the axis-aligned bounding box of a rotated 2D contour - """ - - # Create rotation matrix and rotate over theta - rot_mat = rot_matrix(theta=theta, dim=2) - rot_hull = np.dot(rot_mat, hull_mat) - - # Calculate bounding box surface of the rotated contour - rot_aabb_dims = np.max(rot_hull, axis=1) - np.min(rot_hull, axis=1) - rot_aabb_area = np.product(rot_aabb_dims) - - return rot_aabb_area - - def approx_min_theta(hull_mat: np.ndarray, - theta_sel: float, - res: float, - max_rep: int=5) -> np.ndarray: - """Iterative approximator for finding angle theta that minimises surface area - - Args: - hull_mat (ndarray): convex hull matrix - theta_sel (float): angle in radian - res (float): value in radian - max_rep (int, optional): maximum repetition. Defaults to 5. - - Returns: - ndarray: the angle theta that minimises surfae area - """ - - for i in np.arange(0, max_rep): - - # Select new thetas in vicinity of - theta = np.array([theta_sel-res, theta_sel-0.5*res, - theta_sel, theta_sel+0.5*res, theta_sel+res]) - - # Calculate projection areas for current angles theta - rot_area = np.array( - list(map(lambda x: calc_rot_aabb_surface(theta=x, hull_mat=hull_mat), theta))) - - # Find global minimum and corresponding angle theta_sel - theta_sel = theta[np.argmin(rot_area)] - - # Shrink resolution and iterate - res = res / 2.0 - - return theta_sel - - def rotate_minimal_projection(input_pos: float, - rot_axis: int, - n_minima: int=3, - res_init: float=5.0): - """Function to that rotates input_pos to find the rotation that - minimises the projection of input_pos on the - plane normal to the rot_axis - - Args: - input_pos (float): input position value - rot_axis (int): rotation axis value - n_minima (int, optional): _description_. Defaults to 3. - res_init (float, optional): _description_. Defaults to 5.0. - - Returns: - _type_: _description_ - """ - - - # Find axis aligned bounding box of the point set - aabb_max = np.max(input_pos, axis=0) - aabb_min = np.min(input_pos, axis=0) - - # Center the point set at the AABB center - output_pos = input_pos - 0.5 * (aabb_min + aabb_max) - - # Project model to plane - proj_pos = copy.deepcopy(output_pos) - proj_pos = np.delete(proj_pos, [rot_axis], axis=1) - - # Calculate 2D convex hull of the model projection in plane - if np.shape(proj_pos)[0] >= 10: - hull_2d = ConvexHull(points=proj_pos) - hull_mat = proj_pos[hull_2d.vertices, :] - del hull_2d, proj_pos - else: - hull_mat = proj_pos - del proj_pos - - # Transpose hull_mat so that the array is (ndim, npoints) instead of (npoints, ndim) - hull_mat = np.transpose(hull_mat) - - # Calculate bounding box surface of a series of rotated contours - # Note we can program a min-search algorithm here as well - - # Calculate initial surfaces - theta_init = np.arange(start=0.0, stop=90.0 + - res_init, step=res_init) * np.pi / 180.0 - rot_area = np.array( - list(map(lambda x: calc_rot_aabb_surface(theta=x, hull_mat=hull_mat), theta_init))) - - # Find local minima - df_min = sig_proc_find_peaks(x=rot_area, ddir="neg") - - # Check if any minimum was generated - if len(df_min) > 0: - # Investigate up to n_minima number of local minima, starting with the global minimum - df_min = df_min.sort_values(by="val", ascending=True) - - # Determine max number of minima evaluated - max_iter = np.min([n_minima, len(df_min)]) - - # Initialise place holder array - theta_min = np.zeros(max_iter) - - # Iterate over local minima - for k in np.arange(0, max_iter): - - # Find initial angle corresponding to i-th minimum - sel_ind = df_min.ind.values[k] - theta_curr = theta_init[sel_ind] - - # Zoom in to improve the approximation of theta - theta_min[k] = approx_min_theta( - hull_mat=hull_mat, theta_sel=theta_curr, res=res_init*np.pi/180.0) - - # Calculate surface areas corresponding to theta_min and theta that - # minimises the surface - rot_area = np.array( - list(map(lambda x: calc_rot_aabb_surface(theta=x, hull_mat=hull_mat), theta_min))) - theta_sel = theta_min[np.argmin(rot_area)] - - else: - theta_sel = theta_init[0] - - # Rotate original point along the angle that minimises the projected AABB area - output_pos = np.transpose(output_pos) - rot_mat = rot_matrix(theta=theta_sel, dim=3, rot_axis=rot_axis) - output_pos = np.dot(rot_mat, output_pos) - - # Rotate output_pos back to (npoints, ndim) - output_pos = np.transpose(output_pos) - - return output_pos - - ########################## - # Main function - ########################## - - rot_df = pd.DataFrame({"rot_axis_0": np.array([0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2]), - "rot_axis_1": np.array([1, 2, 1, 2, 0, 2, 0, 2, 0, 1, 0, 1]), - "rot_axis_2": np.array([2, 1, 0, 0, 2, 0, 1, 1, 1, 0, 2, 2]), - "aabb_axis_0": np.zeros(12), - "aabb_axis_1": np.zeros(12), - "aabb_axis_2": np.zeros(12), - "vol": np.zeros(12)}) - - # Rotate over different sequences - for i in np.arange(0, len(rot_df)): - # Create a local copy - work_pos = copy.deepcopy(pos_mat) - - # Rotate over sequence of rotation axes - work_pos = rotate_minimal_projection( - input_pos=work_pos, rot_axis=rot_df.rot_axis_0[i]) - work_pos = rotate_minimal_projection( - input_pos=work_pos, rot_axis=rot_df.rot_axis_1[i]) - work_pos = rotate_minimal_projection( - input_pos=work_pos, rot_axis=rot_df.rot_axis_2[i]) - - # Determine resultant minimum bounding box - aabb_dims = np.max(work_pos, axis=0) - np.min(work_pos, axis=0) - rot_df.loc[i, "aabb_axis_0"] = aabb_dims[0] - rot_df.loc[i, "aabb_axis_1"] = aabb_dims[1] - rot_df.loc[i, "aabb_axis_2"] = aabb_dims[2] - rot_df.loc[i, "vol"] = np.product(aabb_dims) - - del work_pos, aabb_dims - - # Find minimal volume of all rotations and return bounding box dimensions - idxmin = rot_df.vol.idxmin() - sel_row = rot_df.loc[idxmin] - ombb_dims = np.array( - [sel_row.aabb_axis_0, sel_row.aabb_axis_1, sel_row.aabb_axis_2]) - - return ombb_dims - -def get_moran_i(vol: np.ndarray, - res: List[float]) -> float: - """Computes Moran's Index. - This feature refers to "Moran’s I index" (ID = N365) - in the `IBSI1 reference manual `_. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - res (List[float]): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world) or JIK resolution (intrinsic matlab). - - Returns: - float: Value of Moran's Index. - - """ - vol = vol.copy() - res = res.copy() - - # Find the location(s) of all non NaNs voxels - I, J, K = np.nonzero(~np.isnan(vol)) - n_vox = np.size(I) - - # Get the mean - u = np.mean(vol[~np.isnan(vol[:])]) - vol_mean = vol.copy() - u # (x_gl,i - u) - vol_m_mean_s = np.power((vol.copy() - u), 2) # (x_gl,i - u).^2 - # Sum of (x_gl,i - u).^2 over all i - sum_s = np.sum(vol_m_mean_s[~np.isnan(vol_m_mean_s[:])]) - - # Get a meshgrid first - x = res[0]*((np.arange(1, np.shape(vol)[0]+1))-0.5) - y = res[1]*((np.arange(1, np.shape(vol)[1]+1))-0.5) - z = res[2]*((np.arange(1, np.shape(vol)[2]+1))-0.5) - X, Y, Z = np.meshgrid(x, y, z, indexing='ij') - - temp = 0 - sum_w = 0 - for i in range(1, n_vox+1): - # Distance mesh - temp_x = X - X[I[i-1], J[i-1], K[i-1]] - temp_y = Y - Y[I[i-1], J[i-1], K[i-1]] - temp_z = Z - Z[I[i-1], J[i-1], K[i-1]] - - # meshgrid of weigths - temp_dist_mesh = 1 / np.sqrt(temp_x**2 + temp_y**2 + temp_z**2) - - # Removing NaNs - temp_dist_mesh[np.isnan(vol)] = np.NaN - temp_dist_mesh[I[i-1], J[i-1], K[i-1]] = np.NaN - # Running sum of weights - w_sum = np.sum(temp_dist_mesh[~np.isnan(temp_dist_mesh[:])]) - sum_w = sum_w + w_sum - - # Inside sum calculation - # Removing NaNs - temp_vol = vol_mean.copy() - temp_vol[I[i-1], J[i-1], K[i-1]] = np.NaN - temp_vol = temp_dist_mesh * temp_vol # (wij .* (x_gl,j - u)) - # Summing (wij .* (x_gl,j - u)) over all j - sum_val = np.sum(temp_vol[~np.isnan(temp_vol[:])]) - # Running sum of (x_gl,i - u)*(wij .* (x_gl,j - u)) over all i - temp = temp + vol_mean[I[i-1], J[i-1], K[i-1]] * sum_val - - moran_i = temp*n_vox/sum_s/sum_w - - return moran_i - -def get_mesh_volume(faces: np.ndarray, - vertices:np.ndarray) -> float: - """Computes MeshVolume feature. - This feature refers to "Volume (mesh)" (ID = RNU0) - in the `IBSI1 reference manual `_. - - Args: - faces (np.ndarray): matrix of three column vectors, defining the [X,Y,Z] - positions of the ``faces`` of the isosurface or convex hull of the mask - (output from "isosurface.m" or "convhull.m" functions of MATLAB). - --> These are more precisely indexes to ``vertices`` - vertices (np.ndarray): matrix of three column vectors, defining the - [X,Y,Z] positions of the ``vertices`` of the isosurface of the mask (output - from "isosurface.m" function of MATLAB). - --> In mm. - - Returns: - float: Mesh volume - """ - faces = faces.copy() - vertices = vertices.copy() - - # Getting vectors for the three vertices - # (with respect to origin) of each face - a = vertices[faces[:, 0], :] - b = vertices[faces[:, 1], :] - c = vertices[faces[:, 2], :] - - # Calculating volume - v_cross = np.cross(b, c) - v_dot = np.sum(a.conj()*v_cross, axis=1) - volume = np.abs(np.sum(v_dot))/6 - - return volume - -def get_mesh_area(faces: np.ndarray, - vertices: np.ndarray) -> float: - """Computes the surface area (mesh) feature from the ROI mesh by - summing over the triangular face surface areas. - This feature refers to "Surface area (mesh)" (ID = C0JK) - in the `IBSI1 reference manual `_. - - Args: - faces (np.ndarray): matrix of three column vectors, defining the [X,Y,Z] - positions of the ``faces`` of the isosurface or convex hull of the mask - (output from "isosurface.m" or "convhull.m" functions of MATLAB). - --> These are more precisely indexes to ``vertices`` - vertices (np.ndarray): matrix of three column vectors, - defining the [X,Y,Z] - positions of the ``vertices`` of the isosurface of the mask (output - from "isosurface.m" function of MATLAB). - --> In mm. - - Returns: - float: Mesh area. - """ - - faces = faces.copy() - vertices = vertices.copy() - - # Getting two vectors of edges for each face - a = vertices[faces[:, 1], :] - vertices[faces[:, 0], :] - b = vertices[faces[:, 2], :] - vertices[faces[:, 0], :] - - # Calculating the surface area of each face and summing it up all at once. - c = np.cross(a, b) - area = 1/2 * np.sum(np.sqrt(np.sum(np.power(c, 2), 1))) - - return area - -def get_geary_c(vol: np.ndarray, - res: np.ndarray) -> float: - """Computes Geary'C measure (Assesses intensity differences between voxels). - This feature refers to "Geary's C measure" (ID = NPT7) - in the `IBSI1 reference manual `_. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - - Returns: - float: computes value of Geary'C measure. - """ - vol = vol.copy() - res = res.copy() - - # Find the location(s) of all non NaNs voxels - I, J, K = np.nonzero(~np.isnan(vol)) - n_vox = np.size(I) - - # Get the mean - u = np.mean(vol[~np.isnan(vol[:])]) - vol_m_mean_s = np.power((vol.copy() - u), 2) # (x_gl,i - u).^2 - - # Sum of (x_gl,i - u).^2 over all i - sum_s = np.sum(vol_m_mean_s[~np.isnan(vol_m_mean_s[:])]) - - # Get a meshgrid first - x = res[0]*((np.arange(1, np.shape(vol)[0]+1))-0.5) - y = res[1]*((np.arange(1, np.shape(vol)[1]+1))-0.5) - z = res[2]*((np.arange(1, np.shape(vol)[2]+1))-0.5) - X, Y, Z = np.meshgrid(x, y, z, indexing='ij') - - temp = 0 - sum_w = 0 - - for i in range(1, n_vox+1): - # Distance mesh - temp_x = X - X[I[i-1], J[i-1], K[i-1]] - temp_y = Y - Y[I[i-1], J[i-1], K[i-1]] - temp_z = Z - Z[I[i-1], J[i-1], K[i-1]] - - # meshgrid of weigths - temp_dist_mesh = 1/np.sqrt(temp_x**2 + temp_y**2 + temp_z**2) - - # Removing NaNs - temp_dist_mesh[np.isnan(vol)] = np.NaN - temp_dist_mesh[I[i-1], J[i-1], K[i-1]] = np.NaN - - # Running sum of weights - w_sum = np.sum(temp_dist_mesh[~np.isnan(temp_dist_mesh[:])]) - sum_w = sum_w + w_sum - - # Inside sum calculation - val = vol[I[i-1], J[i-1], K[i-1]].copy() # x_gl,i - # wij.*(x_gl,i - x_gl,j).^2 - temp_vol = temp_dist_mesh*(vol - val)**2 - - # Removing i voxel to be sure; - temp_vol[I[i-1], J[i-1], K[i-1]] = np.NaN - - # Sum of wij.*(x_gl,i - x_gl,j).^2 over all j - sum_val = np.sum(temp_vol[~np.isnan(temp_vol[:])]) - - # Running sum of (sum of wij.*(x_gl,i - x_gl,j).^2 over all j) over all i - temp = temp + sum_val - - geary_c = temp * (n_vox-1) / sum_s / (2*sum_w) - - return geary_c - -def min_vol_ellipse(P: np.ndarray, - tolerance: np.ndarray) -> Tuple[np.ndarray, - np.ndarray]: - """Computes min_vol_ellipse. - - Finds the minimum volume enclsing ellipsoid (MVEE) of a set of data - points stored in matrix P. The following optimization problem is solved: - - minimize $$log(det(A))$$ subject to $$(P_i - c)' * A * (P_i - c) <= 1$$ - - in variables A and c, where `P_i` is the `i-th` column of the matrix `P`. - The solver is based on Khachiyan Algorithm, and the final solution - is different from the optimal value by the pre-spesified amount of - `tolerance`. - - Note: - Adapted from MATLAB code of Nima Moshtagh (nima@seas.upenn.edu) - University of Pennsylvania. - - Args: - P (ndarray): (d x N) dimnesional matrix containing N points in R^d. - tolerance (ndarray): error in the solution with respect to the optimal value. - - Returns: - 2-element tuple containing - - - A: (d x d) matrix of the ellipse equation in the 'center form': \ - $$(x-c)' * A * (x-c) = 1$$ \ - where d is shape of `P` along 0-axis. - - - c: d-dimensional vector as the center of the ellipse. - - Examples: - - >>>P = rand(5,100) - - >>>[A, c] = :func:`min_vol_ellipse(P, .01)` - - To reduce the computation time, work with the boundary points only: - - >>>K = :func:`convhulln(P)` - - >>>K = :func:`unique(K(:))` - - >>>Q = :func:`P(:,K)` - - >>>[A, c] = :func:`min_vol_ellipse(Q, .01)` - """ - - # Solving the Dual problem - # data points - d, N = np.shape(P) - Q = np.ones((d+1, N)) - Q[:-1, :] = P[:, :] - - # initializations - err = 1 - u = np.ones(N)/N # 1st iteration - new_u = np.zeros(N) - - # Khachiyan Algorithm - - while (err > tolerance): - diag_u = np.diag(u) - trans_q = np.transpose(Q) - X = Q @ diag_u @ trans_q - - # M the diagonal vector of an NxN matrix - inv_x = np.linalg.inv(X) - M = np.diag(trans_q @ inv_x @ Q) - maximum = np.max(M) - j = np.argmax(M) - - step_size = (maximum - d - 1)/((d+1)*(maximum-1)) - new_u = (1 - step_size)*u.copy() - new_u[j] = new_u[j] + step_size - err = np.linalg.norm(new_u - u) - u = new_u.copy() - - # Computing the Ellipse parameters - # Finds the ellipse equation in the 'center form': - # (x-c)' * A * (x-c) = 1 - # It computes a dxd matrix 'A' and a d dimensional vector 'c' as the center - # of the ellipse. - U = np.diag(u) - - # the A matrix for the ellipse - c = P @ u - c = np.reshape(c, (np.size(c), 1), order='F') # center of the ellipse - - pup_t = P @ U @ np.transpose(P) - cct = c @ np.transpose(c) - a_inv = np.linalg.inv(pup_t - cct) - A = (1/d) * a_inv - - return A, c - -def padding(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: - """Padding the volume and masks. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - - Returns: - tuple of 3 ndarray: Volume and masks after padding. - """ - - # PADDING THE VOLUME WITH A LAYER OF NaNs - # (reduce mesh computation errors of associated mask) - vol = vol.copy() - vol = np.pad(vol, pad_width=1, mode="constant", constant_values=np.NaN) - # PADDING THE MASKS WITH A LAYER OF 0's - # (reduce mesh computation errors of associated mask) - mask_int = mask_int.copy() - mask_int = np.pad(mask_int, pad_width=1, mode="constant", constant_values=0.0) - mask_morph = mask_morph.copy() - mask_morph = np.pad(mask_morph, pad_width=1, mode="constant", constant_values=0.0) - - return vol, mask_int, mask_morph - -def get_variables(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> Tuple[np.ndarray, - np.ndarray, - np.ndarray, - np.ndarray, - np.ndarray, - np.ndarray, - np.ndarray]: - """Compute variables usefull to calculate morphological features. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - tuple of 7 ndarray: Variables usefull to calculate morphological features. - """ - # GETTING IMPORTANT VARIABLES - xgl_int = np.reshape(vol, np.size(vol), order='F')[np.where( - np.reshape(mask_int, np.size(mask_int), order='F') == 1)[0]].copy() - xgl_morph = np.reshape(vol, np.size(vol), order='F')[np.where( - np.reshape(mask_morph, np.size(mask_morph), order='F') == 1)[0]].copy() - # XYZ refers to [Xc,Yc,Zc] in ref. [1]. - xyz_int, _, _ = get_mesh(mask_int, res) - # XYZ refers to [Xc,Yc,Zc] in ref. [1]. - xyz_morph, faces, vertices = get_mesh(mask_morph, res) - # [X,Y,Z] points of the convex hull. - # conv_hull Matlab is conv_hull.simplices - conv_hull = sc.ConvexHull(vertices) - - return xgl_int, xgl_morph, xyz_int, xyz_morph, faces, vertices, conv_hull - -def extract_all(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray, - intensity_type: str, - compute_moran_i: bool=False, - compute_geary_c: bool=False) -> Dict: - """Compute Morphological Features. - This features refer to Morphological family in - the `IBSI1 reference manual `__. - - Note: - Moran's Index and Geary's C measure takes so much computation time. Please - use `compute_moran_i` `compute_geary_c` carefully. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - intensity_type (str): Type of intensity to compute. Can be "arbitrary", "definite" or "filtered". - Will compute all features for "definite" intensity type and all but intergrated intensity for - "arbitrary" intensity type. - compute_moran_i (bool, optional): True to compute Moran's Index. - compute_geary_c (bool, optional): True to compute Geary's C measure. - - Raises: - ValueError: If `intensity_type` is not "arbitrary", "definite" or "filtered". - """ - assert intensity_type in ["arbitrary", "definite", "filtered"], \ - "intensity_type must be 'arbitrary', 'definite' or 'filtered'" - - # Initialization of final structure (Dictionary) containing all features. - morph = {'Fmorph_vol': [], - 'Fmorph_approx_vol': [], - 'Fmorph_area': [], - 'Fmorph_av': [], - 'Fmorph_comp_1': [], - 'Fmorph_comp_2': [], - 'Fmorph_sph_dispr': [], - 'Fmorph_sphericity': [], - 'Fmorph_asphericity': [], - 'Fmorph_com': [], - 'Fmorph_diam': [], - 'Fmorph_pca_major': [], - 'Fmorph_pca_minor': [], - 'Fmorph_pca_least': [], - 'Fmorph_pca_elongation': [], - 'Fmorph_pca_flatness': [], # until here - 'Fmorph_v_dens_aabb': [], - 'Fmorph_a_dens_aabb': [], - 'Fmorph_v_dens_ombb': [], - 'Fmorph_a_dens_ombb': [], - 'Fmorph_v_dens_aee': [], - 'Fmorph_a_dens_aee': [], - 'Fmorph_v_dens_mvee': [], - 'Fmorph_a_dens_mvee': [], - 'Fmorph_v_dens_conv_hull': [], - 'Fmorph_a_dens_conv_hull': [], - 'Fmorph_integ_int': [], - 'Fmorph_moran_i': [], - 'Fmorph_geary_c': [] - } - #Initialization - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - xgl_int, xgl_morph, xyz_int, xyz_morph, faces, vertices, conv_hull = get_variables(vol, mask_int, mask_morph, res) - - # STARTING COMPUTATION - if intensity_type != "filtered": - # Volume in mm^3 - volume = get_mesh_volume(faces, vertices) - morph['Fmorph_vol'] = volume # Volume - - # Approximate Volume - morph['Fmorph_approx_vol'] = np.sum(mask_morph[:]) * np.prod(res) - - # Surface area in mm^2 - area = get_mesh_area(faces, vertices) - morph['Fmorph_area'] = area - - # Surface to volume ratio - morph['Fmorph_av'] = area / volume - - # Compactness 1 - morph['Fmorph_comp_1'] = volume / ((np.pi**(1/2))*(area**(3/2))) - - # Compactness 2 - morph['Fmorph_comp_2'] = 36*np.pi*(volume**2) / (area**3) - - # Spherical disproportion - morph['Fmorph_sph_dispr'] = area / (36*np.pi*volume**2)**(1/3) - - # Sphericity - morph['Fmorph_sphericity'] = ((36*np.pi*volume**2)**(1/3)) / area - - # Asphericity - morph['Fmorph_asphericity'] = ((area**3) / (36*np.pi*volume**2))**(1/3) - 1 - - # Centre of mass shift - morph['Fmorph_com'] = get_com(xgl_int, xgl_morph, xyz_int, xyz_morph) - - # Maximum 3D diameter - morph['Fmorph_diam'] = np.max(sc.distance.pdist(conv_hull.points[conv_hull.vertices])) - - # Major axis length - [major, minor, least] = get_axis_lengths(xyz_morph) - morph['Fmorph_pca_major'] = 4 * np.sqrt(major) - - # Minor axis length - morph['Fmorph_pca_minor'] = 4 * np.sqrt(minor) - - # Least axis length - morph['Fmorph_pca_least'] = 4 * np.sqrt(least) - - # Elongation - morph['Fmorph_pca_elongation'] = np.sqrt(minor / major) - - # Flatness - morph['Fmorph_pca_flatness'] = np.sqrt(least / major) - - # Volume density - axis-aligned bounding box - xc_aabb = np.max(vertices[:, 0]) - np.min(vertices[:, 0]) - yc_aabb = np.max(vertices[:, 1]) - np.min(vertices[:, 1]) - zc_aabb = np.max(vertices[:, 2]) - np.min(vertices[:, 2]) - v_aabb = xc_aabb * yc_aabb * zc_aabb - morph['Fmorph_v_dens_aabb'] = volume / v_aabb - - # Area density - axis-aligned bounding box - a_aabb = 2*xc_aabb*yc_aabb + 2*xc_aabb*zc_aabb + 2*yc_aabb*zc_aabb - morph['Fmorph_a_dens_aabb'] = area / a_aabb - - # Volume density - oriented minimum bounding box - # Implementation of Chan and Tan's algorithm (C.K. Chan, S.T. Tan. - # Determination of the minimum bounding box of an - # arbitrary solid: an iterative approach. - # Comp Struc 79 (2001) 1433-1449 - bound_box_dims = min_oriented_bound_box(vertices) - vol_bb = np.prod(bound_box_dims) - morph['Fmorph_v_dens_ombb'] = volume / vol_bb - - # Area density - oriented minimum bounding box - a_ombb = 2 * (bound_box_dims[0]*bound_box_dims[1] + - bound_box_dims[0]*bound_box_dims[2] + - bound_box_dims[1]*bound_box_dims[2]) - morph['Fmorph_a_dens_ombb'] = area / a_ombb - - # Volume density - approximate enclosing ellipsoid - a = 2*np.sqrt(major) - b = 2*np.sqrt(minor) - c = 2*np.sqrt(least) - v_aee = (4*np.pi*a*b*c) / 3 - morph['Fmorph_v_dens_aee'] = volume / v_aee - - # Area density - approximate enclosing ellipsoid - a_aee = get_area_dens_approx(a, b, c, 20) - morph['Fmorph_a_dens_aee'] = area / a_aee - - # Volume density - minimum volume enclosing ellipsoid - # (Rotate the volume first??) - # Copyright (c) 2009, Nima Moshtagh - # http://www.mathworks.com/matlabcentral/fileexchange/ - # 9542-minimum-volume-enclosing-ellipsoid - # Subsequent singular value decomposition of matrix A and and - # taking the inverse of the square root of the diagonal of the - # sigma matrix will produce respective semi-axis lengths. - # Subsequent singular value decomposition of matrix A and - # taking the inverse of the square root of the diagonal of the - # sigma matrix will produce respective semi-axis lengths. - p = np.stack((conv_hull.points[conv_hull.simplices[:, 0], 0], - conv_hull.points[conv_hull.simplices[:, 1], 1], - conv_hull.points[conv_hull.simplices[:, 2], 2]), axis=1) - A, _ = min_vol_ellipse(np.transpose(p), 0.01) - # New semi-axis lengths - _, Q, _ = np.linalg.svd(A) - a = 1/np.sqrt(Q[2]) - b = 1/np.sqrt(Q[1]) - c = 1/np.sqrt(Q[0]) - v_mvee = (4*np.pi*a*b*c)/3 - morph['Fmorph_v_dens_mvee'] = volume / v_mvee - - # Area density - minimum volume enclosing ellipsoid - # Using a new set of (a,b,c), see Volume density - minimum - # volume enclosing ellipsoid - a_mvee = get_area_dens_approx(a, b, c, 20) - morph['Fmorph_a_dens_mvee'] = area / a_mvee - - # Volume density - convex hull - v_convex = conv_hull.volume - morph['Fmorph_v_dens_conv_hull'] = volume / v_convex - - # Area density - convex hull - a_convex = conv_hull.area - morph['Fmorph_a_dens_conv_hull'] = area / a_convex - - # Integrated intensity - if intensity_type == "definite": - volume = get_mesh_volume(faces, vertices) - morph['Fmorph_integ_int'] = np.mean(xgl_int) * volume - - # Moran's I index - if compute_moran_i: - vol_mor = vol.copy() - vol_mor[mask_int == 0] = np.NaN - morph['Fmorph_moran_i'] = get_moran_i(vol_mor, res) - - # Geary's C measure - if compute_geary_c: - morph['Fmorph_geary_c'] = get_geary_c(vol_mor, res) - - return morph - -def vol(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes morphological volume feature. - This feature refers to "Fmorph_vol" (ID = RNUO) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the morphological volume feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - volume = get_mesh_volume(faces, vertices) - - return volume # Morphological volume feature - -def approx_vol(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes morphological approximate volume feature. - This feature refers to "Fmorph_approx_vol" (ID = YEKZ) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the morphological approximate volume feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - volume_appro = np.sum(mask_morph[:]) * np.prod(res) - - return volume_appro # Morphological approximate volume feature - -def area(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Surface area feature. - This feature refers to "Fmorph_area" (ID = COJJK) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the surface area feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - area = get_mesh_area(faces, vertices) - - return area # Surface area - -def av(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Surface to volume ratio feature. - This feature refers to "Fmorph_av" (ID = 2PR5) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Surface to volume ratio feature. - """ - _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - volume = get_mesh_volume(faces, vertices) - area = get_mesh_area(faces, vertices) - ratio = area / volume - - return ratio # Surface to volume ratio - -def comp_1(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Compactness 1 feature. - This feature refers to "Fmorph_comp_1" (ID = SKGS) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Compactness 1 feature. - """ - _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - volume = get_mesh_volume(faces, vertices) - area = get_mesh_area(faces, vertices) - comp_1 = volume / ((np.pi**(1/2))*(area**(3/2))) - - return comp_1 # Compactness 1 - -def comp_2(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Compactness 2 feature. - This feature refers to "Fmorph_comp_2" (ID = BQWJ) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Compactness 2 feature. - """ - _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - volume = get_mesh_volume(faces, vertices) - area = get_mesh_area(faces, vertices) - comp_2 = 36*np.pi*(volume**2) / (area**3) - - return comp_2 # Compactness 2 - -def sph_dispr(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Spherical disproportion feature. - This feature refers to "Fmorph_sph_dispr" (ID = KRCK) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Spherical disproportion feature. - """ - _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - volume = get_mesh_volume(faces, vertices) - area = get_mesh_area(faces, vertices) - sph_dispr = area / (36*np.pi*volume**2)**(1/3) - - return sph_dispr # Spherical disproportion - -def sphericity(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Sphericity feature. - This feature refers to "Fmorph_sphericity" (ID = QCFX) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Sphericity feature. - """ - _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - volume = get_mesh_volume(faces, vertices) - area = get_mesh_area(faces, vertices) - sphericity = ((36*np.pi*volume**2)**(1/3)) / area - - return sphericity # Sphericity - -def asphericity(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Asphericity feature. - This feature refers to "Fmorph_asphericity" (ID = 25C) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Asphericity feature. - """ - _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - volume = get_mesh_volume(faces, vertices) - area = get_mesh_area(faces, vertices) - asphericity = ((area**3) / (36*np.pi*volume**2))**(1/3) - 1 - - return asphericity # Asphericity - -def com(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Centre of mass shift feature. - This feature refers to "Fmorph_com" (ID = KLM) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Centre of mass shift feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - xgl_int, xgl_morph, xyz_int, xyz_morph, _, _, _ = get_variables(vol, mask_int, mask_morph, res) - com = get_com(xgl_int, xgl_morph, xyz_int, xyz_morph) - - return com # Centre of mass shift - -def diam(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Maximum 3D diameter feature. - This feature refers to "Fmorph_diam" (ID = L0JK) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Maximum 3D diameter feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, _, _, _, conv_hull = get_variables(vol, mask_int, mask_morph, res) - diam = np.max(sc.distance.pdist(conv_hull.points[conv_hull.vertices])) - - return diam # Maximum 3D diameter - -def pca_major(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Major axis length feature. - This feature refers to "Fmorph_pca_major" (ID = TDIC) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Major axis length feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, xyz_morph, _, _, _ = get_variables(vol, mask_int, mask_morph, res) - [major, _, _] = get_axis_lengths(xyz_morph) - pca_major = 4 * np.sqrt(major) - - return pca_major # Major axis length - -def pca_minor(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Minor axis length feature. - This feature refers to "Fmorph_pca_minor" (ID = P9VJ) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Minor axis length feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, xyz_morph, _, _, _ = get_variables(vol, mask_int, mask_morph, res) - [_, minor, _] = get_axis_lengths(xyz_morph) - pca_minor = 4 * np.sqrt(minor) - - return pca_minor # Minor axis length - -def pca_least(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Least axis length feature. - This feature refers to "Fmorph_pca_least" (ID = 7J51) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Least axis length feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, xyz_morph, _, _, _ = get_variables(vol, mask_int, mask_morph, res) - [_, _, least] = get_axis_lengths(xyz_morph) - pca_least = 4 * np.sqrt(least) - - return pca_least # Least axis length - -def pca_elongation(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Elongation feature. - This feature refers to "Fmorph_pca_elongation" (ID = Q3CK) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Elongation feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, xyz_morph, _, _, _ = get_variables(vol, mask_int, mask_morph, res) - [major, minor, _] = get_axis_lengths(xyz_morph) - pca_elongation = np.sqrt(minor / major) - - return pca_elongation # Elongation - -def pca_flatness(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Flatness feature. - This feature refers to "Fmorph_pca_flatness" (ID = N17B) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Flatness feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, xyz_morph, _, _, _ = get_variables(vol, mask_int, mask_morph, res) - [major, _, least] = get_axis_lengths(xyz_morph) - pca_flatness = np.sqrt(least / major) - - return pca_flatness # Flatness - -def v_dens_aabb(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Volume density - axis-aligned bounding box feature. - This feature refers to "Fmorph_v_dens_aabb" (ID = PBX1) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Volume density - axis-aligned bounding box feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - volume = get_mesh_volume(faces, vertices) - xc_aabb = np.max(vertices[:, 0]) - np.min(vertices[:, 0]) - yc_aabb = np.max(vertices[:, 1]) - np.min(vertices[:, 1]) - zc_aabb = np.max(vertices[:, 2]) - np.min(vertices[:, 2]) - v_aabb = xc_aabb * yc_aabb * zc_aabb - v_dens_aabb = volume / v_aabb - - return v_dens_aabb # Volume density - axis-aligned bounding box - -def a_dens_aabb(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Area density - axis-aligned bounding box feature. - This feature refers to "Fmorph_a_dens_aabb" (ID = R59B) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Area density - axis-aligned bounding box feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - area = get_mesh_area(faces, vertices) - xc_aabb = np.max(vertices[:, 0]) - np.min(vertices[:, 0]) - yc_aabb = np.max(vertices[:, 1]) - np.min(vertices[:, 1]) - zc_aabb = np.max(vertices[:, 2]) - np.min(vertices[:, 2]) - a_aabb = 2*xc_aabb*yc_aabb + 2*xc_aabb*zc_aabb + 2*yc_aabb*zc_aabb - a_dens_aabb = area / a_aabb - - return a_dens_aabb # Area density - axis-aligned bounding box - -def v_dens_ombb(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Volume density - oriented minimum bounding box feature. - Implementation of Chan and Tan's algorithm (C.K. Chan, S.T. Tan. - Determination of the minimum bounding box of an - arbitrary solid: an iterative approach. - Comp Struc 79 (2001) 1433-1449. - This feature refers to "Fmorph_v_dens_ombb" (ID = ZH1A) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Volume density - oriented minimum bounding box feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - volume = get_mesh_volume(faces, vertices) - bound_box_dims = min_oriented_bound_box(vertices) - vol_bb = np.prod(bound_box_dims) - v_dens_ombb = volume / vol_bb - - return v_dens_ombb # Volume density - oriented minimum bounding box - -def a_dens_ombb(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Area density - oriented minimum bounding box feature. - Implementation of Chan and Tan's algorithm (C.K. Chan, S.T. Tan. - Determination of the minimum bounding box of an - arbitrary solid: an iterative approach. - This feature refers to "Fmorph_a_dens_ombb" (ID = IQYR) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Area density - oriented minimum bounding box feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - area = get_mesh_area(faces, vertices) - - bound_box_dims = min_oriented_bound_box(vertices) - a_ombb = 2 * (bound_box_dims[0] * bound_box_dims[1] - + bound_box_dims[0] * bound_box_dims[2] - + bound_box_dims[1] * bound_box_dims[2]) - a_dens_ombb = area / a_ombb - - return a_dens_ombb # Area density - oriented minimum bounding box - -def v_dens_aee(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Volume density - approximate enclosing ellipsoid feature. - This feature refers to "Fmorph_v_dens_aee" (ID = 6BDE) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Volume density - approximate enclosing ellipsoid feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, xyz_morph, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - volume = get_mesh_volume(faces, vertices) - [major, minor, least] = get_axis_lengths(xyz_morph) - a = 2*np.sqrt(major) - b = 2*np.sqrt(minor) - c = 2*np.sqrt(least) - v_aee = (4*np.pi*a*b*c) / 3 - v_dens_aee = volume / v_aee - - return v_dens_aee # Volume density - approximate enclosing ellipsoid - -def a_dens_aee(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Area density - approximate enclosing ellipsoid feature. - This feature refers to "Fmorph_a_dens_aee" (ID = RDD2) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Area density - approximate enclosing ellipsoid feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, xyz_morph, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - area = get_mesh_area(faces, vertices) - [major, minor, least] = get_axis_lengths(xyz_morph) - a = 2*np.sqrt(major) - b = 2*np.sqrt(minor) - c = 2*np.sqrt(least) - a_aee = get_area_dens_approx(a, b, c, 20) - a_dens_aee = area / a_aee - - return a_dens_aee # Area density - approximate enclosing ellipsoid - -def v_dens_mvee(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Volume density - minimum volume enclosing ellipsoid feature. - Subsequent singular value decomposition of matrix A and and - taking the inverse of the square root of the diagonal of the - sigma matrix will produce respective semi-axis lengths. - Subsequent singular value decomposition of matrix A and - taking the inverse of the square root of the diagonal of the - sigma matrix will produce respective semi-axis lengths. - This feature refers to "Fmorph_v_dens_mvee" (ID = SWZ1) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Volume density - minimum volume enclosing ellipsoid feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, _, faces, vertices, conv_hull = get_variables(vol, mask_int, mask_morph, res) - volume = get_mesh_volume(faces, vertices) - p = np.stack((conv_hull.points[conv_hull.simplices[:, 0], 0], - conv_hull.points[conv_hull.simplices[:, 1], 1], - conv_hull.points[conv_hull.simplices[:, 2], 2]), axis=1) - A, _ = min_vol_ellipse(np.transpose(p), 0.01) - # New semi-axis lengths - _, Q, _ = np.linalg.svd(A) - a = 1/np.sqrt(Q[2]) - b = 1/np.sqrt(Q[1]) - c = 1/np.sqrt(Q[0]) - v_mvee = (4*np.pi*a*b*c) / 3 - v_dens_mvee = volume / v_mvee - - return v_dens_mvee # Volume density - minimum volume enclosing ellipsoid - -def a_dens_mvee(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Area density - minimum volume enclosing ellipsoid feature. - Subsequent singular value decomposition of matrix A and and - taking the inverse of the square root of the diagonal of the - sigma matrix will produce respective semi-axis lengths. - Subsequent singular value decomposition of matrix A and - taking the inverse of the square root of the diagonal of the - sigma matrix will produce respective semi-axis lengths. - This feature refers to "Fmorph_a_dens_mvee" (ID = BRI8) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Area density - minimum volume enclosing ellipsoid feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, _, faces, vertices, conv_hull = get_variables(vol, mask_int, mask_morph, res) - area = get_mesh_area(faces, vertices) - p = np.stack((conv_hull.points[conv_hull.simplices[:, 0], 0], - conv_hull.points[conv_hull.simplices[:, 1], 1], - conv_hull.points[conv_hull.simplices[:, 2], 2]), axis=1) - A, _ = min_vol_ellipse(np.transpose(p), 0.01) - # New semi-axis lengths - _, Q, _ = np.linalg.svd(A) - a = 1/np.sqrt(Q[2]) - b = 1/np.sqrt(Q[1]) - c = 1/np.sqrt(Q[0]) - a_mvee = get_area_dens_approx(a, b, c, 20) - a_dens_mvee = area / a_mvee - - return a_dens_mvee # Area density - minimum volume enclosing ellipsoid - -def v_dens_conv_hull(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Volume density - convex hull feature. - This feature refers to "Fmorph_v_dens_conv_hull" (ID = R3ER) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Volume density - convex hull feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, _, faces, vertices, conv_hull = get_variables(vol, mask_int, mask_morph, res) - volume = get_mesh_volume(faces, vertices) - v_convex = conv_hull.volume - v_dens_conv_hull = volume / v_convex - - return v_dens_conv_hull # Volume density - convex hull - -def a_dens_conv_hull(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Area density - convex hull feature. - This feature refers to "Fmorph_a_dens_conv_hull" (ID = 7T7F) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Area density - convex hull feature. - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - _, _, _, _, faces, vertices, conv_hull = get_variables(vol, mask_int, mask_morph, res) - area = get_mesh_area(faces, vertices) - v_convex = conv_hull.area - a_dens_conv_hull = area / v_convex - - return a_dens_conv_hull # Area density - convex hull - -def integ_int(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray) -> float: - """Computes Integrated intensity feature. - This feature refers to "Fmorph_integ_int" (ID = 99N0) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the Integrated intensity feature. - - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - xgl_int, _, _, _, faces, vertices, _ = get_variables(vol, mask_int, mask_morph, res) - volume = get_mesh_volume(faces, vertices) - integ_int = np.mean(xgl_int) * volume - - return integ_int # Integrated intensity - -def moran_i(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray, - compute_moran_i: bool=False) -> float: - """Computes Moran's I index feature. - This feature refers to "Fmorph_moran_i" (ID = N365) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - compute_moran_i (bool, optional): True to compute Moran's Index. - - Returns: - float: Value of the Moran's I index feature. - - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - - if compute_moran_i: - vol_mor = vol.copy() - vol_mor[mask_int == 0] = np.NaN - moran_i = get_moran_i(vol_mor, res) - - return moran_i # Moran's I index - -def geary_c(vol: np.ndarray, - mask_int: np.ndarray, - mask_morph: np.ndarray, - res: np.ndarray, - compute_geary_c: bool=False) -> float: - """Computes Geary's C measure feature. - This feature refers to "Fmorph_geary_c" (ID = NPT7) in - the `IBSI1 reference manual `__. - - Args: - vol (ndarray): 3D volume, NON-QUANTIZED, continous imaging intensity distribution. - mask_int (ndarray): Intensity mask. - mask_morph (ndarray): Morphological mask. - res (ndarray): [a,b,c] vector specfying the resolution of the volume in mm. - XYZ resolution (world), or JIK resolution (intrinsic matlab). - compute_geary_c (bool, optional): True to compute Geary's C measure. - - Returns: - float: Value of the Geary's C measure feature. - - """ - vol, mask_int, mask_morph = padding(vol, mask_int, mask_morph) - - if compute_geary_c: - vol_mor = vol.copy() - vol_mor[mask_int == 0] = np.NaN - geary_c = get_geary_c(vol_mor, res) - - return geary_c # Geary's C measure diff --git a/MEDimage/biomarkers/ngldm.py b/MEDimage/biomarkers/ngldm.py deleted file mode 100755 index a44eada..0000000 --- a/MEDimage/biomarkers/ngldm.py +++ /dev/null @@ -1,780 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from copy import deepcopy -from typing import Dict, List - -import numpy as np -import pandas as pd - -from ..utils.textureTools import (coord2index, get_neighbour_direction, - get_value, is_list_all_none) - - -def get_matrix(roi_only: np.array, - levels: np.ndarray) -> float: - """Computes Neighbouring grey level dependence matrix. - This matrix refers to "Neighbouring grey level dependence based features" (ID = REK0) - in the `IBSI1 reference manual `_. - - Args: - roi_only_int (ndarray): Smallest box containing the ROI, with the imaging data ready - for texture analysis computations. Voxels outside the ROI are - set to NaNs. - levels (ndarray or List): Vector containing the quantized gray-levels - in the tumor region (or reconstruction ``levels`` of quantization). - - Returns: - ndarray: Array of neighbouring grey level dependence matrix of ``roi_only``. - - """ - roi_only = roi_only.copy() - - # PRELIMINARY - level_temp = np.max(levels)+1 - roi_only[np.isnan(roi_only)] = level_temp - levels = np.append(levels, level_temp) - dim = np.shape(roi_only) - if np.size(dim) == 2: - np.append(dim, 1) - - q_2 = np.reshape(roi_only, np.prod(dim), order='F').astype("int") - - # QUANTIZATION EFFECTS CORRECTION (M. Vallieres) - # In case (for example) we initially wanted to have 64 levels, but due to - # quantization, only 60 resulted. - # q_s = round(levels*adjust)/adjust; - # q_2 = round(q_2*adjust)/adjust; - q_s = levels.copy() - - # EL NAQA CODE - q_3 = q_2*0 - lqs = np.size(q_s) - for k in range(1, lqs+1): - q_3[q_2 == q_s[k-1]] = k - - q_3 = np.reshape(q_3, dim, order='F') - - # Min dependence = 0, Max dependence = 26; So 27 columns - ngldm = np.zeros((lqs, 27)) - for i in range(1, dim[0]+1): - i_min = max(1, i-1) - i_max = min(i+1, dim[0]) - for j in range(1, dim[1]+1): - j_min = max(1, j-1) - j_max = min(j+1, dim[1]) - for k in range(1, dim[2]+1): - k_min = max(1, k-1) - k_max = min(k+1, dim[2]) - val_q3 = q_3[i-1, j-1, k-1] - count = 0 - for I2 in range(i_min, i_max+1): - for J2 in range(j_min, j_max+1): - for K2 in range(k_min, k_max+1): - if (I2 == i) & (J2 == j) & (K2 == k): - continue - else: - # a = 0 - if (val_q3 - q_3[I2-1, J2-1, K2-1] == 0): - count += 1 - - ngldm[val_q3-1, count] = ngldm[val_q3-1, count] + 1 - - # Last column was for the NaN voxels, to be removed - ngldm = np.delete(ngldm, -1, 0) - stop = np.nonzero(np.sum(ngldm, 0))[0][-1] - ngldm = np.delete(ngldm, range(stop+1, np.shape(ngldm)[1]+1), 1) - - return ngldm - -def extract_all(vol: np.ndarray) -> Dict : - """Compute NGLDM features - - Args: - vol (np.ndarray): volume with discretised intensities as 3D numpy array (x, y, z) - method (str, optional): Either 'old' (deprecated) or 'new' (faster) method. - - Raises: - ValueError: Ngldm should either be calculated using the faster \"new\" method, or the slow \"old\" method. - - Returns: - dict: Dictionary of NGTDM features. - """ - - ngldm = get_ngldm_features(vol=vol) - - return ngldm - - -def get_ngldm_features(vol: np.ndarray, - ngldm_spatial_method: str="3d", - ngldm_diff_lvl: float=0.0, - ngldm_dist: float=1.0) -> Dict: - """Extract neighbouring grey level dependence matrix-based features from the intensity roi mask. - These features refer to "Neighbouring grey level dependence based features" (ID = REK0) in - the `IBSI1 reference manual `__. - - Args: - - vol (ndarray): volume with discretised intensities as 3D numpy array (x, y, z). - intensity_range (ndarray): range of potential discretised intensities, - provided as a list: [minimal discretised intensity, maximal discretised intensity]. - If one or both values are unknown, replace the respective values with np.nan. - ngldm_spatial_method(str): spatial method which determines the way neighbouring grey level dependence - matrices are calculated and how features are determined. One of "2d", "2.5d" or "3d". - ngldm_diff_lvl (float): also called coarseness. Coarseness determines which intensity - differences are allowed for intensities to be considered similar. Typically 0, and - changing discretisation levels may have the same effect as increasing - the coarseness parameter. - ngldm_dist (float): the chebyshev distance that forms a local neighbourhood around a center voxel. - - Returns: - dict: dictionary with feature values. - """ - if type(ngldm_spatial_method) is not list: - ngldm_spatial_method = [ngldm_spatial_method] - - if type(ngldm_diff_lvl) is not list: - ngldm_diff_lvl = [ngldm_diff_lvl] - - if type(ngldm_dist) is not list: - ngldm_dist = [ngldm_dist] - - # Get the roi in tabular format - img_dims = vol.shape - index_id = np.arange(start=0, stop=vol.size) - coords = np.unravel_index(indices=index_id, shape=img_dims) - df_img = pd.DataFrame({"index_id": index_id, - "g": np.ravel(vol), - "x": coords[0], - "y": coords[1], - "z": coords[2], - "roi_int_mask": np.ravel(np.isfinite(vol))}) - - # Generate an empty feature list - feat_list = [] - - # Iterate over spatial arrangements - for ii_spatial in ngldm_spatial_method: - - # Iterate over difference levels - for ii_diff_lvl in ngldm_diff_lvl: - - # Iterate over distances - for ii_dist in ngldm_dist: - - # Initiate list of ngldm objects - ngldm_list = [] - - # Perform 2D analysis - if ii_spatial.lower() in ["2d", "2.5d"]: - - # Iterate over slices - for ii_slice in np.arange(0, img_dims[2]): - - # Add ngldm matrices to list - ngldm_list += [GreyLevelDependenceMatrix(distance=int(ii_dist), diff_lvl=ii_diff_lvl, - spatial_method=ii_spatial.lower(), img_slice=ii_slice)] - - # Perform 3D analysis - elif ii_spatial.lower() == "3d": - - # Add ngldm matrices to list - ngldm_list += [GreyLevelDependenceMatrix(distance=int(ii_dist), diff_lvl=ii_diff_lvl, - spatial_method=ii_spatial.lower(), img_slice=None)] - else: - raise ValueError("Spatial methods for ngldm should be \"2d\", \"2.5d\" or \"3d\".") - - # Calculate ngldm matrices - for ngldm in ngldm_list: - ngldm.calculate_ngldm_matrix(df_img=df_img, img_dims=img_dims) - - # Merge matrices according to the given method - upd_list = combine_ngldm_matrices(ngldm_list=ngldm_list, spatial_method=ii_spatial.lower()) - - # Calculate features - feat_run_list = [] - for ngldm in upd_list: - feat_run_list += [ngldm.compute_ngldm_features()] - - # Average feature values - feat_list += [pd.concat(feat_run_list, axis=0).mean(axis=0, skipna=True).to_frame().transpose()] - - # Merge feature tables into a single dictionary - df_feat = pd.concat(feat_list, axis=1).to_dict(orient="records")[0] - - return df_feat - - -def combine_ngldm_matrices(ngldm_list: List, - spatial_method: str) -> List: - """Function to merge neighbouring grey level dependence matrices prior to feature calculation. - - Args: - ngldm_list (List): list of GreyLevelDependenceMatrix objects. - spatial_method (str): spatial method which determines the way neighbouring grey level - dependence matrices are calculated and how features are determined. - One of "2d", "2.5d" or "3d". - - Returns: - List: list of one or more merged GreyLevelDependenceMatrix objects. - """ - # Initiate empty list - use_list = [] - - if spatial_method == "2d": - # Average features over slice: maintain original ngldms - - # Make copy of ngldm_list - use_list = [] - for ngldm in ngldm_list: - use_list += [ngldm.copy()] - - elif spatial_method in ["2.5d", "3d"]: - # Merge all ngldms into a single representation - - # Select all matrices within the slice - sel_matrix_list = [] - for ngldm_id in np.arange(len(ngldm_list)): - sel_matrix_list += [ngldm_list[ngldm_id].matrix] - - # Check if any matrix has been created - if is_list_all_none(sel_matrix_list): - # No matrix was created - use_list += [GreyLevelDependenceMatrix(distance=ngldm_list[0].distance, diff_lvl=ngldm_list[0].diff_lvl, - spatial_method=spatial_method, img_slice=None, matrix=None, n_v=0.0)] - else: - # Merge neighbouring grey level difference matrices - merge_ngldm = pd.concat(sel_matrix_list, axis=0) - merge_ngldm = merge_ngldm.groupby(by=["i", "j"]).sum().reset_index() - - # Update the number of voxels - merge_n_v = 0.0 - for ngldm_id in np.arange(len(ngldm_list)): - merge_n_v += ngldm_list[ngldm_id].n_v - - # Create new neighbouring grey level difference matrix - use_list += [GreyLevelDependenceMatrix(distance=ngldm_list[0].distance, diff_lvl=ngldm_list[0].diff_lvl, - spatial_method=spatial_method, img_slice=None, matrix=merge_ngldm, n_v=merge_n_v)] - else: - use_list = None - - # Return to new ngldm list to calling function - return use_list - - -class GreyLevelDependenceMatrix: - - def __init__(self, - distance: float, - diff_lvl: float, - spatial_method: str, - img_slice: np.ndarray=None, - matrix: np.ndarray=None, - n_v: float=None) -> None: - """Initialising function for a new neighbouring grey level dependence ``matrix``. - - Args: - distance (float): chebyshev ``distance`` used to determine the local neighbourhood. - diff_lvl (float): coarseness parameter which determines which intensities are considered similar. - spatial_method (str): spatial method used to calculate the ngldm: 2d, 2.5d or 3d - img_slice (ndarray): corresponding slice index (only if the ngldm corresponds to a 2d image slice) - matrix (ndarray): the actual ngldm in sparse format (row, column, count) - n_v (float): the number of voxels in the volume - """ - - # Distance used - self.distance = distance - self.diff_lvl = diff_lvl - - # Slice for which the current matrix is extracted - self.img_slice = img_slice - - # Spatial analysis method (2d, 2.5d, 3d) - self.spatial_method = spatial_method - - # Place holders - self.matrix = matrix - self.n_v = n_v - - def copy(self): - """Returns a copy of the GreyLevelDependenceMatrix object.""" - return deepcopy(self) - - def set_empty(self): - """Creates an empty GreyLevelDependenceMatrix""" - self.n_v = 0 - self.matrix = None - - def calculate_ngldm_matrix(self, - df_img: pd.DataFrame, - img_dims: int): - """ - Function that calculates an ngldm for the settings provided during initialisation and the input image. - - Args: - df_img (pd.DataFrame): data table containing image intensities, x, y and z coordinates, - and mask labels corresponding to voxels in the volume. - img_dims (int): dimensions of the image volume - """ - - # Check if the input image and roi exist - if df_img is None: - self.set_empty() - return - - # Check if the roi contains any masked voxels. If this is not the case, don't construct the ngldm. - if not np.any(df_img.roi_int_mask): - self.set_empty() - return - - if self.spatial_method == "3d": - # Set up neighbour vectors - nbrs = get_neighbour_direction(d=self.distance, distance="chebyshev", centre=False, complete=True, dim3=True) - - # Set up work copy - df_ngldm = deepcopy(df_img) - elif self.spatial_method in ["2d", "2.5d"]: - # Set up neighbour vectors - nbrs = get_neighbour_direction(d=self.distance, distance="chebyshev", centre=False, complete=True, dim3=False) - - # Set up work copy - df_ngldm = deepcopy(df_img[df_img.z == self.img_slice]) - df_ngldm["index_id"] = np.arange(0, len(df_ngldm)) - df_ngldm["z"] = 0 - df_ngldm = df_ngldm.reset_index(drop=True) - else: - raise ValueError("The spatial method for neighbouring grey level dependence matrices should be one of \"2d\", \"2.5d\" or \"3d\".") - - # Set grey level of voxels outside ROI to NaN - df_ngldm.loc[df_ngldm.roi_int_mask == False, "g"] = np.nan - - # Update number of voxels for current iteration - self.n_v = np.sum(df_ngldm.roi_int_mask.values) - - # Initialise sum of grey levels and number of neighbours - df_ngldm["occur"] = 0.0 - df_ngldm["n_nbrs"] = 0.0 - - for k in range(0, np.shape(nbrs)[1]): - # Determine potential transitions from valid voxels - df_ngldm["to_index"] = coord2index(x=df_ngldm.x.values + nbrs[2, k], - y=df_ngldm.y.values + nbrs[1, k], - z=df_ngldm.z.values + nbrs[0, k], - dims=img_dims) - - # Get grey level value from transitions - df_ngldm["to_g"] = get_value(x=df_ngldm.g.values, index=df_ngldm.to_index.values) - - # Determine which voxels have valid neighbours - sel_index = np.isfinite(df_ngldm.to_g) - - # Determine co-occurrence within diff_lvl - df_ngldm.loc[sel_index, "occur"] += ((np.abs(df_ngldm.to_g - df_ngldm.g)[sel_index]) <= self.diff_lvl) * 1 - - # Work with voxels within the intensity roi - df_ngldm = df_ngldm[df_ngldm.roi_int_mask] - - # Drop superfluous columns - df_ngldm = df_ngldm.drop(labels=["index_id", "x", "y", "z", "to_index", "to_g", "roi_int_mask"], axis=1) - - # Sum s over voxels - df_ngldm = df_ngldm.groupby(by=["g", "occur"]).size().reset_index(name="n") - - # Rename columns - df_ngldm.columns = ["i", "j", "s"] - - # Add one to dependency count as features are not defined for k=0 - df_ngldm.j += 1.0 - - # Add matrix to object - self.matrix = df_ngldm - - def compute_ngldm_features(self) -> pd.DataFrame: - """Computes neighbouring grey level dependence matrix features for the current neighbouring grey level dependence matrix. - - Returns: - pandas data frame: with values for each feature. - """ - # Create feature table - feat_names = ["Fngl_lde", - "Fngl_hde", - "Fngl_lgce", - "Fngl_hgce", - "Fngl_ldlge", - "Fngl_ldhge", - "Fngl_hdlge", - "Fngl_hdhge", - "Fngl_glnu", - "Fngl_glnu_norm", - "Fngl_dcnu", - "Fngl_dcnu_norm", - "Fngl_gl_var", - "Fngl_dc_var", - "Fngl_dc_entr", - "Fngl_dc_energy"] - df_feat = pd.DataFrame(np.full(shape=(1, len(feat_names)), fill_value=np.nan)) - df_feat.columns = feat_names - - # Don't return data for empty slices or slices without a good matrix - if self.matrix is None: - # Update names - # df_feat.columns += self.parse_feature_names() - return df_feat - elif len(self.matrix) == 0: - # Update names - # df_feat.columns += self.parse_feature_names() - return df_feat - - # Dependence count dataframe - df_sij = deepcopy(self.matrix) - df_sij.columns = ("i", "j", "sij") - - # Sum over grey levels - df_si = df_sij.groupby(by="i")["sij"].agg(np.sum).reset_index().rename(columns={"sij": "si"}) - - # Sum over dependence counts - df_sj = df_sij.groupby(by="j")["sij"].agg(np.sum).reset_index().rename(columns={"sij": "sj"}) - - # Constant definitions - n_s = np.sum(df_sij.sij) * 1.0 # Number of neighbourhoods considered - n_v = self.n_v # Number of voxels - - ############################################### - # ngldm features - ############################################### - - # Low dependence emphasis - df_feat.loc[0, "Fngl_lde"] = np.sum(df_sj.sj / df_sj.j ** 2.0) / n_s - - # High dependence emphasis - df_feat.loc[0, "Fngl_hde"] = np.sum(df_sj.sj * df_sj.j ** 2.0) / n_s - - # Grey level non-uniformity - df_feat.loc[0, "Fngl_glnu"] = np.sum(df_si.si ** 2.0) / n_s - - # Grey level non-uniformity, normalised - df_feat.loc[0, "Fngl_glnu_norm"] = np.sum(df_si.si ** 2.0) / n_s ** 2.0 - - # Dependence count non-uniformity - df_feat.loc[0, "Fngl_dcnu"] = np.sum(df_sj.sj ** 2.0) / n_s - - # Dependence count non-uniformity, normalised - df_feat.loc[0, "Fngl_dcnu_norm"] = np.sum(df_sj.sj ** 2.0) / n_s ** 2.0 - - # Dependence count percentage - # df_feat.loc[0, "ngl_dc_perc"] = n_s / n_v - - # Low grey level count emphasis - df_feat.loc[0, "Fngl_lgce"] = np.sum(df_si.si / df_si.i ** 2.0) / n_s - - # High grey level count emphasis - df_feat.loc[0, "Fngl_hgce"] = np.sum(df_si.si * df_si.i ** 2.0) / n_s - - # Low dependence low grey level emphasis - df_feat.loc[0, "Fngl_ldlge"] = np.sum(df_sij.sij / (df_sij.i * df_sij.j) ** 2.0) / n_s - - # Low dependence high grey level emphasis - df_feat.loc[0, "Fngl_ldhge"] = np.sum(df_sij.sij * df_sij.i ** 2.0 / df_sij.j ** 2.0) / n_s - - # High dependence low grey level emphasis - df_feat.loc[0, "Fngl_hdlge"] = np.sum(df_sij.sij * df_sij.j ** 2.0 / df_sij.i ** 2.0) / n_s - - # High dependence high grey level emphasis - df_feat.loc[0, "Fngl_hdhge"] = np.sum(df_sij.sij * df_sij.i ** 2.0 * df_sij.j ** 2.0) / n_s - - # Grey level variance - mu = np.sum(df_sij.sij * df_sij.i) / n_s - df_feat.loc[0, "Fngl_gl_var"] = np.sum((df_sij.i - mu) ** 2.0 * df_sij.sij) / n_s - del mu - - # Dependence count variance - mu = np.sum(df_sij.sij * df_sij.j) / n_s - df_feat.loc[0, "Fngl_dc_var"] = np.sum((df_sij.j - mu) ** 2.0 * df_sij.sij) / n_s - del mu - - # Dependence count entropy - df_feat.loc[0, "Fngl_dc_entr"] = - np.sum(df_sij.sij * np.log2(df_sij.sij / n_s)) / n_s - - # Dependence count energy - df_feat.loc[0, "Fngl_dc_energy"] = np.sum(df_sij.sij ** 2.0) / (n_s ** 2.0) - - # Update names - # df_feat.columns += self.parse_feature_names() - - return df_feat - - def parse_feature_names(self): - """ - Adds additional settings-related identifiers to each feature. - Not used currently, as the use of different settings for the - neighbouring grey level dependence matrix is not supported. - """ - parse_str = "" - - # Add distance - parse_str += "_d" + str(np.round(self.distance, 1)) - - # Add difference level - parse_str += "_a" + str(np.round(self.diff_lvl, 0)) - - # Add spatial method - if self.spatial_method is not None: - parse_str += "_" + self.spatial_method - - return parse_str - -def get_dict(vol: np.ndarray) -> dict: - """ - Extract neighbouring grey level dependence matrix-based features from the intensity roi mask. - - Args: - vol (ndarray): volume with discretised intensities as 3D numpy array (x, y, z) - - Returns: - dict: dictionary with feature values - - """ - ngldm_dict = get_ngldm_features(vol, intensity_range=[np.nan, np.nan]) - return ngldm_dict - -def lde(ngldm_dict: np.ndarray)-> float: - """ - Computes low dependence emphasis feature. - This feature refers to "Fngl_lde" (ID = SODN) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: low depence emphasis value - - """ - return ngldm_dict["Fngl_lde"] - -def hde(ngldm_dict: np.ndarray)-> float: - """ - Computes high dependence emphasis feature. - This feature refers to "Fngl_hde" (ID = IMOQ) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: high depence emphasis value - - """ - return ngldm_dict["Fngl_hde"] - -def lgce(ngldm_dict: np.ndarray)-> float: - """ - Computes low grey level count emphasis feature. - This feature refers to "Fngl_lgce" (ID = TL9H) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: low grey level count emphasis value - - """ - return ngldm_dict["Fngl_lgce"] - -def hgce(ngldm_dict: np.ndarray)-> float: - """ - Computes high grey level count emphasis feature. - This feature refers to "Fngl_hgce" (ID = OAE7) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: high grey level count emphasis value - - """ - return ngldm_dict["Fngl_hgce"] - -def ldlge(ngldm_dict: np.ndarray)-> float: - """ - Computes low dependence low grey level emphasis feature. - This feature refers to "Fngl_ldlge" (ID = EQ3F) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: low dependence low grey level emphasis value - - """ - return ngldm_dict["Fngl_ldlge"] - -def ldhge(ngldm_dict: np.ndarray)-> float: - """ - Computes low dependence high grey level emphasis feature. - This feature refers to "Fngl_ldhge" (ID = JA6D) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: low dependence high grey level emphasis value - - """ - return ngldm_dict["Fngl_ldhge"] - -def hdlge(ngldm_dict: np.ndarray)-> float: - """ - Computes high dependence low grey level emphasis feature. - This feature refers to "Fngl_hdlge" (ID = NBZI) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: high dependence low grey level emphasis value - - """ - return ngldm_dict["Fngl_hdlge"] - -def hdhge(ngldm_dict: np.ndarray)-> float: - """ - Computes high dependence high grey level emphasis feature. - This feature refers to "Fngl_hdhge" (ID = 9QMG) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: high dependence high grey level emphasis value - - """ - return ngldm_dict["Fngl_hdhge"] - -def glnu(ngldm_dict: np.ndarray)-> float: - """ - Computes grey level non-uniformity feature. - This feature refers to "Fngl_glnu" (ID = FP8K) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: grey level non-uniformity value - - """ - return ngldm_dict["Fngl_glnu"] - -def glnu_norm(ngldm_dict: np.ndarray)-> float: - """ - Computes grey level non-uniformity normalised feature. - This feature refers to "Fngl_glnu_norm" (ID = 5SPA) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: grey level non-uniformity normalised value - - """ - return ngldm_dict["Fngl_glnu_norm"] - -def dcnu(ngldm_dict: np.ndarray)-> float: - """ - Computes dependence count non-uniformity feature. - This feature refers to "Fngl_dcnu" (ID = Z87G) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: dependence count non-uniformity value - - """ - return ngldm_dict["Fngl_dcnu"] - -def dcnu_norm(ngldm_dict: np.ndarray)-> float: - """ - Computes dependence count non-uniformity normalised feature. - This feature refers to "Fngl_dcnu_norm" (ID = OKJI) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: dependence count non-uniformity normalised value - - """ - return ngldm_dict["Fngl_dcnu_norm"] - -def gl_var(ngldm_dict: np.ndarray)-> float: - """ - Computes grey level variance feature. - This feature refers to "Fngl_gl_var" (ID = 1PFV) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: grey level variance value - - """ - return ngldm_dict["Fngl_gl_var"] - -def dc_var(ngldm_dict: np.ndarray)-> float: - """ - Computes dependence count variance feature. - This feature refers to "Fngl_dc_var" (ID = DNX2) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: dependence count variance value - - """ - return ngldm_dict["Fngl_dc_var"] - -def dc_entr(ngldm_dict: np.ndarray)-> float: - """ - Computes dependence count entropy feature. - This feature refers to "Fngl_dc_entr" (ID = FCBV) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: dependence count entropy value - - """ - return ngldm_dict["Fngl_dc_entr"] - -def dc_energy(ngldm_dict: np.ndarray)-> float: - """ - Computes dependence count energy feature. - This feature refers to "Fngl_dc_energy" (ID = CAS9) in - the `IBSI1 reference manual `__. - - Args: - ngldm (ndarray): array of neighbouring grey level dependence matrix - - Returns: - float: dependence count energy value - - """ - return ngldm_dict["Fngl_dc_energy"] diff --git a/MEDimage/biomarkers/ngtdm.py b/MEDimage/biomarkers/ngtdm.py deleted file mode 100755 index d3a3e6d..0000000 --- a/MEDimage/biomarkers/ngtdm.py +++ /dev/null @@ -1,414 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - - -from typing import Dict, Tuple, Union - -import numpy as np - - -def get_matrix(roi_only:np.ndarray, - levels:np.ndarray, - dist_correction: bool=False) -> Tuple[np.ndarray, np.ndarray]: - """This function computes the Neighborhood Gray-Tone Difference Matrix - (NGTDM) of the region of interest (ROI) of an input volume. The input - volume is assumed to be isotropically resampled. The ngtdm is computed - using 26-voxel connectivity. To account for discretization length - differences, all averages around a center voxel are performed such that - the neighbours at a distance of :math:`\sqrt{3}` voxels are given a weight of - :math:`\sqrt{3}`, and the neighbours at a distance of :math:`\sqrt{2}` voxels are given a - weight of :math:`\sqrt{2}`. - This matrix refers to "Neighbourhood grey tone difference based features" (ID = IPET) - in the `IBSI1 reference manual `_. - - Note: - This function is compatible with 2D analysis (language not adapted in the text) - - Args: - roi_only (ndarray): Smallest box containing the ROI, with the imaging data ready - for texture analysis computations. Voxels outside the ROI are set to NaNs. - levels (ndarray): Vector containing the quantized gray-levels in the tumor region - (or reconstruction ``levels`` of quantization). - dist_correction (bool, optional): Set this variable to true in order to use - discretization length difference corrections as used by the `Institute of Physics and - Engineering in Medicine `_. - Set this variable to false to replicate IBSI results. - - Returns: - Tuple[np.ndarray, np.ndarray]: - - ngtdm: Neighborhood Gray-Tone Difference Matrix of ``roi_only'``. - - count_valid: Array of number of valid voxels used in the ngtdm computation. - - REFERENCES: - [1] Amadasun, M., & King, R. (1989). Textural Features Corresponding to - Textural Properties. IEEE Transactions on Systems Man and Cybernetics, - 19(5), 1264–1274. - - """ - - # PARSING "dist_correction" ARGUMENT - if type(dist_correction) is not bool: - # The user did not input either "true" or "false", - # so the default behavior is used. - dist_correction = True # By default - - # PRELIMINARY - if np.size(np.shape(roi_only)) == 2: # generalization to 2D inputs - two_d = 1 - else: - two_d = 0 - - roi_only = np.pad(roi_only, [1, 1], 'constant', constant_values=np.NaN) - - # # QUANTIZATION EFFECTS CORRECTION - # # In case (for example) we initially wanted to have 64 levels, but due to - # # quantization, only 60 resulted. - unique_vol = levels.astype('int') - NL = np.size(levels) - temp = roi_only.copy().astype('int') - for i in range(1, NL+1): - roi_only[temp == unique_vol[i-1]] = i - - # INTIALIZATION - ngtdm = np.zeros(NL) - count_valid = np.zeros(NL) - - # COMPUTATION OF ngtdm - if two_d: - indices = np.where(~np.isnan(np.reshape( - roi_only, np.size(roi_only), order='F')))[0] - pos_valid = np.unravel_index(indices, np.shape(roi_only), order='F') - n_valid_temp = np.size(pos_valid[0]) - w4 = 1 - if dist_correction: - # Weights given to different neighbors to correct - # for discretization length differences - w8 = 1/np.sqrt(2) - else: - w8 = 1 - - weights = np.array([w8, w4, w8, w4, w4, w4, w8, w4, w8]) - - for n in range(1, n_valid_temp+1): - - neighbours = roi_only[(pos_valid[0][n-1]-1):(pos_valid[0][n-1]+2), - (pos_valid[1][n-1]-1):(pos_valid[1][n-1]+2)].copy() - neighbours = np.reshape(neighbours, 9, order='F') - neighbours = neighbours*weights - value = neighbours[4].astype('int') - neighbours[4] = np.NaN - neighbours = neighbours/np.sum(weights[~np.isnan(neighbours)]) - neighbours = np.delete(neighbours, 4) # Remove the center voxel - # Thus only excluding voxels with NaNs only as neighbors. - if np.size(neighbours[~np.isnan(neighbours)]) > 0: - ngtdm[value-1] = ngtdm[value-1] + np.abs( - value-np.sum(neighbours[~np.isnan(neighbours)])) - count_valid[value-1] = count_valid[value-1] + 1 - else: - - indices = np.where(~np.isnan(np.reshape( - roi_only, np.size(roi_only), order='F')))[0] - pos_valid = np.unravel_index(indices, np.shape(roi_only), order='F') - n_valid_temp = np.size(pos_valid[0]) - w6 = 1 - if dist_correction: - # Weights given to different neighbors to correct - # for discretization length differences - w26 = 1 / np.sqrt(3) - w18 = 1 / np.sqrt(2) - else: - w26 = 1 - w18 = 1 - - weights = np.array([w26, w18, w26, w18, w6, w18, w26, w18, w26, w18, - w6, w18, w6, w6, w6, w18, w6, w18, w26, w18, - w26, w18, w6, w18, w26, w18, w26]) - - for n in range(1, n_valid_temp+1): - neighbours = roi_only[(pos_valid[0][n-1]-1) : (pos_valid[0][n-1]+2), - (pos_valid[1][n-1]-1) : (pos_valid[1][n-1]+2), - (pos_valid[2][n-1]-1) : (pos_valid[2][n-1]+2)].copy() - neighbours = np.reshape(neighbours, 27, order='F') - neighbours = neighbours * weights - value = neighbours[13].astype('int') - neighbours[13] = np.NaN - neighbours = neighbours / np.sum(weights[~np.isnan(neighbours)]) - neighbours = np.delete(neighbours, 13) # Remove the center voxel - # Thus only excluding voxels with NaNs only as neighbors. - if np.size(neighbours[~np.isnan(neighbours)]) > 0: - ngtdm[value-1] = ngtdm[value-1] + np.abs(value - np.sum(neighbours[~np.isnan(neighbours)])) - count_valid[value-1] = count_valid[value-1] + 1 - - return ngtdm, count_valid - -def extract_all(vol: np.ndarray, - dist_correction :Union[bool, str]=None) -> Dict: - """Compute Neighbourhood grey tone difference based features. - These features refer to "Neighbourhood grey tone difference based features" (ID = IPET) in - the `IBSI1 reference manual `__. - - Args: - - vol (ndarray): 3D volume, isotropically resampled, quantized - (e.g. n_g = 32, levels = [1, ..., n_g]), with NaNs outside the region - of interest. - dist_correction (Union[bool, str], optional): Set this variable to true in order to use - discretization length difference corrections as used - by the `Institute of Physics and Engineering in - Medicine `__. - Set this variable to false to replicate IBSI results. - Or use string and specify the norm for distance weighting. - Weighting is only performed if this argument is - "manhattan", "euclidean" or "chebyshev". - - Returns: - Dict: Dict of Neighbourhood grey tone difference based features. - """ - ngtdm_features = {'Fngt_coarseness': [], - 'Fngt_contrast': [], - 'Fngt_busyness': [], - 'Fngt_complexity': [], - 'Fngt_strength': []} - - # GET THE NGTDM MATRIX - # Correct definition, without any assumption - levels = np.arange(1, np.max(vol[~np.isnan(vol[:])].astype("int"))+1) - ngtdm, count_valid = get_matrix(vol, levels, dist_correction) - - n_tot = np.sum(count_valid) - # Now representing the probability of gray-level occurences - count_valid = count_valid/n_tot - nl = np.size(ngtdm) - n_g = np.sum(count_valid != 0) - p_valid = np.where(np.reshape(count_valid, np.size( - count_valid), order='F') > 0)[0]+1 - n_valid = np.size(p_valid) - - # COMPUTING TEXTURES - # Coarseness - coarseness = 1 / np.matmul(np.transpose(count_valid), ngtdm) - coarseness = min(coarseness, 10**6) - ngtdm_features['Fngt_coarseness'] = coarseness - - # Contrast - if n_g == 1: - ngtdm_features['Fngt_contrast'] = 0 - else: - val = 0 - for i in range(1, nl+1): - for j in range(1, nl+1): - val = val + count_valid[i-1] * count_valid[j-1] * ((i-j)**2) - ngtdm_features['Fngt_contrast'] = val * np.sum(ngtdm) / (n_g*(n_g-1)*n_tot) - - # Busyness - if n_g == 1: - ngtdm_features['Fngt_busyness'] = 0 - else: - denom = 0 - for i in range(1, n_valid+1): - for j in range(1, n_valid+1): - denom = denom + np.abs(p_valid[i-1]*count_valid[p_valid[i-1]-1] - - p_valid[j-1]*count_valid[p_valid[j-1]-1]) - ngtdm_features['Fngt_busyness'] = np.matmul(np.transpose(count_valid), ngtdm) / denom - - # Complexity - val = 0 - for i in range(1, n_valid+1): - for j in range(1, n_valid+1): - val = val + (np.abs( - p_valid[i-1]-p_valid[j-1]) / (n_tot*( - count_valid[p_valid[i-1]-1] + - count_valid[p_valid[j-1]-1])))*( - count_valid[p_valid[i-1]-1]*ngtdm[p_valid[i-1]-1] + - count_valid[p_valid[j-1]-1]*ngtdm[p_valid[j-1]-1]) - - ngtdm_features['Fngt_complexity'] = val - - # Strength - if np.sum(ngtdm) == 0: - ngtdm_features['Fngt_strength'] = 0 - else: - val = 0 - for i in range(1, n_valid+1): - for j in range(1, n_valid+1): - val = val + (count_valid[p_valid[i-1]-1] + count_valid[p_valid[j-1]-1])*( - p_valid[i-1]-p_valid[j-1])**2 - - ngtdm_features['Fngt_strength'] = val/np.sum(ngtdm) - - return ngtdm_features - -def get_single_matrix(vol: np.ndarray, - dist_correction = None)-> Tuple[np.ndarray, - np.ndarray]: - """Compute neighbourhood grey tone difference matrix in order to compute the single features. - - Args: - - vol (ndarray): 3D volume, isotropically resampled, quantized - (e.g. n_g = 32, levels = [1, ..., n_g]), with NaNs outside the region of interest. - dist_correction (Union[bool, str], optional): Set this variable to true in order to use - discretization length difference corrections as used - by the `Institute of Physics and Engineering in - Medicine `__. - Set this variable to false to replicate IBSI results. - Or use string and specify the norm for distance weighting. - Weighting is only performed if this argument is - "manhattan", "euclidean" or "chebyshev". - - Returns: - np.ndarray: array of neighbourhood grey tone difference matrix - """ - # GET THE NGTDM MATRIX - # Correct definition, without any assumption - levels = np.arange(1, np.max(vol[~np.isnan(vol[:])].astype("int"))+1) - - ngtdm, count_valid = get_matrix(vol, levels, dist_correction) - - return ngtdm, count_valid - -def coarseness(ngtdm: np.ndarray, count_valid: np.ndarray)-> float: - """ - Computes coarseness feature. - This feature refers to "Coarseness" (ID = QCDE) in - the `IBSI1 reference manual `__. - - Args: - ngtdm (ndarray): array of neighbourhood grey tone difference matrix - - Returns: - float: coarseness value - - """ - n_tot = np.sum(count_valid) - count_valid = count_valid/n_tot - coarseness = 1 / np.matmul(np.transpose(count_valid), ngtdm) - coarseness = min(coarseness, 10**6) - return coarseness - -def contrast(ngtdm: np.ndarray, count_valid: np.ndarray)-> float: - """ - Computes contrast feature. - This feature refers to "Contrast" (ID = 65HE) in - the `IBSI1 reference manual `__. - - Args: - ngtdm (ndarray): array of neighbourhood grey tone difference matrix - - Returns: - float: contrast value - - """ - n_tot = np.sum(count_valid) - count_valid = count_valid/n_tot - nl = np.size(ngtdm) - n_g = np.sum(count_valid != 0) - - if n_g == 1: - return 0 - else: - val = 0 - for i in range(1, nl+1): - for j in range(1, nl+1): - val = val + count_valid[i-1] * count_valid[j-1] * ((i-j)**2) - contrast = val * np.sum(ngtdm) / (n_g*(n_g-1)*n_tot) - return contrast - -def busyness(ngtdm: np.ndarray, count_valid: np.ndarray)-> float: - """ - Computes busyness feature. - This feature refers to "Busyness" (ID = NQ30) in - the `IBSI1 reference manual `__. - - Args: - ngtdm (ndarray): array of neighbourhood grey tone difference matrix - - Returns: - float: busyness value - - """ - n_tot = np.sum(count_valid) - count_valid = count_valid/n_tot - n_g = np.sum(count_valid != 0) - p_valid = np.where(np.reshape(count_valid, np.size( - count_valid), order='F') > 0)[0]+1 - n_valid = np.size(p_valid) - - if n_g == 1: - busyness = 0 - return busyness - else: - denom = 0 - for i in range(1, n_valid+1): - for j in range(1, n_valid+1): - denom = denom + np.abs(p_valid[i-1]*count_valid[p_valid[i-1]-1] - - p_valid[j-1]*count_valid[p_valid[j-1]-1]) - busyness = np.matmul(np.transpose(count_valid), ngtdm) / denom - return busyness - -def complexity(ngtdm: np.ndarray, count_valid: np.ndarray)-> float: - """ - Computes complexity feature. - This feature refers to "Complexity" (ID = HDEZ) in - the `IBSI1 reference manual `__. - - Args: - ngtdm (ndarray): array of neighbourhood grey tone difference matrix - - Returns: - float: complexity value - - """ - n_tot = np.sum(count_valid) - # Now representing the probability of gray-level occurences - count_valid = count_valid/n_tot - p_valid = np.where(np.reshape(count_valid, np.size( - count_valid), order='F') > 0)[0]+1 - n_valid = np.size(p_valid) - - val = 0 - for i in range(1, n_valid+1): - for j in range(1, n_valid+1): - val = val + (np.abs( - p_valid[i-1]-p_valid[j-1]) / (n_tot*( - count_valid[p_valid[i-1]-1] + - count_valid[p_valid[j-1]-1])))*( - count_valid[p_valid[i-1]-1]*ngtdm[p_valid[i-1]-1] + - count_valid[p_valid[j-1]-1]*ngtdm[p_valid[j-1]-1]) - complexity = val - return complexity - -def strength(ngtdm: np.ndarray, count_valid: np.ndarray)-> float: - """ - Computes strength feature. - This feature refers to "Strength" (ID = 1X9X) in - the `IBSI1 reference manual `__. - - Args: - ngtdm (ndarray): array of neighbourhood grey tone difference matrix - - Returns: - float: strength value - - """ - - n_tot = np.sum(count_valid) - # Now representing the probability of gray-level occurences - count_valid = count_valid/n_tot - p_valid = np.where(np.reshape(count_valid, np.size( - count_valid), order='F') > 0)[0]+1 - n_valid = np.size(p_valid) - - if np.sum(ngtdm) == 0: - strength = 0 - return strength - else: - val = 0 - for i in range(1, n_valid+1): - for j in range(1, n_valid+1): - val = val + (count_valid[p_valid[i-1]-1] + count_valid[p_valid[j-1]-1])*( - p_valid[i-1]-p_valid[j-1])**2 - - strength = val/np.sum(ngtdm) - return strength diff --git a/MEDimage/biomarkers/stats.py b/MEDimage/biomarkers/stats.py deleted file mode 100755 index d6cb220..0000000 --- a/MEDimage/biomarkers/stats.py +++ /dev/null @@ -1,373 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import numpy as np -from scipy.stats import iqr, kurtosis, skew, scoreatpercentile, variation - - -def extract_all(vol: np.ndarray, intensity_type: str) -> dict: - """Computes Intensity-based statistical features. - These features refer to "Intensity-based statistical features" (ID = UHIW) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution). - intensity_type (str): Type of intensity to compute. Can be "arbitrary", "definite" or "filtered". - Will compute features only for "definite" intensity type. - - Return: - dict: Dictionnary containing all stats features. - - Raises: - ValueError: If `intensity_type` is not "arbitrary", "definite" or "filtered". - """ - assert intensity_type in ["arbitrary", "definite", "filtered"], \ - "intensity_type must be 'arbitrary', 'definite' or 'filtered'" - - x = vol[~np.isnan(vol[:])] # Initialization - - # Initialization of final structure (Dictionary) containing all features. - stats = {'Fstat_mean': [], - 'Fstat_var': [], - 'Fstat_skew': [], - 'Fstat_kurt': [], - 'Fstat_median': [], - 'Fstat_min': [], - 'Fstat_P10': [], - 'Fstat_P90': [], - 'Fstat_max': [], - 'Fstat_iqr': [], - 'Fstat_range': [], - 'Fstat_mad': [], - 'Fstat_rmad': [], - 'Fstat_medad': [], - 'Fstat_cov': [], - 'Fstat_qcod': [], - 'Fstat_energy': [], - 'Fstat_rms': [] - } - - # STARTING COMPUTATION - if intensity_type == "definite": - stats['Fstat_mean'] = np.mean(x) # Mean - stats['Fstat_var'] = np.var(x) # Variance - stats['Fstat_skew'] = skew(x) # Skewness - stats['Fstat_kurt'] = kurtosis(x) # Kurtosis - stats['Fstat_median'] = np.median(x) # Median - stats['Fstat_min'] = np.min(x) # Minimum grey level - stats['Fstat_P10'] = scoreatpercentile(x, 10) # 10th percentile - stats['Fstat_P90'] = scoreatpercentile(x, 90) # 90th percentile - stats['Fstat_max'] = np.max(x) # Maximum grey level - stats['Fstat_iqr'] = iqr(x) # Interquantile range - stats['Fstat_range'] = np.ptp(x) # Range max(x) - min(x) - stats['Fstat_mad'] = np.mean(np.absolute(x - np.mean(x))) # Mean absolute deviation - x_10_90 = x[np.where((x >= stats['Fstat_P10']) & - (x <= stats['Fstat_P90']), True, False)] - stats['Fstat_rmad'] = np.mean(np.abs(x_10_90 - np.mean(x_10_90))) # Robust mean absolute deviation - stats['Fstat_medad'] = np.mean(np.absolute(x - np.median(x))) # Median absolute deviation - stats['Fstat_cov'] = variation(x) # Coefficient of variation - x_75_25 = scoreatpercentile(x, 75) + scoreatpercentile(x, 25) - stats['Fstat_qcod'] = iqr(x)/x_75_25 # Quartile coefficient of dispersion - stats['Fstat_energy'] = np.sum(np.power(x, 2)) # Energy - stats['Fstat_rms'] = np.sqrt(np.mean(np.power(x, 2))) # Root mean square - - return stats - -def mean(vol: np.ndarray) -> float: - """Computes statistical mean feature of the input dataset (3D Array). - This feature refers to "Fstat_mean" (ID = Q4LE) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Statistical mean feature - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.mean(x) # Mean - -def var(vol: np.ndarray) -> float: - """Computes statistical variance feature of the input dataset (3D Array). - This feature refers to "Fstat_var" (ID = ECT3) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Statistical variance feature - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.var(x) # Variance - -def skewness(vol: np.ndarray) -> float: - """Computes the sample skewness feature of the input dataset (3D Array). - This feature refers to "Fstat_skew" (ID = KE2A) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: The skewness feature of values along an axis. Returning 0 where all values are - equal. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return skew(x) # Skewness - -def kurt(vol: np.ndarray) -> float: - """Computes the kurtosis (Fisher or Pearson) feature of the input dataset (3D Array). - This feature refers to "Fstat_kurt" (ID = IPH6) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: The kurtosis feature of values along an axis. If all values are equal, - return -3 for Fisher's definition and 0 for Pearson's definition. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return kurtosis(x) # Kurtosis - -def median(vol: np.ndarray) -> float: - """Computes the median feature along the specified axis of the input dataset (3D Array). - This feature refers to "Fstat_median" (ID = Y12H) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: The median feature of the array elements. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.median(x) # Median - -def min(vol: np.ndarray) -> float: - """Computes the minimum grey level feature of the input dataset (3D Array). - This feature refers to "Fstat_min" (ID = 1GSF) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: The minimum grey level feature of the array elements. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.min(x) # Minimum grey level - -def p10(vol: np.ndarray) -> float: - """Computes the score at the 10th percentile feature of the input dataset (3D Array). - This feature refers to "Fstat_P10" (ID = QG58) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Score at 10th percentil. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return scoreatpercentile(x, 10) # 10th percentile - -def p90(vol: np.ndarray) -> float: - """Computes the score at the 90th percentile feature of the input dataset (3D Array). - This feature refers to "Fstat_P90" (ID = 8DWT) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Score at 90th percentil. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return scoreatpercentile(x, 90) # 90th percentile - -def max(vol: np.ndarray) -> float: - """Computes the maximum grey level feature of the input dataset (3D Array). - This feature refers to "Fstat_max" (ID = 84IY) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: The maximum grey level feature of the array elements. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.max(x) # Maximum grey level - -def iqrange(vol: np.ndarray) -> float: - """Computes the interquartile range feature of the input dataset (3D Array). - This feature refers to "Fstat_iqr" (ID = SALO) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: Interquartile range. If axis != None, the output data-type is the same as that of the input. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return iqr(x) # Interquartile range - -def range(vol: np.ndarray) -> float: - """Range of values (maximum - minimum) feature along an axis of the input dataset (3D Array). - This feature refers to "Fstat_range" (ID = 2OJQ) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: A new array holding the range of values, unless out was specified, - in which case a reference to out is returned. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.ptp(x) # Range max(x) - min(x) - -def mad(vol: np.ndarray) -> float: - """Mean absolute deviation feature of the input dataset (3D Array). - This feature refers to "Fstat_mad" (ID = 4FUA) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float : A new array holding mean absolute deviation feature. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.mean(np.absolute(x - np.mean(x))) # Mean absolute deviation - -def rmad(vol: np.ndarray) -> float: - """Robust mean absolute deviation feature of the input dataset (3D Array). - This feature refers to "Fstat_rmad" (ID = 1128) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - P10(ndarray): Score at 10th percentil. - P90(ndarray): Score at 90th percentil. - - Returns: - float: A new array holding the robust mean absolute deviation. - """ - x = vol[~np.isnan(vol[:])] # Initialization - P10 = scoreatpercentile(x, 10) # 10th percentile - P90 = scoreatpercentile(x, 90) # 90th percentile - x_10_90 = x[np.where((x >= P10) & - (x <= P90), True, False)] # Holding x for (x >= P10) and (x<= P90) - - return np.mean(np.abs(x_10_90 - np.mean(x_10_90))) # Robust mean absolute deviation - -def medad(vol: np.ndarray) -> float: - """Median absolute deviation feature of the input dataset (3D Array). - This feature refers to "Fstat_medad" (ID = N72L) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: A new array holding the median absolute deviation feature. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.mean(np.absolute(x - np.median(x))) # Median absolute deviation - -def cov(vol: np.ndarray) -> float: - """Computes the coefficient of variation feature of the input dataset (3D Array). - This feature refers to "Fstat_cov" (ID = 7TET) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: A new array holding the coefficient of variation feature. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return variation(x) # Coefficient of variation - -def qcod(vol: np.ndarray) -> float: - """Computes the quartile coefficient of dispersion feature of the input dataset (3D Array). - This feature refers to "Fstat_qcod" (ID = 9S40) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: A new array holding the quartile coefficient of dispersion feature. - """ - x = vol[~np.isnan(vol[:])] # Initialization - x_75_25 = scoreatpercentile(x, 75) + scoreatpercentile(x, 25) - - return iqr(x) / x_75_25 # Quartile coefficient of dispersion - -def energy(vol: np.ndarray) -> float: - """Computes the energy feature of the input dataset (3D Array). - This feature refers to "Fstat_energy" (ID = N8CA) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: A new array holding the energy feature. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.sum(np.power(x, 2)) # Energy - -def rms(vol: np.ndarray) -> float: - """Computes the root mean square feature of the input dataset (3D Array). - This feature refers to "Fstat_rms" (ID = 5ZWQ) in - the `IBSI1 reference manual `_. - - Args: - vol(ndarray): 3D volume, NON-QUANTIZED, with NaNs outside the region of interest - (continuous imaging intensity distribution) - - Returns: - float: A new array holding the root mean square feature. - """ - x = vol[~np.isnan(vol[:])] # Initialization - - return np.sqrt(np.mean(np.power(x, 2))) # Root mean square diff --git a/MEDimage/biomarkers/utils.py b/MEDimage/biomarkers/utils.py deleted file mode 100644 index 6a524bd..0000000 --- a/MEDimage/biomarkers/utils.py +++ /dev/null @@ -1,389 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import math -from typing import List, Tuple, Union - -import numpy as np -from skimage.measure import marching_cubes - - -def find_i_x(levels: np.ndarray, - fract_vol: np.ndarray, - x: float) -> np.ndarray: - """Computes intensity at volume fraction. - - Args: - levels (ndarray): COMPLETE INTEGER grey-levels. - fract_vol (ndarray): Fractional volume. - x (float): Fraction percentage, between 0 and 100. - - Returns: - ndarray: Array of minimum discretised intensity present in at most :math:`x` % of the volume. - - """ - ind = np.where(fract_vol <= x/100)[0][0] - ix = levels[ind] - - return ix - -def find_v_x(fract_int: np.ndarray, - fract_vol: np.ndarray, - x: float) -> np.ndarray: - """Computes volume at intensity fraction. - - Args: - fract_int (ndarray): Intensity fraction. - fract_vol (ndarray): Fractional volume. - x (float): Fraction percentage, between 0 and 100. - - Returns: - ndarray: Array of largest volume fraction ``fract_vol`` that has an - intensity fraction ``fract_int`` of at least :math:`x` %. - - """ - ind = np.where(fract_int >= x/100)[0][0] - vx = fract_vol[ind] - - return vx - -def get_area_dens_approx(a: float, - b: float, - c: float, - n: float) -> float: - """Computes area density - minimum volume enclosing ellipsoid - - Args: - a (float): Major semi-axis length. - b (float): Minor semi-axis length. - c (float): Least semi-axis length. - n (int): Number of iterations. - - Returns: - float: Area density - minimum volume enclosing ellipsoid. - - """ - alpha = np.sqrt(1 - b**2/a**2) - beta = np.sqrt(1 - c**2/a**2) - ab = alpha * beta - point = (alpha**2+beta**2) / (2*ab) - a_ell = 0 - - for v in range(0, n+1): - coef = [0]*v + [1] - legen = np.polynomial.legendre.legval(x=point, c=coef) - a_ell = a_ell + ab**v / (1-4*v**2) * legen - - a_ell = a_ell * 4 * np.pi * a * b - - return a_ell - -def get_axis_lengths(xyz: np.ndarray) -> Tuple[float, float, float]: - """Computes AxisLengths. - - Args: - xyz (ndarray): Array of three column vectors, defining the [X,Y,Z] - positions of the points in the ROI (1's) of the mask volume. In mm. - - Returns: - Tuple[float, float, float]: Array of three column vectors - [Major axis lengths, Minor axis lengths, Least axis lengths]. - - """ - xyz = xyz.copy() - - # Getting the geometric centre of mass - com_geom = np.sum(xyz, 0)/np.shape(xyz)[0] # [1 X 3] vector - - # Subtracting the centre of mass - xyz[:, 0] = xyz[:, 0] - com_geom[0] - xyz[:, 1] = xyz[:, 1] - com_geom[1] - xyz[:, 2] = xyz[:, 2] - com_geom[2] - - # Getting the covariance matrix - cov_mat = np.cov(xyz, rowvar=False) - - # Getting the eigenvalues - eig_val, _ = np.linalg.eig(cov_mat) - eig_val = np.sort(eig_val) - - major = eig_val[2] - minor = eig_val[1] - least = eig_val[0] - - return major, minor, least - -def get_glcm_cross_diag_prob(p_ij: np.ndarray) -> np.ndarray: - """Computes cross diagonal probabilities. - - Args: - p_ij (ndarray): Joint probability of grey levels - i and j occurring in neighboring voxels. (Elements - of the probability distribution for grey level - co-occurrences). - - Returns: - ndarray: Array of the cross diagonal probability. - - """ - n_g = np.size(p_ij, 0) - val_k = np.arange(2, 2*n_g + 100*np.finfo(float).eps) - n_k = np.size(val_k) - p_iplusj = np.zeros(n_k) - - for iteration_k in range(0, n_k): - k = val_k[iteration_k] - p = 0 - for i in range(0, n_g): - for j in range(0, n_g): - if (k - (i+j+2)) == 0: - p += p_ij[i, j] - - p_iplusj[iteration_k] = p - - return p_iplusj - -def get_glcm_diag_prob(p_ij: np.ndarray) -> np.ndarray: - """Computes diagonal probabilities. - - Args: - p_ij (ndarray): Joint probability of grey levels - i and j occurring in neighboring voxels. (Elements - of the probability distribution for grey level - co-occurrences). - - Returns: - ndarray: Array of the diagonal probability. - - """ - - n_g = np.size(p_ij, 0) - val_k = np.arange(0, n_g) - n_k = np.size(val_k) - p_iminusj = np.zeros(n_k) - - for iteration_k in range(0, n_k): - k = val_k[iteration_k] - p = 0 - for i in range(0, n_g): - for j in range(0, n_g): - if (k - abs(i-j)) == 0: - p += p_ij[i, j] - - p_iminusj[iteration_k] = p - - return p_iminusj - -def get_com(xgl_int: np.ndarray, - xgl_morph: np.ndarray, - xyz_int: np.ndarray, - xyz_morph: np.ndarray) -> Union[float, - np.ndarray]: - """Calculates center of mass shift (in mm, since resolution is in mm). - - Note: - Row positions of "x_gl" and "xyz" must correspond for each point. - - Args: - xgl_int (ndarray): Vector of intensity values in the volume to analyze - (only values in the intensity mask). - xgl_morph (ndarray): Vector of intensity values in the volume to analyze - (only values in the morphological mask). - xyz_int (ndarray): [n_points X 3] matrix of three column vectors, defining the [X,Y,Z] - positions of the points in the ROI (1's) of the mask volume (In mm). - (Mesh-based volume calculated from the ROI intensity mesh) - xyz_morph (ndarray): [n_points X 3] matrix of three column vectors, defining the [X,Y,Z] - positions of the points in the ROI (1's) of the mask volume (In mm). - (Mesh-based volume calculated from the ROI morphological mesh) - - Returns: - Union[float, np.ndarray]: The ROI volume centre of mass. - - """ - - # Getting the geometric centre of mass - n_v = np.size(xgl_morph) - - com_geom = np.sum(xyz_morph, 0)/n_v # [1 X 3] vector - - # Getting the density centre of mass - xyz_int[:, 0] = xgl_int*xyz_int[:, 0] - xyz_int[:, 1] = xgl_int*xyz_int[:, 1] - xyz_int[:, 2] = xgl_int*xyz_int[:, 2] - com_gl = np.sum(xyz_int, 0)/np.sum(xgl_int, 0) # [1 X 3] vector - - # Calculating the shift - com = np.linalg.norm(com_geom - com_gl) - - return com - -def get_loc_peak(img_obj: np.ndarray, - roi_obj: np.ndarray, - res: np.ndarray) -> float: - """Computes Local intensity peak. - - Note: - This works only in 3D for now. - - Args: - img_obj (ndarray): Continuos image intensity distribution, with no NaNs - outside the ROI. - roi_obj (ndarray): Array of the mask defining the ROI. - res (List[float]): [a,b,c] vector specifying the resolution of the volume in mm. - xyz resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the local intensity peak. - - """ - # INITIALIZATION - # About 6.2 mm, as defined in document - dist_thresh = (3/(4*math.pi))**(1/3)*10 - - # Insert -inf outside ROI - temp = img_obj.copy() - img_obj = img_obj.copy() - img_obj[roi_obj == 0] = -np.inf - - # Find the location(s) of the maximal voxel - max_val = np.max(img_obj) - I, J, K = np.nonzero(img_obj == max_val) - n_max = np.size(I) - - # Reconverting to full object without -Inf - img_obj = temp - - # Get a meshgrid first - x = res[0]*(np.arange(img_obj.shape[1])+0.5) - y = res[1]*(np.arange(img_obj.shape[0])+0.5) - z = res[2]*(np.arange(img_obj.shape[2])+0.5) - X, Y, Z = np.meshgrid(x, y, z) # In mm - - # Calculate the local peak - max_val = -np.inf - - for n in range(n_max): - temp_x = X - X[I[n], J[n], K[n]] - temp_y = Y - Y[I[n], J[n], K[n]] - temp_z = Z - Z[I[n], J[n], K[n]] - temp_dist_mesh = (np.sqrt(np.power(temp_x, 2) + - np.power(temp_y, 2) + - np.power(temp_z, 2))) - val = img_obj[temp_dist_mesh <= dist_thresh] - val[np.isnan(val)] = [] - - if np.size(val) == 0: - temp_local_peak = img_obj[I[n], J[n], K[n]] - else: - temp_local_peak = np.mean(val) - if temp_local_peak > max_val: - max_val = temp_local_peak - - local_peak = max_val - - return local_peak - -def get_mesh(mask: np.ndarray, - res: Union[np.ndarray, List]) -> Tuple[np.ndarray, - np.ndarray, - np.ndarray]: - """Compute Mesh. - - Note: - Make sure the `mask` is padded with a layer of 0's in all - dimensions to reduce potential isosurface computation errors. - - Args: - mask (ndarray): Contains only 0's and 1's. - res (ndarray or List): [a,b,c] vector specifying the resolution of the volume in mm. - xyz resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - Tuple[np.ndarray, np.ndarray, np.ndarray]: - - Array of the [X,Y,Z] positions of the ROI. - - Array of the spatial coordinates for `mask` unique mesh vertices. - - Array of triangular faces via referencing vertex indices from vertices. - """ - # Getting the grid of X,Y,Z positions, where the coordinate reference - # system (0,0,0) is located at the upper left corner of the first voxel - # (-0.5: half a voxel distance). For the whole volume defining the mask, - # no matter if it is a 1 or a 0. - mask = mask.copy() - res = res.copy() - - x = res[0]*((np.arange(1, np.shape(mask)[0]+1))-0.5) - y = res[1]*((np.arange(1, np.shape(mask)[1]+1))-0.5) - z = res[2]*((np.arange(1, np.shape(mask)[2]+1))-0.5) - X, Y, Z = np.meshgrid(x, y, z, indexing='ij') - - # Getting the isosurface of the mask - vertices, faces, _, _ = marching_cubes(volume=mask, level=0.5, spacing=res) - - # Getting the X,Y,Z positions of the ROI (i.e. 1's) of the mask - X = np.reshape(X, (np.size(X), 1), order='F') - Y = np.reshape(Y, (np.size(Y), 1), order='F') - Z = np.reshape(Z, (np.size(Z), 1), order='F') - - xyz = np.concatenate((X, Y, Z), axis=1) - xyz = xyz[np.where(np.reshape(mask, np.size(mask), order='F') == 1)[0], :] - - return xyz, faces, vertices - -def get_glob_peak(img_obj: np.ndarray, - roi_obj: np.ndarray, - res: np.ndarray) -> float: - """Computes Global intensity peak. - - Note: - This works only in 3D for now. - - Args: - img_obj (ndarray): Continuos image intensity distribution, with no NaNs - outside the ROI. - roi_obj (ndarray): Array of the mask defining the ROI. - res (List[float]): [a,b,c] vector specifying the resolution of the volume in mm. - xyz resolution (world), or JIK resolution (intrinsic matlab). - - Returns: - float: Value of the global intensity peak. - - """ - # INITIALIZATION - # About 6.2 mm, as defined in document - dist_thresh = (3/(4*math.pi))**(1/3)*10 - - # Find the location(s) of all voxels within the ROI - indices = np.nonzero(np.reshape(roi_obj, np.size(roi_obj), order='F') == 1)[0] - I, J, K = np.unravel_index(indices, np.shape(img_obj), order='F') - n_max = np.size(I) - - # Get a meshgrid first - x = res[0]*(np.arange(img_obj.shape[1])+0.5) - y = res[1]*(np.arange(img_obj.shape[0])+0.5) - z = res[2]*(np.arange(img_obj.shape[2])+0.5) - X, Y, Z = np.meshgrid(x, y, z) # In mm - - # Calculate the local peak - max_val = -np.inf - - for n in range(n_max): - temp_x = X - X[I[n], J[n], K[n]] - temp_y = Y - Y[I[n], J[n], K[n]] - temp_z = Z - Z[I[n], J[n], K[n]] - temp_dist_mesh = (np.sqrt(np.power(temp_x, 2) + - np.power(temp_y, 2) + - np.power(temp_z, 2))) - val = img_obj[temp_dist_mesh <= dist_thresh] - val[np.isnan(val)] = [] - - if np.size(val) == 0: - temp_local_peak = img_obj[I[n], J[n], K[n]] - else: - temp_local_peak = np.mean(val) - if temp_local_peak > max_val: - max_val = temp_local_peak - - global_peak = max_val - - return global_peak - \ No newline at end of file diff --git a/MEDimage/filters/TexturalFilter.py b/MEDimage/filters/TexturalFilter.py deleted file mode 100644 index c98293a..0000000 --- a/MEDimage/filters/TexturalFilter.py +++ /dev/null @@ -1,299 +0,0 @@ -from copy import deepcopy -from typing import Union - -import numpy as np -try: - import pycuda.autoinit - import pycuda.driver as cuda - from pycuda.autoinit import context - from pycuda.compiler import SourceModule -except Exception as e: - print("PyCUDA is not installed. Please install it to use the textural filters.", e) - import_failed = True - -from ..processing.discretisation import discretize -from .textural_filters_kernels import glcm_kernel, single_glcm_kernel - - -class TexturalFilter(): - """The Textural filter class. This class is used to apply textural filters to an image. The textural filters are - chosen from the following families: GLCM, NGTDM, GLDZM, GLSZM, NGLDM, GLRLM. The computation is done using CUDA.""" - - def __init__( - self, - family: str, - size: int = 3, - local: bool = False - ): - - """ - The constructor for the textural filter class. - - Args: - family (str): The family of the textural filter. - size (int, optional): The size of the kernel, which will define the filter kernel dimension. - local (bool, optional): If true, the discrete will be computed locally, else globally. - - Returns: - None. - """ - - assert size % 2 == 1 and size > 0, "size should be a positive odd number." - assert isinstance(family, str) and family.upper() in ["GLCM", "NGTDM", "GLDZM", "GLSZM", "NGLDM", "GLRLM"],\ - "family should be a string and should be one of the following: GLCM, NGTDM, GLDZM, GLSZM, NGLDM, GLRLM." - - self.family = family - self.size = size - self.local = local - self.glcm_features = [ - "Fcm_joint_max", - "Fcm_joint_avg", - "Fcm_joint_var", - "Fcm_joint_entr", - "Fcm_diff_avg", - "Fcm_diff_var", - "Fcm_diff_entr", - "Fcm_sum_avg", - "Fcm_sum_var", - "Fcm_sum_entr", - "Fcm_energy", - "Fcm_contrast", - "Fcm_dissimilarity", - "Fcm_inv_diff", - "Fcm_inv_diff_norm", - "Fcm_inv_diff_mom", - "Fcm_inv_diff_mom_norm", - "Fcm_inv_var", - "Fcm_corr", - "Fcm_auto_corr", - "Fcm_clust_tend", - "Fcm_clust_shade", - "Fcm_clust_prom", - "Fcm_info_corr1", - "Fcm_info_corr2" - ] - - def __glcm_filter( - self, - input_images: np.ndarray, - discretization : dict, - user_set_min_val: float, - feature = None - ) -> np.ndarray: - """ - Apply a textural filter to the input image. - - Args: - input_images (ndarray): The images to filter. - discretization (dict): The discretization parameters. - user_set_min_val (float): The minimum value to use for the discretization. - family (str, optional): The family of the textural filter. - feature (str, optional): The feature to extract from the family. if not specified, all the features of the - family will be extracted. - - Returns: - ndarray: The filtered image. - """ - - if feature: - if isinstance(feature, str): - assert feature in self.glcm_features,\ - "feature should be a string or an integer and should be one of the following: " + ", ".join(self.glcm_features) + "." - elif isinstance(feature, int): - assert feature in range(len(self.glcm_features)),\ - "feature's index should be an integer between 0 and " + str(len(self.glcm_features) - 1) + "." - else: - raise TypeError("feature should be an integer or a string from the following list: " + ", ".join(self.glcm_features) + ".") - - - # Pre-processing of the input volume - padding_size = (self.size - 1) // 2 - input_images = np.pad(input_images[:, :, :], padding_size, mode="constant", constant_values=np.nan) - input_images_copy = deepcopy(input_images) - - # Set up the strides - strides = ( - input_images_copy.shape[2] * input_images_copy.shape[1] * input_images_copy.dtype.itemsize, - input_images_copy.shape[2] * input_images_copy.dtype.itemsize, - input_images_copy.dtype.itemsize - ) - input_images = np.lib.stride_tricks.as_strided(input_images, shape=input_images.shape, strides=strides) - input_images[:,:,:] = input_images_copy[:, :, :] - - if self.local: - # Discretization (to get the global max value) - if discretization['type'] == "FBS": - print("Warning: FBS local discretization is equivalent to global discretization.") - n_q = discretization['bw'] - elif discretization['type'] == "FBN" and discretization['adapted']: - n_q = (np.nanmax(input_images) - np.nanmin(input_images)) // discretization['bw'] - user_set_min_val = np.nanmin(input_images) - elif discretization['type'] == "FBN": - n_q = discretization['bn'] - user_set_min_val = np.nanmin(input_images) - else: - raise ValueError("Discretization should be either FBS or FBN.") - - temp_vol, _ = discretize( - vol_re=input_images, - discr_type=discretization['type'], - n_q=n_q, - user_set_min_val=user_set_min_val, - ivh=False - ) - - # Initialize the filtering parameters - max_vol = np.nanmax(temp_vol) - - del temp_vol - - else: - # Discretization - if discretization['type'] == "FBS": - n_q = discretization['bw'] - elif discretization['type'] == "FBN": - n_q = discretization['bn'] - user_set_min_val = np.nanmin(input_images) - else: - raise ValueError("Discretization should be either FBS or FBN.") - - input_images, _ = discretize( - vol_re=input_images, - discr_type=discretization['type'], - n_q=n_q, - user_set_min_val=user_set_min_val, - ivh=False - ) - - # Initialize the filtering parameters - max_vol = np.nanmax(input_images) - - volume_copy = deepcopy(input_images) - - # Filtering - if feature is not None: - # Select the feature to compute - feature = self.glcm_features.index(feature) if isinstance(feature, str) else feature - - # Initialize the kernel - kernel_glcm = single_glcm_kernel.substitute( - max_vol=int(max_vol), - filter_size=self.size, - shape_volume_0=int(volume_copy.shape[0]), - shape_volume_1=int(volume_copy.shape[1]), - shape_volume_2=int(volume_copy.shape[2]), - discr_type=discretization['type'], - n_q=n_q, - min_val=user_set_min_val, - feature_index=feature - ) - - else: - # Create the final volume to store the results - input_images = np.zeros((input_images.shape[0], input_images.shape[1], input_images.shape[2], 25), dtype=np.float32) - - # Fill with nan - input_images[:] = np.nan - - # Initialize the kernel - kernel_glcm = glcm_kernel.substitute( - max_vol=int(max_vol), - filter_size=self.size, - shape_volume_0=int(volume_copy.shape[0]), - shape_volume_1=int(volume_copy.shape[1]), - shape_volume_2=int(volume_copy.shape[2]), - discr_type=discretization['type'], - n_q=n_q, - min_val=user_set_min_val - ) - - # Compile the CUDA kernel - if not import_failed: - mod = SourceModule(kernel_glcm, no_extern_c=True) - if self.local: - process_loop_kernel = mod.get_function("glcm_filter_local") - else: - process_loop_kernel = mod.get_function("glcm_filter_global") - - # Allocate GPU memory - volume_gpu = cuda.mem_alloc(input_images.nbytes) - volume_gpu_copy = cuda.mem_alloc(volume_copy.nbytes) - - # Copy data to the GPU - cuda.memcpy_htod(volume_gpu, input_images) - cuda.memcpy_htod(volume_gpu_copy, volume_copy) - - # Set up the grid and block dimensions - block_dim = (16, 16, 1) # threads per block - grid_dim = ( - int((volume_copy.shape[0] - 1) // block_dim[0] + 1), - int((volume_copy.shape[1] - 1) // block_dim[1] + 1), - int((volume_copy.shape[2] - 1) // block_dim[2] + 1) - ) # blocks in the grid - - # Run the kernel - process_loop_kernel(volume_gpu, volume_gpu_copy, block=block_dim, grid=grid_dim) - - # Synchronize to ensure all CUDA operations are complete - context.synchronize() - - # Copy data back to the CPU - cuda.memcpy_dtoh(input_images, volume_gpu) - - # Free the allocated GPU memory - volume_gpu.free() - volume_gpu_copy.free() - del volume_copy - - # unpad the volume - if feature: # 3D (single-feature) - input_images = input_images[padding_size:-padding_size, padding_size:-padding_size, padding_size:-padding_size] - else: # 4D (all features) - input_images = input_images[padding_size:-padding_size, padding_size:-padding_size, padding_size:-padding_size, :] - - return input_images - - else: - return None - - def __call__( - self, - input_images: np.ndarray, - discretization : dict, - user_set_min_val: float, - family: str = "GLCM", - feature : str = None, - size: int = None, - local: bool = False - ) -> np.ndarray: - """ - Apply a textural filter to the input image. - - Args: - input_images (ndarray): The images to filter. - discretization (dict): The discretization parameters. - user_set_min_val (float): The minimum value to use for the discretization. - family (str, optional): The family of the textural filter. - feature (str, optional): The feature to extract from the family. if not specified, all the features of the - family will be extracted. - size (int, optional): The filter size. - local (bool, optional): If true, the discretization will be computed locally, else globally. - - Returns: - ndarray: The filtered image. - """ - # Initialization - if family: - self.family = family - if size: - self.size = size - if local: - self.local = local - - # Filtering - if self.family.lower() == "glcm": - filtered_images = self.__glcm_filter(input_images, discretization, user_set_min_val, feature) - else: - raise NotImplementedError("Only GLCM is implemented for now.") - - return filtered_images diff --git a/MEDimage/filters/__init__.py b/MEDimage/filters/__init__.py deleted file mode 100644 index 30b73a2..0000000 --- a/MEDimage/filters/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from . import * -from .apply_filter import * -from .gabor import * -from .laws import * -from .log import * -from .mean import * -from .TexturalFilter import * -from .utils import * -from .wavelet import * diff --git a/MEDimage/filters/apply_filter.py b/MEDimage/filters/apply_filter.py deleted file mode 100644 index 87cb901..0000000 --- a/MEDimage/filters/apply_filter.py +++ /dev/null @@ -1,134 +0,0 @@ -import numpy as np - -from ..MEDscan import MEDscan -from ..utils.image_volume_obj import image_volume_obj -from .gabor import * -from .laws import * -from .log import * -from .mean import * -try: - from .TexturalFilter import TexturalFilter -except ImportError: - import_failed = True -from .wavelet import * - - -def apply_filter( - medscan: MEDscan, - vol_obj: Union[image_volume_obj, np.ndarray], - user_set_min_val: float = None, - feature: str = None - ) -> Union[image_volume_obj, np.ndarray]: - """Applies mean filter on the given data - - Args: - medscan (MEDscan): Instance of the MEDscan class that holds the filtering params - vol_obj (image_volume_obj): Imaging data to be filtered - user_set_min_val (float, optional): The minimum value to use for the discretization. Defaults to None. - feature (str, optional): The feature to extract from the family. In batch extraction, all the features - of the family will be extracted. Defaults to None. - - Returns: - image_volume_obj: Filtered imaging data. - """ - filter_type = medscan.params.filter.filter_type - - if filter_type.lower() == "mean": - input = np.expand_dims(vol_obj.data.astype(np.float64), axis=0) # Convert to shape : (B, W, H, D) - # Initialize filter class instance - _filter = Mean( - ndims=medscan.params.filter.mean.ndims, - size=medscan.params.filter.mean.size, - padding=medscan.params.filter.mean.padding - ) - # Run convolution - result = _filter.convolve(input, orthogonal_rot=medscan.params.filter.mean.orthogonal_rot) - - elif filter_type.lower() == "log": - # Initialize filter class params & instance - input = np.expand_dims(vol_obj.data.astype(np.float64), axis=0) # Convert to shape : (B, W, H, D) - voxel_length = medscan.params.process.scale_non_text[0] - sigma = medscan.params.filter.log.sigma / voxel_length - length = 2 * int(4 * sigma + 0.5) + 1 - _filter = LaplacianOfGaussian( - ndims=medscan.params.filter.log.ndims, - size=length, - sigma=sigma, - padding=medscan.params.filter.log.padding - ) - # Run convolution - result = _filter.convolve(input, orthogonal_rot=medscan.params.filter.log.orthogonal_rot) - - elif filter_type.lower() == "laws": - # Initialize filter class instance - input = np.expand_dims(vol_obj.data.astype(np.float64), axis=0) # Convert to shape : (B, W, H, D) - _filter = Laws( - config=medscan.params.filter.laws.config, - energy_distance=medscan.params.filter.laws.energy_distance, - rot_invariance=medscan.params.filter.laws.rot_invariance, - padding=medscan.params.filter.laws.padding - ) - # Run convolution - result = _filter.convolve( - input, - orthogonal_rot=medscan.params.filter.laws.orthogonal_rot, - energy_image=medscan.params.filter.laws.energy_image - ) - - elif filter_type.lower() == "gabor": - # Initialize filter class params & instance - input = np.expand_dims(vol_obj.data.astype(np.float64), axis=0) # Convert to shape : (B, W, H, D) - voxel_length = medscan.params.process.scale_non_text[0] - sigma = medscan.params.filter.gabor.sigma / voxel_length - lamb = medscan.params.filter.gabor._lambda / voxel_length - size = 2 * int(7 * sigma + 0.5) + 1 - _filter = Gabor(size=size, - sigma=sigma, - lamb=lamb, - gamma=medscan.params.filter.gabor.gamma, - theta=-medscan.params.filter.gabor.theta, - rot_invariance=medscan.params.filter.gabor.rot_invariance, - padding=medscan.params.filter.gabor.padding - ) - # Run convolution - result = _filter.convolve(input, orthogonal_rot=medscan.params.filter.gabor.orthogonal_rot) - - elif filter_type.lower().startswith("wavelet"): - # Initialize filter class instance - input = np.expand_dims(vol_obj.data.astype(np.float64), axis=0) # Convert to shape : (B, W, H, D) - _filter = Wavelet( - ndims=medscan.params.filter.wavelet.ndims, - wavelet_name=medscan.params.filter.wavelet.basis_function, - rot_invariance=medscan.params.filter.wavelet.rot_invariance, - padding=medscan.params.filter.wavelet.padding - ) - # Run convolution - result = _filter.convolve( - input, - _filter=medscan.params.filter.wavelet.subband, - level=medscan.params.filter.wavelet.level - ) - elif filter_type.lower() == "textural": - if not import_failed: - # Initialize filter class instance - _filter = TexturalFilter( - family=medscan.params.filter.textural.family, - ) - # Apply filter - vol_obj = _filter( - vol_obj, - size=medscan.params.filter.textural.size, - discretization=medscan.params.filter.textural.discretization, - local=medscan.params.filter.textural.local, - user_set_min_val=user_set_min_val, - feature=feature - ) - else: - raise ValueError( - r'Filter name should either be: "mean", "log", "laws", "gabor" or "wavelet".' - ) - - if not filter_type.lower() == "textural": - vol_obj.data = np.squeeze(result,axis=0) - - return vol_obj diff --git a/MEDimage/filters/gabor.py b/MEDimage/filters/gabor.py deleted file mode 100644 index 77b5ed4..0000000 --- a/MEDimage/filters/gabor.py +++ /dev/null @@ -1,215 +0,0 @@ -import math -from itertools import product -from typing import List, Union - -import numpy as np - -from ..MEDscan import MEDscan -from ..utils.image_volume_obj import image_volume_obj -from .utils import convolve - - -class Gabor(): - """ - The Gabor filter class - """ - - def __init__( - self, - size: int, - sigma: float, - lamb: float, - gamma: float, - theta: float, - rot_invariance=False, - padding="symmetric" - ) -> None: - """ - The constructor of the Gabor filter. Highly inspired by Ref 1. - - Args: - size (int): An integer that represent the length along one dimension of the kernel. - sigma (float): A positive float that represent the scale of the Gabor filter - lamb (float): A positive float that represent the wavelength in the Gabor filter. (mm or pixel?) - gamma (float): A positive float that represent the spacial aspect ratio - theta (float): Angle parameter used in the rotation matrix - rot_invariance (bool): If true, rotation invariance will be done on the kernel and the kernel - will be rotate 2*pi / theta times. - padding: The padding type that will be used to produce the convolution - - Returns: - None - """ - - assert ((size + 1) / 2).is_integer() and size > 0, "size should be a positive odd number." - assert sigma > 0, "sigma should be a positive float" - assert lamb > 0, "lamb represent the wavelength, so it should be a positive float" - assert gamma > 0, "gamma is the ellipticity of the support of the filter, so it should be a positive float" - - self.dim = 2 - self.padding = padding - self.size = size - self.sigma = sigma - self.lamb = lamb - self.gamma = gamma - self.theta = theta - self.rot = rot_invariance - self.create_kernel() - - def create_kernel(self) -> List[np.ndarray]: - """Create the kernel of the Gabor filter - - Returns: - List[ndarray]: A list of numpy 2D-array that contain the kernel of the real part and - the imaginary part respectively. - """ - - def compute_weight(position, theta): - k_2 = position[0]*math.cos(theta) + position[1] * math.sin(theta) - k_1 = position[1]*math.cos(theta) - position[0] * math.sin(theta) - - common = math.e**(-(k_1**2 + (self.gamma*k_2)**2)/(2*self.sigma**2)) - real = math.cos(2*math.pi*k_1/self.lamb) - im = math.sin(2*math.pi*k_1/self.lamb) - return common*real, common*im - - # Rotation invariance - nb_rot = round(2*math.pi/abs(self.theta)) if self.rot else 1 - real_list = [] - im_list = [] - - for i in range(1, nb_rot+1): - # Initialize the kernel as tensor of zeros - real_kernel = np.zeros([self.size for _ in range(2)]) - im_kernel = np.zeros([self.size for _ in range(2)]) - - for k in product(range(self.size), repeat=2): - real_kernel[k], im_kernel[k] = compute_weight(np.array(k)-int((self.size-1)/2), self.theta*i) - - real_list.extend([real_kernel]) - im_list.extend([im_kernel]) - - self.kernel = np.expand_dims( - np.concatenate((real_list, im_list), axis=0), - axis=1 - ) - - def convolve(self, - images: np.ndarray, - orthogonal_rot=False, - pooling_method='mean') -> np.ndarray: - """Filter a given image using the Gabor kernel defined during the construction of this instance. - - Args: - images (ndarray): A n-dimensional numpy array that represent the images to filter - orthogonal_rot (bool): If true, the 3D images will be rotated over coronal, axial and sagittal axis - - Returns: - ndarray: The filtered image as a numpy ndarray - """ - - # Swap the second axis with the last, to convert image B, W, H, D --> B, D, H, W - image = np.swapaxes(images, 1, 3) - - result = convolve(self.dim, self.kernel, image, orthogonal_rot, self.padding) - - # Reshape to get real and imaginary response on the first axis. - _dim = 2 if orthogonal_rot else 1 - nb_rot = int(result.shape[_dim]/2) - result = np.stack(np.array_split(result, np.array([nb_rot]), _dim), axis=0) - - # 2D modulus response map - result = np.linalg.norm(result, axis=0) - - # Rotation invariance. - if pooling_method == 'mean': - result = np.mean(result, axis=2) if orthogonal_rot else np.mean(result, axis=1) - elif pooling_method == 'max': - result = np.max(result, axis=2) if orthogonal_rot else np.max(result, axis=1) - else: - raise ValueError("Pooling method should be either 'mean' or 'max'.") - - # Aggregate orthogonal rotation - result = np.mean(result, axis=0) if orthogonal_rot else result - - return np.swapaxes(result, 1, 3) - -def apply_gabor( - input_images: Union[image_volume_obj, np.ndarray], - medscan: MEDscan = None, - voxel_length: float = 0.0, - sigma: float = 0.0, - _lambda: float = 0.0, - gamma: float = 0.0, - theta: float = 0.0, - rot_invariance: bool = False, - padding: str = "symmetric", - orthogonal_rot: bool = False, - pooling_method: str = "mean" - ) -> np.ndarray: - """Apply the Gabor filter to a given imaging data. - - Args: - input_images (Union[image_volume_obj, np.ndarray]): The input images to filter. - medscan (MEDscan, optional): The MEDscan object that will provide the filter parameters. - voxel_length (float, optional): The voxel size of the input image. - sigma (float, optional): A positive float that represent the scale of the Gabor filter. - _lambda (float, optional): A positive float that represent the wavelength in the Gabor filter. - gamma (float, optional): A positive float that represent the spacial aspect ratio. - theta (float, optional): Angle parameter used in the rotation matrix. - rot_invariance (bool, optional): If true, rotation invariance will be done on the kernel and the kernel - will be rotate 2*pi / theta times. - padding (str, optional): The padding type that will be used to produce the convolution. Check options - here: `numpy.pad `__. - orthogonal_rot (bool, optional): If true, the 3D images will be rotated over coronal, axial and sagittal axis. - - Returns: - ndarray: The filtered image. - """ - # Check if the input is a numpy array or a Image volume object - spatial_ref = None - if type(input_images) == image_volume_obj: - spatial_ref = input_images.spatialRef - input_images = input_images.data - - # Convert to shape : (B, W, H, D) - input_images = np.expand_dims(input_images.astype(np.float64), axis=0) - - if medscan: - # Initialize filter class params & instance - voxel_length = medscan.params.process.scale_non_text[0] - sigma = medscan.params.filter.gabor.sigma / voxel_length - lamb = medscan.params.filter.gabor._lambda / voxel_length - size = 2 * int(7 * sigma + 0.5) + 1 - _filter = Gabor(size=size, - sigma=sigma, - lamb=lamb, - gamma=medscan.params.filter.gabor.gamma, - theta=-medscan.params.filter.gabor.theta, - rot_invariance=medscan.params.filter.gabor.rot_invariance, - padding=medscan.params.filter.gabor.padding - ) - # Run convolution - result = _filter.convolve(input_images, orthogonal_rot=medscan.params.filter.gabor.orthogonal_rot) - else: - if not (voxel_length and sigma and _lambda and gamma and theta): - raise ValueError("Missing parameters to build the Gabor filter.") - # Initialize filter class params & instance - sigma = sigma / voxel_length - lamb = _lambda / voxel_length - size = 2 * int(7 * sigma + 0.5) + 1 - _filter = Gabor(size=size, - sigma=sigma, - lamb=lamb, - gamma=gamma, - theta=theta, - rot_invariance=rot_invariance, - padding=padding - ) - # Run convolution - result = _filter.convolve(input_images, orthogonal_rot=orthogonal_rot, pooling_method=pooling_method) - - if spatial_ref: - return image_volume_obj(np.squeeze(result), spatial_ref) - else: - return np.squeeze(result) diff --git a/MEDimage/filters/laws.py b/MEDimage/filters/laws.py deleted file mode 100644 index 359f5a4..0000000 --- a/MEDimage/filters/laws.py +++ /dev/null @@ -1,283 +0,0 @@ -import math -from itertools import permutations, product -from typing import List, Union - -import numpy as np -from scipy.signal import fftconvolve - -from ..MEDscan import MEDscan -from ..utils.image_volume_obj import image_volume_obj -from .utils import convolve, pad_imgs - - -class Laws(): - """ - The Laws filter class - """ - - def __init__( - self, - config: List = None, - energy_distance: int = 7, - rot_invariance: bool = False, - padding: str = "symmetric"): - """The constructor of the Laws filter - - Args: - config (str): A string list of every 1D filter used to create the Laws kernel. Since the outer product is - not commutative, we need to use a list to specify the order of the outer product. It is not - recommended to use filter of different size to create the Laws kernel. - energy_distance (float): The distance that will be used to create the energy_kernel. - rot_invariance (bool): If true, rotation invariance will be done on the kernel. - padding (str): The padding type that will be used to produce the convolution - - Returns: - None - """ - - ndims = len(config) - - self.config = config - self.energy_dist = energy_distance - self.dim = ndims - self.padding = padding - self.rot = rot_invariance - self.energy_kernel = None - self.create_kernel() - self.__create_energy_kernel() - - @staticmethod - def __get_filter(name, - pad=False) -> np.ndarray: - """This method create a 1D filter according to the given filter name. - - Args: - name (float): The filter name. (Such as L3, L5, E3, E5, S3, S5, W5 or R5) - pad (bool): If true, add zero padding of length 1 each side of kernel L3, E3 and S3 - - Returns: - ndarray: A 1D filter that is needed to construct the Laws kernel. - """ - - if name == "L3": - ker = np.array([0, 1, 2, 1, 0]) if pad else np.array([1, 2, 1]) - return 1/math.sqrt(6) * ker - elif name == "L5": - return 1/math.sqrt(70) * np.array([1, 4, 6, 4, 1]) - elif name == "E3": - ker = np.array([0, -1, 0, 1, 0]) if pad else np.array([-1, 0, 1]) - return 1 / math.sqrt(2) * ker - elif name == "E5": - return 1 / math.sqrt(10) * np.array([-1, -2, 0, 2, 1]) - elif name == "S3": - ker = np.array([0, -1, 2, -1, 0]) if pad else np.array([-1, 2, -1]) - return 1 / math.sqrt(6) * ker - elif name == "S5": - return 1 / math.sqrt(6) * np.array([-1, 0, 2, 0, -1]) - elif name == "W5": - return 1 / math.sqrt(10) * np.array([-1, 2, 0, -2, 1]) - elif name == "R5": - return 1 / math.sqrt(70) * np.array([1, -4, 6, -4, 1]) - else: - raise Exception(f"{name} is not a valid filter name. " - "Choose between : L3, L5, E3, E5, S3, S5, W5 or R5") - - def __verify_padding_need(self) -> bool: - """Check if we need to pad the kernels - - Returns: - bool: A boolean that indicate if a kernel is smaller than at least one other. - """ - - ker_length = np.array([int(name[-1]) for name in self.config]) - - return not(ker_length.min == ker_length.max) - - def create_kernel(self) -> np.ndarray: - """Create the Laws by computing the outer product of 1d filter specified in the config attribute. - Kernel = config[0] X config[1] X ... X config[n]. Where X is the outer product. - - Returns: - ndarray: A numpy multi-dimensional arrays that represent the Laws kernel. - """ - - pad = self.__verify_padding_need() - filter_list = np.array([[self.__get_filter(name, pad) for name in self.config]]) - - if self.rot: - filter_list = np.concatenate((filter_list, np.flip(filter_list, axis=2)), axis=0) - prod_list = [prod for prod in product(*np.swapaxes(filter_list, 0, 1))] - - perm_list = [] - for i in range(len(prod_list)): - perm_list.extend([perm for perm in permutations(prod_list[i])]) - - filter_list = np.unique(perm_list, axis=0) - - kernel_list = [] - for perm in filter_list: - kernel = perm[0] - shape = kernel.shape - - for i in range(1, len(perm)): - sub_kernel = perm[i] - shape += np.shape(sub_kernel) - kernel = np.outer(sub_kernel, kernel).reshape(shape) - if self.dim == 3: - kernel_list.extend([np.expand_dims(np.flip(kernel, axis=(1, 2)), axis=0)]) - else: - kernel_list.extend([np.expand_dims(np.flip(kernel, axis=(0, 1)), axis=0)]) - - self.kernel = np.unique(kernel_list, axis=0) - - def __create_energy_kernel(self) -> np.ndarray: - """Create the kernel that will be used to generate Laws texture energy images - - Returns: - ndarray: A numpy multi-dimensional arrays that represent the Laws energy kernel. - """ - - # Initialize the kernel as tensor of zeros - kernel = np.zeros([self.energy_dist*2+1 for _ in range(self.dim)]) - - for k in product(range(self.energy_dist*2 + 1), repeat=self.dim): - position = np.array(k)-self.energy_dist - kernel[k] = 1 if np.max(abs(position)) <= self.energy_dist else 0 - - self.energy_kernel = np.expand_dims(kernel/np.prod(kernel.shape), axis=(0, 1)) - - def __compute_energy_image(self, - images: np.ndarray) -> np.ndarray: - """Compute the Laws texture energy images as described in (Ref 1). - - Args: - images (ndarray): A n-dimensional numpy array that represent the filtered images - - Returns: - ndarray: A numpy multi-dimensional array of the Laws texture energy map. - """ - # If we have a 2D kernel but a 3D images, we swap dimension channel with dimension batch. - images = np.swapaxes(images, 0, 1) - - # absolute image intensities are used in convolution - result = fftconvolve(np.abs(images), self.energy_kernel, mode='valid') - - if self.dim == 2: - return np.swapaxes(result, axis1=0, axis2=1) - else: - return np.squeeze(result, axis=1) - - def convolve(self, - images: np.ndarray, - orthogonal_rot=False, - energy_image=False): - """Filter a given image using the Laws kernel defined during the construction of this instance. - - Args: - images (ndarray): A n-dimensional numpy array that represent the images to filter - orthogonal_rot (bool): If true, the 3D images will be rotated over coronal, axial and sagittal axis - energy_image (bool): If true, return also the Laws Texture Energy Images - - Returns: - ndarray: The filtered image - """ - images = np.swapaxes(images, 1, 3) - - if orthogonal_rot: - raise NotImplementedError - - result = convolve(self.dim, self.kernel, images, orthogonal_rot, self.padding) - result = np.amax(result, axis=1) if self.dim == 2 else np.amax(result, axis=0) - - if energy_image: - # We pad the response map - result = np.expand_dims(result, axis=1) if self.dim == 3 else result - ndims = len(result.shape) - - padding = [self.energy_dist for _ in range(2 * self.dim)] - pad_axis_list = [i for i in range(ndims - self.dim, ndims)] - - response = pad_imgs(result, padding, pad_axis_list, self.padding) - - # Free memory - del result - - # We compute the energy map and we squeeze the second dimension of the energy maps. - energy_imgs = self.__compute_energy_image(response) - - return np.swapaxes(energy_imgs, 1, 3) - else: - return np.swapaxes(result, 1, 3) - -def apply_laws( - input_images: Union[np.ndarray, image_volume_obj], - medscan: MEDscan = None, - config: List[str] = [], - energy_distance: int = 7, - padding: str = "symmetric", - rot_invariance: bool = False, - orthogonal_rot: bool = False, - energy_image: bool = False, - ) -> np.ndarray: - """Apply the mean filter to the input image - - Args: - input_images (ndarray): The images to filter. - medscan (MEDscan, optional): The MEDscan object that will provide the filter parameters. - config (List[str], optional): A string list of every 1D filter used to create the Laws kernel. Since the outer product is - not commutative, we need to use a list to specify the order of the outer product. It is not - recommended to use filter of different size to create the Laws kernel. - energy_distance (int, optional): The distance of the Laws energy map from the center of the image. - padding (str, optional): The padding type that will be used to produce the convolution. Check options - here: `numpy.pad `__. - rot_invariance (bool, optional): If true, rotation invariance will be done on the kernel. - orthogonal_rot (bool, optional): If true, the 3D images will be rotated over coronal, axial and sagittal axis. - energy_image (bool, optional): If true, will compute and return the Laws Texture Energy Images. - - Returns: - ndarray: The filtered image. - """ - # Check if the input is a numpy array or a Image volume object - spatial_ref = None - if type(input_images) == image_volume_obj: - spatial_ref = input_images.spatialRef - input_images = input_images.data - - # Convert to shape : (B, W, H, D) - input_images = np.expand_dims(input_images.astype(np.float64), axis=0) - - if medscan: - # Initialize filter class instance - _filter = Laws( - config=medscan.params.filter.laws.config, - energy_distance=medscan.params.filter.laws.energy_distance, - rot_invariance=medscan.params.filter.laws.rot_invariance, - padding=medscan.params.filter.laws.padding - ) - # Run convolution - result = _filter.convolve( - input_images, - orthogonal_rot=medscan.params.filter.laws.orthogonal_rot, - energy_image=medscan.params.filter.laws.energy_image - ) - elif config: - # Initialize filter class instance - _filter = Laws( - config=config, - energy_distance=energy_distance, - rot_invariance=rot_invariance, - padding=padding - ) - # Run convolution - result = _filter.convolve( - input_images, - orthogonal_rot=orthogonal_rot, - energy_image=energy_image - ) - else: - raise ValueError("Either medscan or config must be provided") - - if spatial_ref: - return image_volume_obj(np.squeeze(result), spatial_ref) - else: - return np.squeeze(result) diff --git a/MEDimage/filters/log.py b/MEDimage/filters/log.py deleted file mode 100644 index 1ffaec7..0000000 --- a/MEDimage/filters/log.py +++ /dev/null @@ -1,147 +0,0 @@ -import math -from itertools import product -from typing import Union - -import numpy as np - -from ..MEDscan import MEDscan -from ..utils.image_volume_obj import image_volume_obj -from .utils import convolve - - -class LaplacianOfGaussian(): - """The Laplacian of gaussian filter class.""" - - def __init__( - self, - ndims: int, - size: int, - sigma: float=0.1, - padding: str="symmetric"): - """The constructor of the laplacian of gaussian (LoG) filter - - Args: - ndims (int): Number of dimension of the kernel filter - size (int): An integer that represent the length along one dimension of the kernel. - sigma (float): The gaussian standard deviation parameter of the laplacian of gaussian filter - padding (str): The padding type that will be used to produce the convolution - - Returns: - None - """ - - assert isinstance(ndims, int) and ndims > 0, "ndims should be a positive integer" - assert ((size+1)/2).is_integer() and size > 0, "size should be a positive odd number." - assert sigma > 0, "alpha should be a positive float." - - self.dim = ndims - self.padding = padding - self.size = int(size) - self.sigma = sigma - self.create_kernel() - - def create_kernel(self) -> np.ndarray: - """This method construct the LoG kernel using the parameters specified to the constructor - - Returns: - ndarray: The laplacian of gaussian kernel as a numpy multidimensional array - """ - - def compute_weight(position): - distance_2 = np.sum(position**2) - # $\frac{-1}{\sigma^2} * \frac{1}{\sqrt{2 \pi} \sigma}^D = \frac{-1}{\sqrt{D/2}{2 \pi} * \sigma^{D+2}}$ - first_part = -1/((2*math.pi)**(self.dim/2) * self.sigma**(self.dim+2)) - - # $(D - \frac{||k||^2}{\sigma^2}) * e^{\frac{-||k||^2}{2 \sigma^2}}$ - second_part = (self.dim - distance_2/self.sigma**2)*math.e**(-distance_2/(2 * self.sigma**2)) - - return first_part * second_part - - # Initialize the kernel as tensor of zeros - kernel = np.zeros([self.size for _ in range(self.dim)]) - - for k in product(range(self.size), repeat=self.dim): - kernel[k] = compute_weight(np.array(k)-int((self.size-1)/2)) - - kernel -= np.sum(kernel)/np.prod(kernel.shape) - self.kernel = np.expand_dims(kernel, axis=(0, 1)) - - def convolve(self, - images: np.ndarray, - orthogonal_rot=False) -> np.ndarray: - """Filter a given image using the LoG kernel defined during the construction of this instance. - - Args: - images (ndarray): A n-dimensional numpy array that represent the images to filter - orthogonal_rot (bool): If true, the 3D images will be rotated over coronal, axial and sagittal axis - - Returns: - ndarray: The filtered image - """ - # Swap the second axis with the last, to convert image B, W, H, D --> B, D, H, W - image = np.swapaxes(images, 1, 3) - result = np.squeeze(convolve(self.dim, self.kernel, image, orthogonal_rot, self.padding), axis=1) - return np.swapaxes(result, 1, 3) - -def apply_log( - input_images: Union[np.ndarray, image_volume_obj], - medscan: MEDscan = None, - ndims: int = 3, - voxel_length: float = 0.0, - sigma: float = 0.1, - padding: str = "symmetric", - orthogonal_rot: bool = False - ) -> np.ndarray: - """Apply the mean filter to the input image - - Args: - input_images (ndarray): The images to filter. - medscan (MEDscan, optional): The MEDscan object that will provide the filter parameters. - ndims (int, optional): The number of dimensions of the input image. - voxel_length (float, optional): The voxel size of the input image. - sigma (float, optional): standard deviation of the Gaussian, controls the scale of the convolutional operator. - padding (str, optional): The padding type that will be used to produce the convolution. - Check options here: `numpy.pad `__. - orthogonal_rot (bool, optional): If true, the 3D images will be rotated over coronal, axial and sagittal axis. - - Returns: - ndarray: The filtered image. - """ - # Check if the input is a numpy array or a Image volume object - spatial_ref = None - if type(input_images) == image_volume_obj: - spatial_ref = input_images.spatialRef - input_images = input_images.data - - # Convert to shape : (B, W, H, D) - input_images = np.expand_dims(input_images.astype(np.float64), axis=0) - - if medscan: - # Initialize filter class params & instance - sigma = medscan.params.filter.log.sigma / voxel_length - length = 2 * int(4 * sigma + 0.5) + 1 - _filter = LaplacianOfGaussian( - ndims=medscan.params.filter.log.ndims, - size=length, - sigma=sigma, - padding=medscan.params.filter.log.padding - ) - # Run convolution - result = _filter.convolve(input_images, orthogonal_rot=medscan.params.filter.log.orthogonal_rot) - else: - # Initialize filter class params & instance - sigma = sigma / voxel_length - length = 2 * int(4 * sigma + 0.5) + 1 - _filter = LaplacianOfGaussian( - ndims=ndims, - size=length, - sigma=sigma, - padding=padding - ) - # Run convolution - result = _filter.convolve(input_images, orthogonal_rot=orthogonal_rot) - - if spatial_ref: - return image_volume_obj(np.squeeze(result), spatial_ref) - else: - return np.squeeze(result) diff --git a/MEDimage/filters/mean.py b/MEDimage/filters/mean.py deleted file mode 100644 index 7907b8a..0000000 --- a/MEDimage/filters/mean.py +++ /dev/null @@ -1,121 +0,0 @@ -from abc import ABC -from typing import Union - -import numpy as np - -from ..MEDscan import MEDscan -from ..utils.image_volume_obj import image_volume_obj -from .utils import convolve - - -class Mean(): - """The mean filter class""" - - def __init__( - self, - ndims: int, - size: int, - padding="symmetric"): - """The constructor of the mean filter - - Args: - ndims (int): Number of dimension of the kernel filter - size (int): An integer that represent the length along one dimension of the kernel. - padding: The padding type that will be used to produce the convolution - - Returns: - None - """ - - assert isinstance(ndims, int) and ndims > 0, "ndims should be a positive integer" - assert ((size+1)/2).is_integer() and size > 0, "size should be a positive odd number." - - self.padding = padding - self.dim = ndims - self.size = int(size) - self.create_kernel() - - def create_kernel(self): - """This method construct the mean kernel using the parameters specified to the constructor. - - Returns: - ndarray: The mean kernel as a numpy multidimensional array - """ - - # Initialize the kernel as tensor of zeros - weight = 1 / np.prod(self.size ** self.dim) - kernel = np.ones([self.size for _ in range(self.dim)]) * weight - - self.kernel = np.expand_dims(kernel, axis=(0, 1)) - - def convolve(self, - images: np.ndarray, - orthogonal_rot: bool = False)-> np.ndarray: - """Filter a given image using the LoG kernel defined during the construction of this instance. - - Args: - images (ndarray): A n-dimensional numpy array that represent the images to filter - orthogonal_rot (bool, optional): If true, the 3D images will be rotated over coronal, axial and sagittal axis - - Returns: - ndarray: The filtered image - """ - # Swap the second axis with the last, to convert image B, W, H, D --> B, D, H, W - image = np.swapaxes(images, 1, 3) - result = np.squeeze(convolve(self.dim, self.kernel, image, orthogonal_rot, self.padding), axis=1) - return np.swapaxes(result, 1, 3) - -def apply_mean( - input_images: Union[np.ndarray, image_volume_obj], - medscan: MEDscan = None, - ndims: int = 3, - size: int = 15, - padding: str = "symmetric", - orthogonal_rot: bool = False - ) -> np.ndarray: - """Apply the mean filter to the input image - - Args: - input_images (ndarray): The images to filter. - medscan (MEDscan, optional): The MEDscan object that will provide the filter parameters. - ndims (int, optional): The number of dimensions of the input image. - size (int, optional): The size of the kernel. - padding (str, optional): The padding type that will be used to produce the convolution. - Check options here: `numpy.pad `__. - orthogonal_rot (bool, optional): If true, the 3D images will be rotated over coronal, axial and sagittal axis. - - Returns: - ndarray: The filtered image. - """ - # Check if the input is a numpy array or a Image volume object - spatial_ref = None - if type(input_images) == image_volume_obj: - spatial_ref = input_images.spatialRef - input_images = input_images.data - - # Convert to shape : (B, W, H, D) - input_images = np.expand_dims(input_images.astype(np.float64), axis=0) - - if medscan: - # Initialize filter class instance - _filter = Mean( - ndims=medscan.params.filter.mean.ndims, - size=medscan.params.filter.mean.size, - padding=medscan.params.filter.mean.padding - ) - # Run convolution - result = _filter.convolve(input_images, orthogonal_rot=medscan.params.filter.mean.orthogonal_rot) - else: - # Initialize filter class instance - _filter = Mean( - ndims=ndims, - size=size, - padding=padding, - ) - # Run convolution - result = _filter.convolve(input_images, orthogonal_rot=orthogonal_rot) - - if spatial_ref: - return image_volume_obj(np.squeeze(result), spatial_ref) - else: - return np.squeeze(result) diff --git a/MEDimage/filters/textural_filters_kernels.py b/MEDimage/filters/textural_filters_kernels.py deleted file mode 100644 index b4d01c8..0000000 --- a/MEDimage/filters/textural_filters_kernels.py +++ /dev/null @@ -1,1738 +0,0 @@ -from string import Template - -glcm_kernel = Template(""" -#include -#include -#include - -# define MAX_SIZE ${max_vol} -# define FILTER_SIZE ${filter_size} - -// Function flatten a 3D matrix into a 1D vector -__device__ float * reshape(float(*matrix)[FILTER_SIZE][FILTER_SIZE]) { - //size of array - const int size = FILTER_SIZE* FILTER_SIZE* FILTER_SIZE; - float flattened[size]; - int index = 0; - for (int i = 0; i < FILTER_SIZE; ++i) { - for (int j = 0; j < FILTER_SIZE; ++j) { - for (int k = 0; k < FILTER_SIZE; ++k) { - flattened[index] = matrix[i][j][k]; - index++; - } - } - } - return flattened; -} - -// Function to perform histogram equalisation of the ROI imaging intensities -__device__ void discretize(float vol_quant_re[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE], - float max_val, float min_val=${min_val}) { - - // PARSING ARGUMENTS - float n_q = ${n_q}; - const char* discr_type = "${discr_type}"; - - // DISCRETISATION - if (discr_type == "FBS") { - float w_b = n_q; - for (int i = 0; i < FILTER_SIZE; i++) { - for (int j = 0; j < FILTER_SIZE; j++) { - for (int k = 0; k < FILTER_SIZE; k++) { - float value = vol_quant_re[i][j][k]; - if (!isnan(value)) { - vol_quant_re[i][j][k] = floorf((value - min_val) / w_b) + 1.0; - } - } - } - } - } - else if (discr_type == "FBN") { - float w_b = (max_val - min_val) / n_q; - for (int i = 0; i < FILTER_SIZE; i++) { - for (int j = 0; j < FILTER_SIZE; j++) { - for (int k = 0; k < FILTER_SIZE; k++) { - float value = vol_quant_re[i][j][k]; - if (!isnan(value)) { - vol_quant_re[i][j][k] = floorf(n_q * ((value - min_val) / (max_val - min_val))) + 1.0; - if (value == max_val) { - vol_quant_re[i][j][k] = n_q; - } - } - } - } - } - } - else { - printf("ERROR: discretization type not supported"); - assert(false); - } -} - -// Compute the diagonal probability -__device__ float * GLCMDiagProb(float p_ij[MAX_SIZE][MAX_SIZE], float max_vol) { - int valK[MAX_SIZE]; - for (int i = 0; i < (int)max_vol; ++i) { - valK[i] = i; - } - float p_iminusj[MAX_SIZE] = { 0.0 }; - for (int iterationK = 0; iterationK < (int)max_vol; ++iterationK) { - int k = valK[iterationK]; - float p = 0.0; - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - if (k - fabsf(i - j) == 0) { - p += p_ij[i][j]; - } - } - } - - p_iminusj[iterationK] = p; - } - - return p_iminusj; -} - -// Compute the cross-diagonal probability -__device__ float * GLCMCrossDiagProb(float p_ij[MAX_SIZE][MAX_SIZE], float max_vol) { - float valK[2 * MAX_SIZE - 1]; - // fill valK with 2, 3, 4, ..., 2*max_vol - 1 - for (int i = 0; i < 2 * (int)max_vol - 1; ++i) { - valK[i] = i + 2; - } - float p_iplusj[2*MAX_SIZE - 1] = { 0.0 }; - - for (int iterationK = 0; iterationK < 2*(int)max_vol - 1; ++iterationK) { - int k = valK[iterationK]; - float p = 0.0; - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - if (k - (i + j + 2) == 0) { - p += p_ij[i][j]; - } - } - } - - p_iplusj[iterationK] = p; - } - - return p_iplusj; -} - -__device__ void getGLCMmatrix( - float (*ROIonly)[FILTER_SIZE][FILTER_SIZE], - float GLCMfinal[MAX_SIZE][MAX_SIZE], - float max_vol, - bool distCorrection = true) -{ - // PARSING "distCorrection" ARGUMENT - - const int Ng = MAX_SIZE; - float levels[Ng] = {0}; - // initialize levels to 1, 2, 3, ..., 15 - for (int i = 0; i < (int)max_vol; ++i) { - levels[i] = i + 1; - } - - float levelTemp = max_vol + 1; - - for (int i = 0; i < FILTER_SIZE; ++i) { - for (int j = 0; j < FILTER_SIZE; ++j) { - for (int k = 0; k < FILTER_SIZE; ++k) { - if (isnan(ROIonly[i][j][k])) { - ROIonly[i][j][k] = levelTemp; - } - } - } - } - - int dim_x = FILTER_SIZE; - int dim_y = FILTER_SIZE; - int dim_z = FILTER_SIZE; - - // Reshape the 3D matrix to a 1D vector - float *q2; - q2 = reshape(ROIonly); - - // Combine levels and level_temp into qs - float qs[Ng + 1] = {0}; - for (int i = 0; i < (int)max_vol + 1; ++i) { - if (i == (int)max_vol) { - qs[i] = levelTemp; - break; - } - qs[i] = levels[i]; - } - const int lqs = Ng + 1; - - // Create a q3 matrix and assign values based on qs - int q3[FILTER_SIZE* FILTER_SIZE* FILTER_SIZE] = {0}; - - // fill q3 with 0s - for (int i = 0; i < FILTER_SIZE * FILTER_SIZE * FILTER_SIZE; ++i) { - q3[i] = 0; - } - for (int k = 0; k < (int)max_vol + 1; ++k) { - for (int i = 0; i < FILTER_SIZE * FILTER_SIZE * FILTER_SIZE; ++i) { - if (fabsf(q2[i] - qs[k]) < 1.19209e-07) { - q3[i] = k; - } - } - } - - // Reshape q3 back to the original dimensions (dimX, dimY, dimZ) - float reshaped_q3[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE]; - - int index = 0; - for (int i = 0; i < dim_x; ++i) { - for (int j = 0; j < dim_y; ++j) { - for (int k = 0; k < dim_z; ++k) { - reshaped_q3[i][j][k] = q3[index++]; - } - } - } - - - float GLCM[lqs][lqs] = {0}; - - // fill GLCM with 0s - for (int i = 0; i < (int)max_vol + 1; ++i) { - for (int j = 0; j < (int)max_vol + 1; ++j) { - GLCM[i][j] = 0; - } - } - - for (int i = 1; i <= dim_x; ++i) { - int i_min = max(1, i - 1); - int i_max = min(i + 1, dim_x); - for (int j = 1; j <= dim_y; ++j) { - int j_min = max(1, j - 1); - int j_max = min(j + 1, dim_y); - for (int k = 1; k <= dim_z; ++k) { - int k_min = max(1, k - 1); - int k_max = min(k + 1, dim_z); - int val_q3 = reshaped_q3[i - 1][j - 1][k - 1]; - for (int I2 = i_min; I2 <= i_max; ++I2) { - for (int J2 = j_min; J2 <= j_max; ++J2) { - for (int K2 = k_min; K2 <= k_max; ++K2) { - if (I2 == i && J2 == j && K2 == k) { - continue; - } - else { - int val_neighbor = reshaped_q3[I2 - 1][J2 - 1][K2 - 1]; - if (distCorrection) { - // Discretization length correction - GLCM[val_q3][val_neighbor] += - sqrtf(fabsf(I2 - i) + - fabsf(J2 - j) + - fabsf(K2 - k)); - } - else { - GLCM[val_q3][val_neighbor] += 1; - } - } - } - } - } - } - } - } - - // Eliminate last row and column - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - GLCMfinal[i][j] = GLCM[i][j]; - } - } -} - -__device__ void computeGLCMFeatures(float(*vol)[FILTER_SIZE][FILTER_SIZE], float features[25], float max_vol, bool distCorrection) { - - float GLCM[MAX_SIZE][MAX_SIZE] = { 0.0 }; - - // Call function with specified distCorrection - getGLCMmatrix(vol, GLCM, max_vol, distCorrection); - - // Normalize GLCM - float sumGLCM = 0.0; - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - sumGLCM += GLCM[i][j]; - } - } - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - GLCM[i][j] /= sumGLCM; - } - } - - // Compute textures - // // Number of gray levels - const int Ng = MAX_SIZE; - float vectNg[Ng]; - - // fill vectNg with 1, 2, ..., Ng - for (int i = 0; i < (int)max_vol; ++i) { - vectNg[i] = i + 1; - } - - // Create meshgird of size Ng x Ng - float colGrid[Ng][Ng] = { 0.0 }; - float rowGrid[Ng][Ng] = { 0.0 }; - - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - colGrid[i][j] = vectNg[j]; - } - } - for (int j = 0; j < int(max_vol); ++j) { - for (int i = 0; i < int(max_vol); ++i) { - rowGrid[i][j] = vectNg[i]; - } - } - int step_i = 0; - int step_j = 0; - - // Joint maximum - float joint_max = 0.0; - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - joint_max = max(joint_max, GLCM[i][j]); - } - } - features[0] = joint_max; - - // Joint average - float u = 0.0; - for (int i = 0; i < (int)max_vol; ++i) { - step_i = 0; - for (int j = 0; j < (int)max_vol; ++j) { - u += GLCM[i][j] * rowGrid[i][j]; - step_i++; - } - step_j++; - } - features[1] = u; - - // Joint variance - step_j = 0; - float var = 0.0; - u = 0.0; - for (int i = 0; i < (int)max_vol; ++i) { - step_i = 0; - for (int j = 0; j < (int)max_vol; ++j) { - u += GLCM[i][j] * rowGrid[i][j]; - step_i++; - } - step_j++; - } - for (int i = 0; i < (int)max_vol; ++i) { - step_i = 0; - for (int j = 0; j < (int)max_vol; ++j) { - var += GLCM[i][j] * powf(rowGrid[i][j] - u, 2); - step_i++; - } - step_j++; - } - features[2] = var; - - // Joint entropy - float entropy = 0.0; - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - if (GLCM[i][j] > 0.0) { - entropy += GLCM[i][j] * log2f(GLCM[i][j]); - } - } - } - features[3] = -entropy; - - // Difference average - float* p_iminusj; - p_iminusj = GLCMDiagProb(GLCM, max_vol); - float diff_avg = 0.0; - float k[Ng]; - // fill k with 0, 1, ..., Ng - 1 - for (int i = 0; i < int(max_vol); ++i) { - k[i] = i; - } - for (int i = 0; i < int(max_vol); ++i) { - diff_avg += p_iminusj[i] * k[i]; - } - features[4] = diff_avg; - - // Difference variance - diff_avg = 0.0; - // fill k with 0, 1, ..., Ng - 1 - for (int i = 0; i < int(max_vol); ++i) { - k[i] = i; - } - for (int i = 0; i < int(max_vol); ++i) { - diff_avg += p_iminusj[i] * k[i]; - } - float diff_var = 0.0; - step_i = 0; - for (int i = 0; i < int(max_vol); ++i) { - diff_var += p_iminusj[i] * powf(k[i] - diff_avg, 2); - step_i++; - } - features[5] = diff_var; - - // Difference entropy - float diff_entropy = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - if (p_iminusj[i] > 0.0) { - diff_entropy += p_iminusj[i] * log2f(p_iminusj[i]); - } - } - features[6] = -diff_entropy; - - // Sum average - float k1[2 * Ng - 1]; - // fill k with 2, 3, ..., 2 * Ng - for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { - k1[i] = i + 2; - } - float sum_avg = 0.0; - float* p_iplusj = GLCMCrossDiagProb(GLCM, max_vol); - for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { - sum_avg += p_iplusj[i] * k1[i]; - } - features[7] = sum_avg; - - // Sum variance - float sum_var = 0.0; - for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { - sum_var += p_iplusj[i] * powf(k1[i] - sum_avg, 2); - } - features[8] = sum_var; - - // Sum entropy - float sum_entr = 0.0; - for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { - if (p_iplusj[i] > 0.0) { - sum_entr += p_iplusj[i] * log2f(p_iplusj[i]); - } - } - features[9] = -sum_entr; - - // Angular second moment (energy) - float energy = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - for (int j = 0; j < int(max_vol); ++j) { - energy += powf(GLCM[i][j], 2); - } - } - features[10] = energy; - - // Contrast - float contrast = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - for (int j = 0; j < int(max_vol); ++j) { - contrast += powf(rowGrid[i][j] - colGrid[i][j], 2) * GLCM[i][j]; - } - } - features[11] = contrast; - - // Dissimilarity - float dissimilarity = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - for (int j = 0; j < int(max_vol); ++j) { - dissimilarity += fabsf(rowGrid[i][j] - colGrid[i][j]) * GLCM[i][j]; - } - } - features[12] = dissimilarity; - - // Inverse difference - float inv_diff = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - for (int j = 0; j < int(max_vol); ++j) { - inv_diff += GLCM[i][j] / (1 + fabsf(rowGrid[i][j] - colGrid[i][j])); - } - } - features[13] = inv_diff; - - // Inverse difference normalized - float invDiffNorm = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - for (int j = 0; j < int(max_vol); ++j) { - invDiffNorm += GLCM[i][j] / (1 + fabsf(rowGrid[i][j] - colGrid[i][j]) / int(max_vol)); - } - } - features[14] = invDiffNorm; - - // Inverse difference moment - float invDiffMom = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - for (int j = 0; j < int(max_vol); ++j) { - invDiffMom += GLCM[i][j] / (1 + powf((rowGrid[i][j] - colGrid[i][j]), 2)); - } - } - features[15] = invDiffMom; - - // Inverse difference moment normalized - float invDiffMomNorm = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - for (int j = 0; j < int(max_vol); ++j) { - invDiffMomNorm += GLCM[i][j] / (1 + powf((rowGrid[i][j] - colGrid[i][j]), 2) / powf(int(max_vol), 2)); - } - } - features[16] = invDiffMomNorm; - - // Inverse variance - float invVar = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = i + 1; j < int(max_vol); j++) { - invVar += GLCM[i][j] / powf((i - j), 2); - } - } - features[17] = 2*invVar; - - // Correlation - float u_i = 0.0; - float u_j = 0.0; - float std_i = 0.0; - float std_j = 0.0; - float p_i[Ng] = { 0.0 }; - float p_j[Ng] = { 0.0 }; - - // sum over rows - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_i[i] += GLCM[i][j]; - } - } - // sum over columns - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_j[j] += GLCM[i][j]; - } - } - for (int i = 0; i < int(max_vol); i++) { - u_i += vectNg[i] * p_i[i]; - u_j += vectNg[i] * p_j[i]; - } - for (int i = 0; i < int(max_vol); i++) { - std_i += powf(vectNg[i] - u_i, 2) * p_i[i]; - std_j += powf(vectNg[i] - u_j, 2) * p_j[i]; - } - std_i = sqrtf(std_i); - std_j = sqrtf(std_j); - - float tempSum = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - tempSum += rowGrid[i][j] * colGrid[i][j] * GLCM[i][j]; - } - } - float correlation = (1 / (std_i * std_j)) * (-u_i * u_j + tempSum); - features[18] = correlation; - - // Autocorrelation - float autoCorr = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - autoCorr += rowGrid[i][j] * colGrid[i][j] * GLCM[i][j]; - } - } - features[19] = autoCorr; - - // Cluster tendency - float clusterTend = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - clusterTend += powf(rowGrid[i][j] + colGrid[i][j] - u_i - u_j, 2) * GLCM[i][j]; - } - } - features[20] = clusterTend; - - // Cluster shade - float clusterShade = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - clusterShade += powf(rowGrid[i][j] + colGrid[i][j] - u_i - u_j, 3) * GLCM[i][j]; - } - } - features[21] = clusterShade; - - // Cluster prominence - float clusterProm = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - clusterProm += powf(rowGrid[i][j] + colGrid[i][j] - u_i - u_j, 4) * GLCM[i][j]; - } - } - features[22] = clusterProm; - - // First measure of information correlation - float HXY = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - if (GLCM[i][j] > 0.0) { - HXY += GLCM[i][j] * log2f(GLCM[i][j]); - } - } - } - HXY = -HXY; - - float HX = 0.0; - for (int i = 0; i < int(max_vol); i++) { - if (p_i[i] > 0.0) { - HX += p_i[i] * log2f(p_i[i]); - } - } - HX = -HX; - - // Repeat p_i and p_j Ng times - float p_i_temp[Ng][Ng]; - float p_j_temp[Ng][Ng]; - float p_temp[Ng][Ng]; - - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_i_temp[i][j] = p_i[i]; - p_j_temp[i][j] = p_j[j]; - p_temp[i][j] = p_i_temp[i][j] * p_j_temp[i][j]; - } - } - - float HXY1 = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - if (p_temp[i][j] > 0.0) { - HXY1 += GLCM[i][j] * log2f(p_temp[i][j]); - } - } - } - HXY1 = -HXY1; - features[23] = (HXY - HXY1) / HX; - - // Second measure of information correlation - float HXY2 = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - if (p_temp[i][j] > 0.0) { - HXY2 += p_temp[i][j] * log2f(p_temp[i][j]); - } - } - } - HXY2 = -HXY2; - if (HXY > HXY2) { - features[24] = 0.0; - } - else { - features[24] = sqrtf(1 - expf(-2 * (HXY2 - HXY))); - } -} - -extern "C" -__global__ void glcm_filter_global( - float vol[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}][25], - float vol_copy[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}], - bool distCorrection = false) -{ - int i = blockIdx.x * blockDim.x + threadIdx.x; - int j = blockIdx.y * blockDim.y + threadIdx.y; - int k = blockIdx.z * blockDim.z + threadIdx.z; - - if (i < ${shape_volume_0} && j < ${shape_volume_1} && k < ${shape_volume_2} && i >= 0 && j >= 0 && k >= 0) { - // pad size - const int padd_size = (FILTER_SIZE - 1) / 2; - - // size vol - const int size_x = ${shape_volume_0}; - const int size_y = ${shape_volume_1}; - const int size_z = ${shape_volume_2}; - - // skip all calculations if vol at position i,j,k is nan - if (!isnan(vol_copy[i][j][k])) { - // get submatrix - float sub_matrix[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE] = {NAN}; - for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { - for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { - for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { - if ((i - padd_size + idx_i) >= 0 && (i - padd_size + idx_i) < size_x && - (j - padd_size + idx_j) >= 0 && (j - padd_size + idx_j) < size_y && - (k - padd_size + idx_k) >= 0 && (k - padd_size + idx_k) < size_z) { - sub_matrix[idx_i][idx_j][idx_k] = vol_copy[i - padd_size + idx_i][j - padd_size + idx_j][k - padd_size + idx_k]; - } - } - } - } - - // get the maximum value of the submatrix - float max_vol = -3.40282e+38; - for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { - for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { - for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { - max_vol = max(max_vol, sub_matrix[idx_i][idx_j][idx_k]); - } - } - } - - // compute GLCM features - float features[25] = { 0.0 }; - computeGLCMFeatures(sub_matrix, features, max_vol, false); - - // Copy GLCM feature to voxels of the volume - if (i < size_x && j < size_y && k < size_z){ - for (int idx = 0; idx < 25; ++idx) { - vol[i][j][k][idx] = features[idx]; - } - } - } - } -} - -extern "C" -__global__ void glcm_filter_local( - float vol[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}][25], - float vol_copy[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}], - bool distCorrection = false) -{ - int i = blockIdx.x * blockDim.x + threadIdx.x; - int j = blockIdx.y * blockDim.y + threadIdx.y; - int k = blockIdx.z * blockDim.z + threadIdx.z; - - if (i < ${shape_volume_0} && j < ${shape_volume_1} && k < ${shape_volume_2} && i >= 0 && j >= 0 && k >= 0) { - // pad size - const int padd_size = (FILTER_SIZE - 1) / 2; - - // size vol - const int size_x = ${shape_volume_0}; - const int size_y = ${shape_volume_1}; - const int size_z = ${shape_volume_2}; - - // skip all calculations if vol at position i,j,k is nan - if (!isnan(vol_copy[i][j][k])) { - // get submatrix - float sub_matrix[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE] = {NAN}; - for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { - for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { - for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { - if ((i - padd_size + idx_i) >= 0 && (i - padd_size + idx_i) < size_x && - (j - padd_size + idx_j) >= 0 && (j - padd_size + idx_j) < size_y && - (k - padd_size + idx_k) >= 0 && (k - padd_size + idx_k) < size_z) { - sub_matrix[idx_i][idx_j][idx_k] = vol_copy[i - padd_size + idx_i][j - padd_size + idx_j][k - padd_size + idx_k]; - } - } - } - } - - // get the maximum value of the submatrix - float max_vol = -3.40282e+38; - for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { - for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { - for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { - max_vol = max(max_vol, sub_matrix[idx_i][idx_j][idx_k]); - } - } - } - // get the minimum value of the submatrix if discr_type is FBN - float min_val = 3.40282e+38; - if ("${discr_type}" == "FBN") { - for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { - for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { - for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { - min_val = min(min_val, sub_matrix[idx_i][idx_j][idx_k]); - } - } - } - discretize(sub_matrix, max_vol, min_val); - } - - // If FBS discretize the submatrix with user set minimum value - else{ - discretize(sub_matrix, max_vol); - } - - // get the maximum value of the submatrix after discretization - max_vol = -3.40282e+38; - for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { - for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { - for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { - max_vol = max(max_vol, sub_matrix[idx_i][idx_j][idx_k]); - } - } - } - - // compute GLCM features - float features[25] = { 0.0 }; - computeGLCMFeatures(sub_matrix, features, max_vol, false); - - // Copy GLCM feature to voxels of the volume - if (i < size_x && j < size_y && k < size_z){ - for (int idx = 0; idx < 25; ++idx) { - vol[i][j][k][idx] = features[idx]; - } - } - } - } -} -""") - -# Signle-feature kernel -single_glcm_kernel = Template(""" -#include -#include -#include - -# define MAX_SIZE ${max_vol} -# define FILTER_SIZE ${filter_size} - -// Function flatten a 3D matrix into a 1D vector -__device__ float * reshape(float(*matrix)[FILTER_SIZE][FILTER_SIZE]) { - //size of array - const int size = FILTER_SIZE* FILTER_SIZE* FILTER_SIZE; - float flattened[size]; - int index = 0; - for (int i = 0; i < FILTER_SIZE; ++i) { - for (int j = 0; j < FILTER_SIZE; ++j) { - for (int k = 0; k < FILTER_SIZE; ++k) { - flattened[index] = matrix[i][j][k]; - index++; - } - } - } - return flattened; -} - -// Function to perform discretization on the ROI imaging intensities -__device__ void discretize(float vol_quant_re[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE], - float max_val, float min_val=${min_val}) { - - // PARSING ARGUMENTS - float n_q = ${n_q}; - const char* discr_type = "${discr_type}"; - - // DISCRETISATION - if (discr_type == "FBS") { - float w_b = n_q; - for (int i = 0; i < FILTER_SIZE; i++) { - for (int j = 0; j < FILTER_SIZE; j++) { - for (int k = 0; k < FILTER_SIZE; k++) { - float value = vol_quant_re[i][j][k]; - if (!isnan(value)) { - vol_quant_re[i][j][k] = floorf((value - min_val) / w_b) + 1.0; - } - } - } - } - } - else if (discr_type == "FBN") { - float w_b = (max_val - min_val) / n_q; - for (int i = 0; i < FILTER_SIZE; i++) { - for (int j = 0; j < FILTER_SIZE; j++) { - for (int k = 0; k < FILTER_SIZE; k++) { - float value = vol_quant_re[i][j][k]; - if (!isnan(value)) { - vol_quant_re[i][j][k] = floorf(n_q * ((value - min_val) / (max_val - min_val))) + 1.0; - if (value == max_val) { - vol_quant_re[i][j][k] = n_q; - } - } - } - } - } - } - else { - printf("ERROR: discretization type not supported"); - assert(false); - } -} - -// Compute the diagonal probability -__device__ float * GLCMDiagProb(float p_ij[MAX_SIZE][MAX_SIZE], float max_vol) { - int valK[MAX_SIZE]; - for (int i = 0; i < (int)max_vol; ++i) { - valK[i] = i; - } - float p_iminusj[MAX_SIZE] = { 0.0 }; - for (int iterationK = 0; iterationK < (int)max_vol; ++iterationK) { - int k = valK[iterationK]; - float p = 0.0; - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - if (k - fabsf(i - j) == 0) { - p += p_ij[i][j]; - } - } - } - - p_iminusj[iterationK] = p; - } - - return p_iminusj; -} - -// Compute the cross-diagonal probability -__device__ float * GLCMCrossDiagProb(float p_ij[MAX_SIZE][MAX_SIZE], float max_vol) { - float valK[2 * MAX_SIZE - 1]; - // fill valK with 2, 3, 4, ..., 2*max_vol - 1 - for (int i = 0; i < 2 * (int)max_vol - 1; ++i) { - valK[i] = i + 2; - } - float p_iplusj[2*MAX_SIZE - 1] = { 0.0 }; - - for (int iterationK = 0; iterationK < 2*(int)max_vol - 1; ++iterationK) { - int k = valK[iterationK]; - float p = 0.0; - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - if (k - (i + j + 2) == 0) { - p += p_ij[i][j]; - } - } - } - - p_iplusj[iterationK] = p; - } - - return p_iplusj; -} - -__device__ void getGLCMmatrix( - float (*ROIonly)[FILTER_SIZE][FILTER_SIZE], - float GLCMfinal[MAX_SIZE][MAX_SIZE], - float max_vol, - bool distCorrection = true) -{ - // PARSING "distCorrection" ARGUMENT - - const int Ng = MAX_SIZE; - float levels[Ng] = {0}; - // initialize levels to 1, 2, 3, ..., 15 - for (int i = 0; i < (int)max_vol; ++i) { - levels[i] = i + 1; - } - - float levelTemp = max_vol + 1; - - for (int i = 0; i < FILTER_SIZE; ++i) { - for (int j = 0; j < FILTER_SIZE; ++j) { - for (int k = 0; k < FILTER_SIZE; ++k) { - if (isnan(ROIonly[i][j][k])) { - ROIonly[i][j][k] = levelTemp; - } - } - } - } - - int dim_x = FILTER_SIZE; - int dim_y = FILTER_SIZE; - int dim_z = FILTER_SIZE; - - // Reshape the 3D matrix to a 1D vector - float *q2; - q2 = reshape(ROIonly); - - // Combine levels and level_temp into qs - float qs[Ng + 1] = {0}; - for (int i = 0; i < (int)max_vol + 1; ++i) { - if (i == (int)max_vol) { - qs[i] = levelTemp; - break; - } - qs[i] = levels[i]; - } - const int lqs = Ng + 1; - - // Create a q3 matrix and assign values based on qs - int q3[FILTER_SIZE* FILTER_SIZE* FILTER_SIZE] = {0}; - - // fill q3 with 0s - for (int i = 0; i < FILTER_SIZE * FILTER_SIZE * FILTER_SIZE; ++i) { - q3[i] = 0; - } - for (int k = 0; k < (int)max_vol + 1; ++k) { - for (int i = 0; i < FILTER_SIZE * FILTER_SIZE * FILTER_SIZE; ++i) { - if (fabsf(q2[i] - qs[k]) < 1.19209e-07) { - q3[i] = k; - } - } - } - - // Reshape q3 back to the original dimensions (dimX, dimY, dimZ) - float reshaped_q3[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE]; - - int index = 0; - for (int i = 0; i < dim_x; ++i) { - for (int j = 0; j < dim_y; ++j) { - for (int k = 0; k < dim_z; ++k) { - reshaped_q3[i][j][k] = q3[index++]; - } - } - } - - - float GLCM[lqs][lqs] = {0}; - - // fill GLCM with 0s - for (int i = 0; i < (int)max_vol + 1; ++i) { - for (int j = 0; j < (int)max_vol + 1; ++j) { - GLCM[i][j] = 0; - } - } - - for (int i = 1; i <= dim_x; ++i) { - int i_min = max(1, i - 1); - int i_max = min(i + 1, dim_x); - for (int j = 1; j <= dim_y; ++j) { - int j_min = max(1, j - 1); - int j_max = min(j + 1, dim_y); - for (int k = 1; k <= dim_z; ++k) { - int k_min = max(1, k - 1); - int k_max = min(k + 1, dim_z); - int val_q3 = reshaped_q3[i - 1][j - 1][k - 1]; - for (int I2 = i_min; I2 <= i_max; ++I2) { - for (int J2 = j_min; J2 <= j_max; ++J2) { - for (int K2 = k_min; K2 <= k_max; ++K2) { - if (I2 == i && J2 == j && K2 == k) { - continue; - } - else { - int val_neighbor = reshaped_q3[I2 - 1][J2 - 1][K2 - 1]; - if (distCorrection) { - // Discretization length correction - GLCM[val_q3][val_neighbor] += - sqrtf(fabsf(I2 - i) + - fabsf(J2 - j) + - fabsf(K2 - k)); - } - else { - GLCM[val_q3][val_neighbor] += 1; - } - } - } - } - } - } - } - } - - // Eliminate last row and column - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - GLCMfinal[i][j] = GLCM[i][j]; - } - } -} - -__device__ float computeGLCMFeatures(float(*vol)[FILTER_SIZE][FILTER_SIZE], int feature, float max_vol, bool distCorrection) { - - float GLCM[MAX_SIZE][MAX_SIZE] = { 0.0 }; - - // Call function with specified distCorrection - getGLCMmatrix(vol, GLCM, max_vol, distCorrection); - - // Normalize GLCM - float sumGLCM = 0.0; - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - sumGLCM += GLCM[i][j]; - } - } - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - GLCM[i][j] /= sumGLCM; - } - } - - // Compute textures - // // Number of gray levels - const int Ng = MAX_SIZE; - float vectNg[Ng]; - - // fill vectNg with 1, 2, ..., Ng - for (int i = 0; i < (int)max_vol; ++i) { - vectNg[i] = i + 1; - } - - // Create meshgird of size Ng x Ng - float colGrid[Ng][Ng] = { 0.0 }; - float rowGrid[Ng][Ng] = { 0.0 }; - - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - colGrid[i][j] = vectNg[j]; - } - } - for (int j = 0; j < int(max_vol); ++j) { - for (int i = 0; i < int(max_vol); ++i) { - rowGrid[i][j] = vectNg[i]; - } - } - int step_i = 0; - int step_j = 0; - - // Joint maximum - if (feature == 0) { - float joint_max = NAN; - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - joint_max = max(joint_max, GLCM[i][j]); - } - } - return joint_max; - } - // Joint average - else if (feature == 1) { - float u = 0.0; - for (int i = 0; i < (int)max_vol; ++i) { - step_i = 0; - for (int j = 0; j < (int)max_vol; ++j) { - u += GLCM[i][j] * rowGrid[i][j]; - step_i++; - } - step_j++; - } - return u; - } - // Joint variance - else if (feature == 2) { - step_j = 0; - float var = 0.0; - float u = 0.0; - for (int i = 0; i < (int)max_vol; ++i) { - step_i = 0; - for (int j = 0; j < (int)max_vol; ++j) { - u += GLCM[i][j] * rowGrid[i][j]; - step_i++; - } - step_j++; - } - for (int i = 0; i < (int)max_vol; ++i) { - step_i = 0; - for (int j = 0; j < (int)max_vol; ++j) { - var += GLCM[i][j] * powf(rowGrid[i][j] - u, 2); - step_i++; - } - step_j++; - } - return var; - } - // Joint entropy - else if (feature == 3) { - float entropy = 0.0; - for (int i = 0; i < (int)max_vol; ++i) { - for (int j = 0; j < (int)max_vol; ++j) { - if (GLCM[i][j] > 0.0) { - entropy += GLCM[i][j] * log2f(GLCM[i][j]); - } - } - } - return -entropy; - } - // Difference average - else if (feature == 4) { - float *p_iminusj; - p_iminusj = GLCMDiagProb(GLCM, max_vol); - float diff_avg = 0.0; - float k[Ng]; - // fill k with 0, 1, ..., Ng - 1 - for (int i = 0; i < int(max_vol); ++i) { - k[i] = i; - } - for (int i = 0; i < int(max_vol); ++i) { - diff_avg += p_iminusj[i] * k[i]; - } - return diff_avg; - } - // Difference variance - else if (feature == 5) { - float* p_iminusj; - p_iminusj = GLCMDiagProb(GLCM, max_vol); - float diff_avg = 0.0; - float k[Ng]; - // fill k with 0, 1, ..., Ng - 1 - for (int i = 0; i < int(max_vol); ++i) { - k[i] = i; - } - for (int i = 0; i < int(max_vol); ++i) { - diff_avg += p_iminusj[i] * k[i]; - } - float diff_var = 0.0; - step_i = 0; - for (int i = 0; i < int(max_vol); ++i) { - diff_var += p_iminusj[i] * powf(k[i] - diff_avg, 2); - step_i++; - } - return diff_var; - } - // Difference entropy - else if (feature == 6) { - float* p_iminusj = GLCMDiagProb(GLCM, max_vol); - float diff_entropy = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - if (p_iminusj[i] > 0.0) { - diff_entropy += p_iminusj[i] * log2f(p_iminusj[i]); - } - } - return -diff_entropy; - } - // Sum average - else if (feature == 7) { - float k[2 * Ng - 1]; - // fill k with 2, 3, ..., 2 * Ng - for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { - k[i] = i + 2; - } - float sum_avg = 0.0; - float* p_iplusj = GLCMCrossDiagProb(GLCM, max_vol); - for (int i = 0; i < 2*int(max_vol) - 1; ++i) { - sum_avg += p_iplusj[i] * k[i]; - } - return sum_avg; - } - // Sum variance - else if (feature == 8) { - float k[2 * Ng - 1]; - // fill k with 2, 3, ..., 2 * Ng - for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { - k[i] = i + 2; - } - float sum_avg = 0.0; - float* p_iplusj = GLCMCrossDiagProb(GLCM, max_vol); - for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { - sum_avg += p_iplusj[i] * k[i]; - } - float sum_var = 0.0; - for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { - sum_var += p_iplusj[i] * powf(k[i] - sum_avg, 2); - } - return sum_var; - } - // Sum entropy - else if (feature == 9) { - float sum_entr = 0.0; - float* p_iplusj = GLCMCrossDiagProb(GLCM, max_vol); - for (int i = 0; i < 2 * int(max_vol) - 1; ++i) { - if (p_iplusj[i] > 0.0) { - sum_entr += p_iplusj[i] * log2f(p_iplusj[i]); - } - } - return -sum_entr; - } - // Angular second moment (energy) - else if (feature == 10) { - float energy = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - for (int j = 0; j < int(max_vol); ++j) { - energy += powf(GLCM[i][j], 2); - } - } - return energy; - } - // Contrast - else if (feature == 11) { - float contrast = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - for (int j = 0; j < int(max_vol); ++j) { - contrast += powf(rowGrid[i][j] - colGrid[i][j], 2) * GLCM[i][j]; - } - } - return contrast; - } - // Dissimilarity - else if (feature == 12) { - float dissimilarity = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - for (int j = 0; j < int(max_vol); ++j) { - dissimilarity += fabsf(rowGrid[i][j] - colGrid[i][j]) * GLCM[i][j]; - } - } - return dissimilarity; - } - // Inverse difference - else if (feature == 13) { - float inv_diff = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - for (int j = 0; j < int(max_vol); ++j) { - inv_diff += GLCM[i][j] / (1 + fabsf(rowGrid[i][j] - colGrid[i][j])); - } - } - return inv_diff; - } - // Inverse difference normalized - else if (feature == 14) { - float invDiffNorm = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - for (int j = 0; j < int(max_vol); ++j) { - invDiffNorm += GLCM[i][j] / (1 + fabsf(rowGrid[i][j] - colGrid[i][j]) / int(max_vol)); - } - } - return invDiffNorm; - } - // Inverse difference moment - else if (feature == 15) { - float invDiffMom = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - for (int j = 0; j < int(max_vol); ++j) { - invDiffMom += GLCM[i][j] / (1 + powf((rowGrid[i][j] - colGrid[i][j]), 2)); - } - } - return invDiffMom; - } - // Inverse difference moment normalized - else if (feature == 16) { - float invDiffMomNorm = 0.0; - for (int i = 0; i < int(max_vol); ++i) { - for (int j = 0; j < int(max_vol); ++j) { - invDiffMomNorm += GLCM[i][j] / (1 + powf((rowGrid[i][j] - colGrid[i][j]), 2) / powf(int(max_vol), 2)); - } - } - return invDiffMomNorm; - } - // Inverse variance - else if (feature == 17) { - float invVar = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = i + 1; j < int(max_vol); j++) { - invVar += GLCM[i][j] / powf((i - j), 2); - } - } - return 2*invVar; - } - // Correlation - else if (feature == 18) { - float u_i = 0.0; - float u_j = 0.0; - float std_i = 0.0; - float std_j = 0.0; - float p_i[Ng] = { 0.0 }; - float p_j[Ng] = { 0.0 }; - - // sum over rows - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_i[i] += GLCM[i][j]; - } - } - // sum over columns - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_j[j] += GLCM[i][j]; - } - } - for (int i = 0; i < int(max_vol); i++) { - u_i += vectNg[i] * p_i[i]; - u_j += vectNg[i] * p_j[i]; - } - for (int i = 0; i < int(max_vol); i++) { - std_i += powf(vectNg[i] - u_i, 2) * p_i[i]; - std_j += powf(vectNg[i] - u_j, 2) * p_j[i]; - } - std_i = sqrtf(std_i); - std_j = sqrtf(std_j); - - float tempSum = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - tempSum += rowGrid[i][j] * colGrid[i][j] * GLCM[i][j]; - } - } - float correlation = (1 / (std_i * std_j)) * (-u_i * u_j + tempSum); - return correlation; - } - // Autocorrelation - else if (feature == 19) { - float u_i = 0.0; - float u_j = 0.0; - float std_i = 0.0; - float std_j = 0.0; - float p_i[Ng] = { 0.0 }; - float p_j[Ng] = { 0.0 }; - - // sum over rows - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_i[i] += GLCM[i][j]; - } - } - // sum over columns - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_j[j] += GLCM[i][j]; - } - } - for (int i = 0; i < int(max_vol); i++) { - u_i += vectNg[i] * p_i[i]; - u_j += vectNg[i] * p_j[i]; - } - for (int i = 0; i < int(max_vol); i++) { - std_i += powf(vectNg[i] - u_i, 2) * p_i[i]; - std_j += powf(vectNg[i] - u_j, 2) * p_j[i]; - } - std_i = sqrtf(std_i); - std_j = sqrtf(std_j); - - float autoCorr = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - autoCorr += rowGrid[i][j] * colGrid[i][j] * GLCM[i][j]; - } - } - - return autoCorr; - } - // Cluster tendency - else if (feature == 20) { - float u_i = 0.0; - float u_j = 0.0; - float p_i[Ng] = { 0.0 }; - float p_j[Ng] = { 0.0 }; - - // sum over rows - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_i[i] += GLCM[i][j]; - } - } - // sum over columns - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_j[j] += GLCM[i][j]; - } - } - for (int i = 0; i < int(max_vol); i++) { - u_i += vectNg[i] * p_i[i]; - u_j += vectNg[i] * p_j[i]; - } - float clusterTend = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - clusterTend += powf(rowGrid[i][j] + colGrid[i][j] - u_i - u_j, 2) * GLCM[i][j]; - } - } - return clusterTend; - } - // Cluster shade - else if (feature == 21) { - float u_i = 0.0; - float u_j = 0.0; - float p_i[Ng] = { 0.0 }; - float p_j[Ng] = { 0.0 }; - - // sum over rows - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_i[i] += GLCM[i][j]; - } - } - // sum over columns - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_j[j] += GLCM[i][j]; - } - } - for (int i = 0; i < int(max_vol); i++) { - u_i += vectNg[i] * p_i[i]; - u_j += vectNg[i] * p_j[i]; - } - float clusterShade = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - clusterShade += powf(rowGrid[i][j] + colGrid[i][j] - u_i - u_j, 3) * GLCM[i][j]; - } - } - return clusterShade; - } - // Cluster prominence - else if (feature == 22) { - float u_i = 0.0; - float u_j = 0.0; - float p_i[Ng] = { 0.0 }; - float p_j[Ng] = { 0.0 }; - - // sum over rows - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_i[i] += GLCM[i][j]; - } - } - // sum over columns - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_j[j] += GLCM[i][j]; - } - } - for (int i = 0; i < int(max_vol); i++) { - u_i += vectNg[i] * p_i[i]; - u_j += vectNg[i] * p_j[i]; - } - float clusterProm = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - clusterProm += powf(rowGrid[i][j] + colGrid[i][j] - u_i - u_j, 4) * GLCM[i][j]; - } - } - return clusterProm; - } - // First measure of information correlation - else if (feature == 23) { - float p_i[Ng] = { 0.0 }; - float p_j[Ng] = { 0.0 }; - // sum over rows - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_i[i] += GLCM[i][j]; - } - } - // sum over columns - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_j[j] += GLCM[i][j]; - } - } - - float HXY = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - if (GLCM[i][j] > 0.0) { - HXY += GLCM[i][j] * log2f(GLCM[i][j]); - } - } - } - HXY = -HXY; - - float HX = 0.0; - for (int i = 0; i < int(max_vol); i++) { - if (p_i[i] > 0.0) { - HX += p_i[i] * log2f(p_i[i]); - } - } - HX = -HX; - - // Repeat p_i and p_j Ng times - float p_i_temp[Ng][Ng]; - float p_j_temp[Ng][Ng]; - float p_temp[Ng][Ng]; - - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_i_temp[i][j] = p_i[i]; - p_j_temp[i][j] = p_j[j]; - p_temp[i][j] = p_i_temp[i][j] * p_j_temp[i][j]; - } - } - - float HXY1 = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - if (p_temp[i][j] > 0.0) { - HXY1 += GLCM[i][j] * log2f(p_temp[i][j]); - } - } - } - HXY1 = -HXY1; - - return (HXY - HXY1) / HX; - } - // Second measure of information correlation - else if (feature == 24) { - float p_i[Ng] = { 0.0 }; - float p_j[Ng] = { 0.0 }; - // sum over rows - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_i[i] += GLCM[i][j]; - } - } - // sum over columns - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_j[j] += GLCM[i][j]; - } - } - - float HXY = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - if (GLCM[i][j] > 0.0) { - HXY += GLCM[i][j] * log2f(GLCM[i][j]); - } - } - } - HXY = -HXY; - - // Repeat p_i and p_j Ng times - float p_i_temp[Ng][Ng]; - float p_j_temp[Ng][Ng]; - float p_temp[Ng][Ng]; - - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - p_i_temp[i][j] = p_i[i]; - p_j_temp[i][j] = p_j[j]; - p_temp[i][j] = p_i_temp[i][j] * p_j_temp[i][j]; - } - } - - float HXY2 = 0.0; - for (int i = 0; i < int(max_vol); i++) { - for (int j = 0; j < int(max_vol); j++) { - if (p_temp[i][j] > 0.0) { - HXY2 += p_temp[i][j] * log2f(p_temp[i][j]); - } - } - } - HXY2 = -HXY2; - if (HXY > HXY2) { - return 0.0; - } - else { - return sqrtf(1 - expf(-2 * (HXY2 - HXY))); - } - } - else { - // Print error message - printf("Error: feature %d not implemented\\n", feature); - assert(false); - } -} - -extern "C" -__global__ void glcm_filter_global( - float vol[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}], - float vol_copy[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}], - bool distCorrection = false) -{ - int i = blockIdx.x * blockDim.x + threadIdx.x; - int j = blockIdx.y * blockDim.y + threadIdx.y; - int k = blockIdx.z * blockDim.z + threadIdx.z; - - if (i < ${shape_volume_0} && j < ${shape_volume_1} && k < ${shape_volume_2} && i >= 0 && j >= 0 && k >= 0) { - // pad size - const int padd_size = (FILTER_SIZE - 1) / 2; - - // size vol - const int size_x = ${shape_volume_0}; - const int size_y = ${shape_volume_1}; - const int size_z = ${shape_volume_2}; - - // skip all calculations if vol at position i,j,k is nan - if (!isnan(vol_copy[i][j][k])) { - // get submatrix - float sub_matrix[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE] = {NAN}; - for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { - for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { - for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { - if ((i - padd_size + idx_i) >= 0 && (i - padd_size + idx_i) < size_x && - (j - padd_size + idx_j) >= 0 && (j - padd_size + idx_j) < size_y && - (k - padd_size + idx_k) >= 0 && (k - padd_size + idx_k) < size_z) { - sub_matrix[idx_i][idx_j][idx_k] = vol_copy[i - padd_size + idx_i][j - padd_size + idx_j][k - padd_size + idx_k]; - } - } - } - } - - // get the maximum value of the submatrix - float max_vol = -3.40282e+38; - for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { - for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { - for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { - max_vol = max(max_vol, sub_matrix[idx_i][idx_j][idx_k]); - } - } - } - - // get feature index - const int feature = ${feature_index}; - - // compute GLCM features - float glcm_feature = computeGLCMFeatures(sub_matrix, feature, max_vol, false); - - // Copy GLCM feature to voxels of the volume - if (i < size_x && j < size_y && k < size_z){ - vol[i][j][k] = glcm_feature; - } - } - } -} - -extern "C" -__global__ void glcm_filter_local( - float vol[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}], - float vol_copy[${shape_volume_0}][${shape_volume_1}][${shape_volume_2}], - bool distCorrection = false) -{ - int i = blockIdx.x * blockDim.x + threadIdx.x; - int j = blockIdx.y * blockDim.y + threadIdx.y; - int k = blockIdx.z * blockDim.z + threadIdx.z; - - if (i < ${shape_volume_0} && j < ${shape_volume_1} && k < ${shape_volume_2} && i >= 0 && j >= 0 && k >= 0) { - // pad size - const int padd_size = (FILTER_SIZE - 1) / 2; - - // size vol - const int size_x = ${shape_volume_0}; - const int size_y = ${shape_volume_1}; - const int size_z = ${shape_volume_2}; - - // skip all calculations if vol at position i,j,k is nan - if (!isnan(vol_copy[i][j][k])) { - // get submatrix - float sub_matrix[FILTER_SIZE][FILTER_SIZE][FILTER_SIZE] = {NAN}; - for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { - for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { - for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { - if ((i - padd_size + idx_i) >= 0 && (i - padd_size + idx_i) < size_x && - (j - padd_size + idx_j) >= 0 && (j - padd_size + idx_j) < size_y && - (k - padd_size + idx_k) >= 0 && (k - padd_size + idx_k) < size_z) { - sub_matrix[idx_i][idx_j][idx_k] = vol_copy[i - padd_size + idx_i][j - padd_size + idx_j][k - padd_size + idx_k]; - } - } - } - } - - // get the maximum value of the submatrix - float max_vol = -3.40282e+38; - for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { - for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { - for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { - max_vol = max(max_vol, sub_matrix[idx_i][idx_j][idx_k]); - } - } - } - // get the minimum value of the submatrix if discr_type is FBN - float min_val = 3.40282e+38; - if ("${discr_type}" == "FBN") { - for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { - for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { - for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { - min_val = min(min_val, sub_matrix[idx_i][idx_j][idx_k]); - } - } - } - discretize(sub_matrix, max_vol, min_val); - } - - // If FBS discretize the submatrix with user set minimum value - else{ - discretize(sub_matrix, max_vol); - } - - // get the maximum value of the submatrix after discretization - max_vol = -3.40282e+38; - for (int idx_i = 0; idx_i < FILTER_SIZE; ++idx_i) { - for (int idx_j = 0; idx_j < FILTER_SIZE; ++idx_j) { - for (int idx_k = 0; idx_k < FILTER_SIZE; ++idx_k) { - max_vol = max(max_vol, sub_matrix[idx_i][idx_j][idx_k]); - } - } - } - - // get feature index - const int feature = ${feature_index}; - - // compute GLCM features - float glcm_feature = computeGLCMFeatures(sub_matrix, feature, max_vol, false); - - // Copy GLCM feature to voxels of the volume - if (i < size_x && j < size_y && k < size_z){ - vol[i][j][k] = glcm_feature; - } - } - } -} -""") \ No newline at end of file diff --git a/MEDimage/filters/utils.py b/MEDimage/filters/utils.py deleted file mode 100644 index d25720c..0000000 --- a/MEDimage/filters/utils.py +++ /dev/null @@ -1,107 +0,0 @@ -from typing import List - -import numpy as np -from scipy.signal import fftconvolve - - -def pad_imgs( - images: np.ndarray, - padding_length: List, - axis: List, - mode: str - )-> np.ndarray: - """Apply padding on a 3d images using a 2D padding pattern. - - Args: - images (ndarray): a numpy array that represent the image. - padding_length (List): The padding length that will apply on each side of each axe. - axis (List): A list of axes on which the padding will be done. - mode (str): The padding mode. Check options here: `numpy.pad - `__. - - Returns: - ndarray: A numpy array that represent the padded image. - """ - pad_tuple = () - j = 1 - - for i in range(np.ndim(images)): - if i in axis: - pad_tuple += ((padding_length[-j], padding_length[-j]),) - j += 1 - else: - pad_tuple += ((0, 0),) - - return np.pad(images, pad_tuple, mode=mode) - -def convolve( - dim: int, - kernel: np.ndarray, - images: np.ndarray, - orthogonal_rot: bool=False, - mode: str = "symmetric" - ) -> np.ndarray: - """Convolve a given n-dimensional array with the kernel to generate a filtered image. - - Args: - dim (int): The dimension of the images. - kernel (ndarray): The kernel to use for the convolution. - images (ndarray): A n-dimensional numpy array that represent a batch of images to filter. - orthogonal_rot (bool, optional): If true, the 3D images will be rotated over coronal, axial and sagittal axis. - mode (str, optional): The padding mode. Check options here: `numpy.pad - `__. - - Returns: - ndarray: The filtered image. - """ - - in_size = np.shape(images) - - # We only handle 2D or 3D images. - assert len(in_size) == 3 or len(in_size) == 4, \ - "The tensor should have the followed shape (B, H, W) or (B, D, H, W)" - - if not orthogonal_rot: - # If we have a 2D kernel but a 3D images, we squeeze the tensor - if dim < len(in_size) - 1: - images = images.reshape((in_size[0] * in_size[1], in_size[2], in_size[3])) - - # We compute the padding size along each dimension - padding = [int((kernel.shape[-1] - 1) / 2) for _ in range(dim)] - pad_axis_list = [i for i in range(1, dim+1)] - - # We pad the images and we add the channel axis. - padded_imgs = pad_imgs(images, padding, pad_axis_list, mode) - new_imgs = np.expand_dims(padded_imgs, axis=1) - - # Operate the convolution - if dim < len(in_size) - 1: - # If we have a 2D kernel but a 3D images, we convolve slice by slice - result_list = [fftconvolve(np.expand_dims(new_imgs[i], axis=0), kernel, mode='valid') for i in range(len(images))] - result = np.squeeze(np.stack(result_list), axis=2) - - else : - result = fftconvolve(new_imgs, kernel, mode='valid') - - # Reshape the data to retrieve the following format: (B, C, D, H, W) - if dim < len(in_size) - 1: - result = result.reshape(( - in_size[0], in_size[1], result.shape[1], in_size[2], in_size[3]) - ).transpose(0, 2, 1, 3, 4) - - # If we want orthogonal rotation - else: - coronal_imgs = images - axial_imgs, sagittal_imgs = np.rot90(images, 1, (1, 2)), np.rot90(images, 1, (1, 3)) - - result_coronal = convolve(dim, kernel, coronal_imgs, False, mode) - result_axial = convolve(dim, kernel, axial_imgs, False, mode) - result_sagittal = convolve(dim, kernel, sagittal_imgs, False, mode) - - # split and unflip and stack the result on a new axis - result_axial = np.rot90(result_axial, 1, (3, 2)) - result_sagittal = np.rot90(result_sagittal, 1, (4, 2)) - - result = np.stack([result_coronal, result_axial, result_sagittal]) - - return result diff --git a/MEDimage/filters/wavelet.py b/MEDimage/filters/wavelet.py deleted file mode 100644 index f276663..0000000 --- a/MEDimage/filters/wavelet.py +++ /dev/null @@ -1,237 +0,0 @@ -import math -from itertools import combinations, permutations -from typing import List, Union - -import numpy as np -import pywt - -from ..MEDscan import MEDscan -from ..utils.image_volume_obj import image_volume_obj - - -class Wavelet(): - """ - The wavelet filter class. - """ - - def __init__( - self, - ndims: int, - wavelet_name="haar", - padding="symmetric", - rot_invariance=False): - """The constructor of the wavelet filter - - Args: - ndims (int): The number of dimension of the images that will be filter as int. - wavelet_name (str): The name of the wavelet kernel as string. - padding (str): The padding type that will be used to produce the convolution - rot_invariance (bool): If true, rotation invariance will be done on the images. - - Returns: - None - """ - self.dim = ndims - self.padding = padding - self.rot = rot_invariance - self.wavelet = None - self.kernel_length = None - self.create_kernel(wavelet_name) - - def create_kernel(self, - wavelet_name: str): - """Get the wavelet object and his kernel length. - - Args: - wavelet_name (str): A string that represent the wavelet name that will be use to create the kernel - - Returns: - None - """ - - self.wavelet = pywt.Wavelet(wavelet_name) - self.kernel_length = max(self.wavelet.rec_len, self.wavelet.dec_len) - - def __unpad(self, - images: np.ndarray, - padding: List) -> np.ndarray: - """Unpad a batch of images - - Args: - images: A numpy nd-array or a list that represent the batch of padded images. - The shape should be (B, H, W) or (B, H, W, D) - padding: a list of length 2*self.dim that gives the length of padding on each side of each axis. - - Returns: - ndarray: A numpy nd-array or a list that represent the batch of unpadded images - """ - - if self.dim == 2: - return images[:, padding[0]:-padding[1], padding[2]:-padding[3]] - elif self.dim == 3: - return images[:, padding[0]:-padding[1], padding[2]:-padding[3], padding[4]:-padding[5]] - else: - raise NotImplementedError - - def __get_pad_length(self, - image_shape: List, - level: int) -> np.ndarray: - """Compute the padding length needed to have a padded image where the length - along each axis is a multiple 2^level. - - Args: - image_shape (List): a list of integer that describe the length of the image along each axis. - level (int): The level of the wavelet transform - - Returns: - ndarray: An integer list of length 2*self.dim that gives the length of padding on each side of each axis. - """ - padding = [] - ker_length = self.kernel_length*level - for l in image_shape: - padded_length = math.ceil((l + 2*(ker_length-1)) / 2**level) * 2**level - l - padding.extend([math.floor(padded_length/2), math.ceil(padded_length/2)]) - - return padding - - def _pad_imgs(self, - images: np.ndarray, - padding, - axis: List): - """Apply padding on a 3d images using a 2D padding pattern (special for wavelet). - - Args: - images: a numpy array that represent the image. - padding: The padding length that will apply on each side of each axe. - axis: A list of axes on which the padding will be done. - - Returns: - ndarray: A numpy array that represent the padded image. - """ - pad_tuple = () - j = 0 - - for i in range(np.ndim(images)): - if i in axis: - pad_tuple += ((padding[j], padding[j+1]),) - j += 2 - else: - pad_tuple += ((0, 0),) - - return np.pad(images, pad_tuple, mode=self.padding) - - - def convolve(self, - images: np.ndarray, - _filter="LHL", - level=1)-> np.ndarray: - """Filter a given batch of images using pywavelet. - - Args: - images (ndarray): A n-dimensional numpy array that represent the images to filter - _filter (str): The filter to uses. - level (int): The number of decomposition steps to perform. - - Returns: - ndarray: The filtered image as numpy nd-array - """ - - # We pad the images - padding = self.__get_pad_length(np.shape(images[0]), level) - axis_list = [i for i in range(0, self.dim)] - images = np.expand_dims(self._pad_imgs(images[0], padding, axis_list), axis=0) - - # We generate the to collect the result from pywavelet dictionary - _index = str().join(['a' if _filter[i] == 'L' else 'd' for i in range(len(_filter))]) - - if self.rot: - result = [] - _index_list = np.unique([str().join(perm) for perm in permutations(_index, self.dim)]) - - # For each images, we flip each axis. - for image in images: - axis_rot = [comb for j in range(self.dim+1) for comb in combinations(np.arange(self.dim), j)] - images_rot = [np.flip(image, axis) for axis in axis_rot] - - res_rot = [] - for i in range(len(images_rot)): - filtered_image = pywt.swtn(images_rot[i], self.wavelet, level=level)[0] - res_rot.extend([np.flip(filtered_image[j], axis=axis_rot[i]) for j in _index_list]) - - result.extend([np.mean(res_rot, axis=0)]) - else: - result = [] - for i in range(len(images)): - result.extend([pywt.swtn(images[i], self.wavelet, level=level)[level-1][_index]]) - - return self.__unpad(np.array(result), padding) - -def apply_wavelet( - input_images: Union[np.ndarray, image_volume_obj], - medscan: MEDscan = None, - ndims: int = 3, - wavelet_name: str = "haar", - subband: str = "LHL", - level: int = 1, - padding: str = "symmetric", - rot_invariance: bool = False - ) -> np.ndarray: - """Apply the mean filter to the input image - - Args: - input_images (ndarray): The image to filter. - medscan (MEDscan, optional): The MEDscan object that will provide the filter parameters. - ndims (int, optional): The number of dimensions of the input image. - wavelet_name (str): The name of the wavelet kernel as string. - level (List[str], optional): The number of decompositions steps to perform. - subband (str, optional): String of the 1D wavelet kernels ("H" for high-pass - filter or "L" for low-pass filter). Must have a size of ``ndims``. - padding (str, optional): The padding type that will be used to produce the convolution. Check options - here: `numpy.pad `__. - rot_invariance (bool, optional): If true, rotation invariance will be done on the kernel. - - Returns: - ndarray: The filtered image. - """ - # Check if the input is a numpy array or a Image volume object - spatial_ref = None - if type(input_images) == image_volume_obj: - spatial_ref = input_images.spatialRef - input_images = input_images.data - - # Convert to shape : (B, W, H, D) - input_images = np.expand_dims(input_images.astype(np.float64), axis=0) - - if medscan: - # Initialize filter class instance - _filter = Wavelet( - ndims=medscan.params.filter.wavelet.ndims, - wavelet_name=medscan.params.filter.wavelet.basis_function, - rot_invariance=medscan.params.filter.wavelet.rot_invariance, - padding=medscan.params.filter.wavelet.padding - ) - # Run convolution - result = _filter.convolve( - input_images, - _filter=medscan.params.filter.wavelet.subband, - level=medscan.params.filter.wavelet.level - ) - else: - # Initialize filter class instance - _filter = Wavelet( - ndims=ndims, - wavelet_name=wavelet_name, - rot_invariance=rot_invariance, - padding=padding - ) - # Run convolution - result = _filter.convolve( - input_images, - _filter=subband, - level=level - ) - - if spatial_ref: - return image_volume_obj(np.squeeze(result), spatial_ref) - else: - return np.squeeze(result) \ No newline at end of file diff --git a/MEDimage/learning/DataCleaner.py b/MEDimage/learning/DataCleaner.py deleted file mode 100644 index d3e2c92..0000000 --- a/MEDimage/learning/DataCleaner.py +++ /dev/null @@ -1,198 +0,0 @@ -import random -from typing import Dict, List - -import numpy as np -import pandas as pd - - -class DataCleaner: - """ - Class that will clean features of the csv by removing features with too many missing values, - too little variation, too many missing values per sample, too little variation per sample, - and imputing missing values. - """ - def __init__(self, df_features: pd.DataFrame, type: str = "continuous"): - """ - Constructor of the class DataCleaner - - Args: - df_features (pd.DataFrame): Table of features. - type (str): Type of variable: "continuous", "hcategorical" or "icategorical". Defaults to "continuous". - """ - self.df_features = df_features - self.type = type - - def __update_df_features(self, var_of_type: List[str], flag_var_out: List[bool]) -> List[str]: - """ - Updates the variable table by deleting the features that are not in the variable of type - - Args: - var_of_type (List[str]): List of variable names. - flag_var_out (List[bool]): List of variables to flag out. - - Returns: - List[str]: List of variable names that were not flagged out. - """ - var_to_delete = np.delete(var_of_type, [i for i, v in enumerate(flag_var_out) if not v]) - var_of_type = np.delete(var_of_type, [i for i, v in enumerate(flag_var_out) if v]) - self.df_features = self.df_features.drop(var_to_delete, axis=1) - return var_of_type - - def cut_off_missing_per_sample(self, var_of_type: List[str], missing_cutoff : float = 0.25) -> None: - """ - Removes observations/samples with more than ``missing_cutoff`` missing features. - - Args: - var_of_type (List[str]): List of variable names. - missing_cutoff (float): Maximum percentage cut-offs of missing features per sample. Defaults to 25%. - - Returns: - None. - """ - # Initialization - n_observation, n_features = self.df_features.shape - empty_vec = np.zeros(n_observation, dtype=int) - data = self.df_features[var_of_type] - empty_vec += data.isna().sum(axis=1).values - - # Gathering results - ind_obs_out = np.where(((empty_vec/n_features) > missing_cutoff) == True) - self.df_features = self.df_features.drop(self.df_features.index[ind_obs_out]) - - def cut_off_missing_per_feature(self, var_of_type: List[str], missing_cutoff : float = 0.1) -> List[str]: - """ - Removes features with more than ``missing_cutoff`` missing patients. - - Args: - var_of_type (list): List of variable names. - missing_cutoff (float): maximal percentage cut-offs of missing patient samples per variable. - - Returns: - List[str]: List of variable names that were not flagged out. - """ - flag_var_out = (((self.df_features[var_of_type].isna().sum()) / self.df_features.shape[0]) > missing_cutoff) - return self.__update_df_features(var_of_type, flag_var_out) - - def cut_off_variation(self, var_of_type: List[str], cov_cutoff : float = 0.1) -> List[str]: - """ - Removes features with a coefficient of variation (cov) less than ``cov_cutoff``. - - Args: - var_of_type (list): List of variable names. - cov_cutoff (float): minimal coefficient of variation cut-offs over samples per variable. Defaults to 10%. - - Returns: - List[str]: List of variable names that were not flagged out. - """ - eps = np.finfo(np.float32).eps - cov_df_features = (self.df_features[var_of_type].std(skipna=True) / self.df_features[var_of_type].mean(skipna=True)) - flag_var_out = cov_df_features.abs().add(eps) < cov_cutoff - return self.__update_df_features(var_of_type, flag_var_out) - - def impute_missing(self, var_of_type: List[str], imputation_method : str = "mean") -> None: - """ - Imputes missing values of the features of type. - - Args: - var_of_type (list): List of variable names. - imputation_method (str): Method of imputation. Can be "mean", "median", "mode" or "random". - For "random" imputation, a seed can be provided by adding the seed value after the method - name, for example "random42". - - Returns: - None. - """ - if self.type in ['continuous', 'hcategorical']: - # random imputation - if 'random' in imputation_method: - if len(imputation_method) > 6: - try: - seed = int(imputation_method[7:]) - random.seed(seed) - except Exception as e: - print(f"Warning: Seed must be an integer. Random seed will be set to None. str({e})") - random.seed(a=None) - else: - random.seed(a=None) - self.df_features[var_of_type] = self.df_features[var_of_type].apply(lambda x: x.fillna(random.choice(list(x.dropna(axis=0))))) - - # Imputation with median - elif 'median' in imputation_method: - self.df_features[var_of_type] = self.df_features[var_of_type].fillna(self.df_features[var_of_type].median()) - - # Imputation with mean - elif 'mean' in imputation_method: - self.df_features[var_of_type] = self.df_features[var_of_type].fillna(self.df_features[var_of_type].mean()) - - else: - raise ValueError("Imputation method for continuous and hcategorical features must be 'random', 'median' or 'mean'.") - - elif self.type in ['icategorical']: - if 'random' in imputation_method: - if len(imputation_method) > 6: - seed = int(imputation_method[7:]) - random.seed(seed) - else: - random.seed(a=None) - - self.df_features[var_of_type] = self.df_features[var_of_type].apply(lambda x: x.fillna(random.choice(list(x.dropna(axis=0))))) - - if 'mode' in imputation_method: - self.df_features[var_of_type] = self.df_features[var_of_type].fillna(self.df_features[var_of_type].mode().max()) - else: - raise ValueError("Variable type must be 'continuous', 'hcategorical' or 'icategorical'.") - - def __call__(self, cleaning_dict: Dict, imputation_method: str = "mean", - missing_cutoff_ps: float = 0.25, missing_cutoff_pf: float = 0.1, - cov_cutoff:float = 0.1) -> pd.DataFrame: - """ - Applies data cleaning to the features of type. - - Args: - cleaning_dict (dict): Dictionary of cleaning parameters (missing cutoffs and coefficient of variation cutoffs etc.). - var_of_type (list, optional): List of variable names. - imputation_method (str): Method of imputation. Can be "mean", "median", "mode" or "random". - For "random" imputation, a seed can be provided by adding the seed value after the method - name, for example "random42". - missing_cutoff_ps (float, optional): maximal percentage cut-offs of missing features per sample. - missing_cutoff_pf (float, optional): maximal percentage cut-offs of missing samples per variable. - cov_cutoff (float, optional): minimal coefficient of variation cut-offs over samples per variable. - - Returns: - pd.DataFrame: Cleaned table of features. - """ - - # Initialization - var_of_type = self.df_features.Properties['userData']['variables']['continuous'] - - # Retrieve thresholds from cleaning_dict if not None - if cleaning_dict is not None: - missing_cutoff_pf = cleaning_dict['missingCutoffpf'] - missing_cutoff_ps = cleaning_dict['missingCutoffps'] - cov_cutoff = cleaning_dict['covCutoff'] - imputation_method = cleaning_dict['imputation'] - - # Replace infinite values with NaNs - self.df_features = self.df_features.replace([np.inf, -np.inf], np.nan) - - # Remove features with more than missing_cutoff_pf missing samples (NaNs) - var_of_type = self.cut_off_missing_per_feature(var_of_type, missing_cutoff_pf) - - # Check - if len(var_of_type) == 0: - return None - - # Remove features with a coefficient of variation less than cov_cutoff - var_of_type = self.cut_off_variation(var_of_type, cov_cutoff) - - # Check - if len(var_of_type) == 0: - return None - - # Remove scans with more than missing_cutoff_ps missing features - self.cut_off_missing_per_sample(var_of_type, missing_cutoff_ps) - - # Impute missing values - self.impute_missing(var_of_type, imputation_method) - - return self.df_features diff --git a/MEDimage/learning/DesignExperiment.py b/MEDimage/learning/DesignExperiment.py deleted file mode 100644 index 428b56a..0000000 --- a/MEDimage/learning/DesignExperiment.py +++ /dev/null @@ -1,480 +0,0 @@ -import platform -import re -from itertools import combinations, product -from pathlib import Path -from typing import Dict, List - -import pandas as pd - -from ..utils.get_institutions_from_ids import get_institutions_from_ids -from ..utils.json_utils import load_json, posix_to_string, save_json -from .ml_utils import cross_validation_split, get_stratified_splits - - -class DesignExperiment: - def __init__(self, path_study: Path, path_settings: Path, experiment_label: str) -> None: - """ - Constructor of the class DesignExperiment. - - Args: - path_study (Path): Path to the main study folder where the outcomes, - learning patients and holdout patients dictionaries are found. - path_settings (Path): Path to the settings folder. - experiment_label (str): String specifying the label to attach to a given learning experiment in - "path_experiments". This label will be attached to the ml__$experiments_label$.json file as well - as the learn__$experiment_label$ folder. This label is used to keep track of different experiments - with different settings (e.g. radiomics, scans, machine learning algorithms, etc.). - - Returns: - None - """ - self.path_study = Path(path_study) - self.path_settings = Path(path_settings) - self.experiment_label = str(experiment_label) - self.path_ml_object = None - - def __create_folder_and_content( - self, - path_learn: Path, - run_name: str, - patients_train: List, - patients_test: List, - ml_path: Path - ) -> List: - """ - Creates json files needed for a given run - - Args: - path_learn (Path): path to the main learning folder containing information about the training and test set. - run_name (str): name for a given run. - patients_train (List): list of patients in the training set. - patients_test (List): list of patients in the test set. - ml_path (Path): path to the given run. - - Returns: - List: list of paths to the given run. - """ - paths_ml = dict() - path_run = path_learn / run_name - Path.mkdir(path_run, exist_ok=True) - path_train = path_run / 'patientsTrain.json' - path_test = path_run / 'patientsTest.json' - save_json(path_train, sorted(patients_train)) - save_json(path_test, sorted(patients_test)) - paths_ml['patientsTrain'] = path_train - paths_ml['patientsTest'] = path_test - paths_ml['outcomes'] = self.path_study / 'outcomes.csv' - paths_ml['ml'] = self.path_ml_object - paths_ml['results'] = path_run / 'run_results.json' - path_file = path_run / 'paths_ml.json' - paths_ml = posix_to_string(paths_ml) - ml_path.append(path_file) - save_json(path_file, paths_ml) - - return ml_path - - def generate_learner_dict(self) -> dict: - """ - Generates a dictionary containing all the settings for the learning experiment. - - Returns: - dict: Dictionary containing all the settings for the learning experiment. - """ - ml_options = dict() - - # operating system - ml_options['os'] = platform.system() - - # design experiment settings - ml_options['design'] = self.path_settings / 'ml_design.json' - # check if file exist: - if not ml_options['design'].exists(): - raise FileNotFoundError(f"File {ml_options['design']} does not exist.") - - # ML run settings - run = dict() - ml_options['run'] = run - - # Machine learning settings - ml_options['settings'] = self.path_settings / 'ml_settings.json' - # check if file exist: - if not ml_options['settings'].exists(): - raise FileNotFoundError(f"File {ml_options['settings']} does not exist.") - - # variables settings - ml_options['variables'] = self.path_settings / 'ml_variables.json' - # check if file exist: - if not ml_options['variables'].exists(): - raise FileNotFoundError(f"File {ml_options['variables']} does not exist.") - - # ML algorithms settings - ml_options['algorithms'] = self.path_settings / 'ml_algorithms.json' - # check if file exist: - if not ml_options['algorithms'].exists(): - raise FileNotFoundError(f"File {ml_options['algorithms']} does not exist.") - - # Data cleaning settings - ml_options['datacleaning'] = self.path_settings / 'ml_datacleaning.json' - # check if file exist: - if not ml_options['datacleaning'].exists(): - raise FileNotFoundError(f"File {ml_options['datacleaning']} does not exist.") - - # Normalization settings - ml_options['normalization'] = self.path_settings / 'ml_normalization.json' - # check if file exist: - if not ml_options['normalization'].exists(): - raise FileNotFoundError(f"File {ml_options['normalization']} does not exist.") - - # Feature set reduction settings - ml_options['fSetReduction'] = self.path_settings / 'ml_fset_reduction.json' - # check if file exist: - if not ml_options['fSetReduction'].exists(): - raise FileNotFoundError(f"File {ml_options['fSetReduction']} does not exist.") - - # Experiment label check - if self.experiment_label == "": - raise ValueError("Experiment label is empty. Class was not initialized properly.") - - # save all the ml options and return the path to the saved file - name_save_options = 'ml_options_' + self.experiment_label + '.json' - path_ml_options = self.path_settings / name_save_options - ml_options = posix_to_string(ml_options) - save_json(path_ml_options, ml_options) - - return path_ml_options - - def fill_learner_dict(self, path_ml_options: Path) -> Path: - """ - Fills the main expirement dictionary from the settings in the different json files. - This main dictionary will hold all the settings for the data processing and learning experiment. - - Args: - path_ml_options (Path): Path to the ml_options json file for the experiment. - - Returns: - Path: Path to the learner object. - """ - # Initialization - all_datacleaning = list() - all_normalization = list() - all_fset_reduction = list() - - # Load ml options dict - ml_options = load_json(path_ml_options) - options = ml_options.keys() - - # Design options - ml = dict() - ml['design'] = load_json(ml_options['design']) - - # ML run options - ml['run'] = ml_options['run'] - - # Machine learning options - if 'settings' in options: - ml['settings'] = load_json(ml_options['settings']) - - # Machine learning variables - if 'variables' in options: - ml['variables'] = dict() - var_options = load_json(ml_options['variables']) - fields = list(var_options.keys()) - vars = [(idx, s) for idx, s in enumerate(fields) if re.match(r"^var[0-9]{1,}$", s)] - var_names = [var[1] for var in vars] # list of var names - - # For each variable, organize the option in the ML dictionary - for (idx, var) in vars: - vars_dict = dict() - vars_dict[var] = var_options[var] - var_struct = var_options[var] - - # Radiomics variables - if 'radiomics' in var_struct['nameType'].lower(): - # Get radiomics features in workspace - if 'settofeatures' in var_struct['path'].lower(): - name_folder = re.match(r"setTo(.*)inWorkspace", var_struct['path']).group(1) - path_features = self.path_study / name_folder - # Get radiomics features in path provided in the dictionary by the user - else: - path_features = var_struct['path'] - scans = var_struct['scans'] # list of imaging sequences - rois = var_struct['rois'] # list of roi labels - im_spaces = var_struct['imSpaces'] # list of image spaces (filterd and original) - use_combinations = var_struct['use_combinations'] # boolean to use combinations of scans and im_spaces - if use_combinations: - all_combinations = [] - scans = list(var_struct['combinations'].keys()) - for scan in scans: - im_spaces = list(var_struct['combinations'][scan]) - all_combinations += list(product([scan], rois, im_spaces)) - else: - all_combinations = list(product(scans, rois, im_spaces)) - - # Initialize dict to hold all paths to radiomics features (csv and txt files) - path = dict() - for idx, (scan, roi, im_space) in enumerate(all_combinations): - rad_tab_x = {} - name_tab = 'radTab' + str(idx+1) - radiomics_table_name = 'radiomics__' + scan + '(' + roi + ')__' + im_space - rad_tab_x['csv'] = path_features / (radiomics_table_name + '.csv') - rad_tab_x['txt'] = path_features / (radiomics_table_name + '.txt') - rad_tab_x['type'] = path_features / (scan + '(' + roi + ')__' + im_space) - - # check if file exist - if not rad_tab_x['csv'].exists(): - raise FileNotFoundError(f"File {rad_tab_x['csv']} does not exist.") - if not rad_tab_x['txt'].exists(): - raise FileNotFoundError(f"File {rad_tab_x['txt']} does not exist.") - - path[name_tab] = rad_tab_x - - # Add path to ml dict for the current variable - vars_dict[var]['path'] = path - - # Add to ml dict for the current variable - ml['variables'].update(vars_dict) - - # Clinical or other variables (For ex: Volume) - else: - # get path to csv file of features - if not var_struct['path']: - if var_options['pathCSV'] == 'setToCSVinWorkspace': - path_csv = self.path_study / 'CSV' - else: - path_csv = var_options['pathCSV'] - var_struct['path'] = path_csv / var_struct['nameFile'] - - # Add to ml dict for the current variable - ml['variables'].update(vars_dict) - - # Initialize data processing methods - if 'var_datacleaning' in var_struct.keys(): - all_datacleaning.append(var_struct['var_datacleaning']) - if 'var_normalization' in var_struct.keys(): - all_normalization.append((var_struct['var_normalization'])) - if 'var_fSetReduction' in var_struct.keys(): - all_fset_reduction.append(var_struct['var_fSetReduction']['method']) - - # Combinations of variables - if 'combinations' in var_options.keys(): - if var_options['combinations'] == ['all']: # Combine all variables - combs = [comb for i in range(len(vars)) for comb in combinations(var_names, i+1)] - combstrings = ['_'.join(elt) for elt in combs] - ml['variables']['combinations'] = combstrings - else: - ml['variables']['combinations'] = var_options['combinations'] - - # Varibles to use for ML - ml['variables']['varStudy'] = var_options['varStudy'] - - # ML algorithms - if 'algorithms' in options: - algorithm = ml['settings']['algorithm'] - algorithms = load_json(ml_options['algorithms']) - ml['algorithms'] = {} - ml['algorithms'][algorithm] = algorithms[algorithm] - - # ML data processing methods and its options - for (method, method_list) in [ - ('datacleaning', all_datacleaning), - ('normalization', all_normalization), - ('fSetReduction', all_fset_reduction) - ]: - # Skip if no method is selected - if all(v == "" for v in method_list): - continue - if method in options: - # Add algorithm specific methods - if method in ml['settings'].keys(): - method_list.append(ml['settings'][method]) - method_list = list(set(method_list)) # to only get unique values of all_datacleaning - method_options = load_json(ml_options[method]) # load json file of each method - if method == 'normalization' and 'combat' in method_list: - ml[method] = 'combat' - continue - ml[method] = dict() - for name in list(set(method_list)): - if name != "": - ml[method][name] = method_options[name] - - # Save the ML dictionary - if self.experiment_label == "": - raise ValueError("Experiment label is empty. Class was not initialized properly.") - path_ml_object = self.path_study / f'ml__{self.experiment_label}.json' - ml = posix_to_string(ml) # Convert all paths to string - save_json(path_ml_object, ml) - - # return ml - return path_ml_object - - def create_experiment(self, ml: dict = None) -> Dict: - """ - Create the machine learning experiment dictionary, organizes each test/split information in a seperate folder. - - Args: - ml (dict, optional): Dictionary containing all the machine learning settings. Defaults to None. - - Returns: - Dict: Dictionary containing all the organized machine learning settings. - """ - # Initialization - ml_path = list() - ml = load_json(self.path_ml_object) if ml is None else ml - - # Learning set - patients_learn = load_json(self.path_study / 'patientsLearn.json') - - # Outcomes table - outcomes_table = pd.read_csv(self.path_study / 'outcomes.csv', index_col=0) - - # keep only patients in learn set and outcomes table - patients_to_keep = list(filter(lambda x: x in patients_learn, outcomes_table.index.values.tolist())) - outcomes_table = outcomes_table.loc[patients_to_keep] - - # Get the "experiment label" from ml__$experiment_label$.json - if self.experiment_label: - experiment_label = self.experiment_label - else: - experiment_label = Path(self.path_ml_object).name[4:-5] - - # Create the folder for the training and testing sets (machine learning) information - name_learn = 'learn__' + experiment_label - path_learn = Path(self.path_study) / name_learn - Path.mkdir(path_learn, exist_ok=True) - - # Getting the type of test_sets - test_sets_types = ml['design']['testSets'] - - # Creating the sets for the different machine learning runs - for type_set in test_sets_types: - # Random splits - if type_set.lower() == 'random': - # Get the experiment options for the sets - random_info = ml['design'][type_set] - method = random_info['method'] - n_splits = random_info['nSplits'] - stratify_institutions = random_info['stratifyInstitutions'] - test_proportion = random_info['testProportion'] - seed = random_info['seed'] - if method == 'SubSampling': - # Get the training and testing sets - patients_train, patients_test = get_stratified_splits( - outcomes_table, n_splits, - test_proportion, seed, - stratify_institutions - ) - - # If patients are not in a list - if type(patients_train) != list and not hasattr((patients_train), "__len__"): - patients_train = [patients_train] - patients_test = [patients_test] - - for i in range(n_splits): - # Create a folder for each split/run - run_name = "test__{0:03}".format(i+1) - ml_path = self.__create_folder_and_content( - path_learn, - run_name, - patients_train[i], - patients_test[i], - ml_path - ) - # Institutions-based splits - elif type_set.lower() == 'institutions': - # Get institutions run info - patient_ids = pd.Series(outcomes_table.index) - institution_cat_vector = get_institutions_from_ids(patient_ids) - institution_cats = list(set(institution_cat_vector)) - n_institution = len(institution_cats) - # The 'Institutions' argument only make sense if n_institutions > 1 - if n_institution > 1: - for i in range(n_institution): - cat = institution_cats[i] - patients_train = [elt for elt in patient_ids if cat not in elt] - patients_test = [elt for elt in patient_ids if cat in elt] - run_name = f"test__{cat}" - # Create a folder for each split/run - ml_path = self.__create_folder_and_content( - path_learn, - run_name, - patients_train, - patients_test, - ml_path - ) - if n_institution > 2: - size_inst = list() - for i in range(n_institution): - cat = institution_cats[i] - size_inst.append(sum([1 if cat in elt else 0 for elt in institution_cat_vector])) - ind_max = size_inst.index(max(size_inst)) - str_test = list() - for i in range(n_institution): - if i != ind_max: - cat = institution_cats[i] - str_test.append(cat) - cat = institution_cats[ind_max] - patients_train = [elt for elt in patient_ids if cat in elt] - patients_test = [elt for elt in patient_ids if cat not in elt] - run_name = f"test__{'_'.join(str_test)}" - # Create a folder for each split/run - ml_path = self.__create_folder_and_content( - path_learn, - run_name, - patients_train, - patients_test, - ml_path - ) - elif type_set.lower() == 'cv': - # Get the experiment options for the sets - cv_info = ml['design'][type_set] - n_splits = cv_info['nSplits'] - seed = cv_info['seed'] - - # Get the training and testing sets - patients_train, patients_test = cross_validation_split( - outcomes_table, - n_splits, - seed=seed - ) - - # If patients are not in a list - if type(patients_train) != list and not hasattr((patients_train), "__len__"): - patients_train = [patients_train] - patients_test = [patients_test] - - for i in range(n_splits): - # Create a folder for each split/run - run_name = "test__{0:03}".format(i+1) - ml_path = self.__create_folder_and_content( - path_learn, - run_name, - patients_train[i], - patients_test[i], - ml_path - ) - else: - raise ValueError("The type of test set is not recognized. Must be 'random' or 'institutions'.") - - # Make ml_path a dictionary to easily save it in json - return {f"run{idx+1}": value for idx, value in enumerate(ml_path)} - - def generate_experiment(self): - """ - Generate the json files containing all the options the experiment. - The json files will then be used in machine learning. - """ - # Generate the ml options dictionary - path_ml_options = self.generate_learner_dict() - - # Fill the ml options dictionary - self.path_ml_object = self.fill_learner_dict(path_ml_options) - - # Generate the experiment dictionary - experiment_dict = self.create_experiment() - - # Saving the final experiment dictionary - path_file = self.path_study / f'path_file_ml_paths__{self.experiment_label}.json' - experiment_dict = posix_to_string(experiment_dict) # Convert all paths to string - save_json(path_file, experiment_dict) - - return path_file diff --git a/MEDimage/learning/FSR.py b/MEDimage/learning/FSR.py deleted file mode 100644 index c30deb9..0000000 --- a/MEDimage/learning/FSR.py +++ /dev/null @@ -1,667 +0,0 @@ -from pathlib import Path -from typing import Dict, List, Tuple - -import numpy as np -import pandas as pd -from numpyencoder import NumpyEncoder - -from MEDimage.learning.ml_utils import (combine_rad_tables, finalize_rad_table, - get_stratified_splits, - intersect_var_tables) -from MEDimage.utils.get_full_rad_names import get_full_rad_names -from MEDimage.utils.json_utils import save_json - - -class FSR: - def __init__(self, method: str = 'fda') -> None: - """ - Feature set reduction class constructor. - - Args: - method (str): Method of feature set reduction. Can be "FDA", "LASSO" or "mRMR". - """ - self.method = method - - def __get_fda_corr_table( - self, - variable_table: pd.DataFrame, - outcome_table_binary: pd.DataFrame, - n_splits: int, - corr_type: str, - seed: int - ) -> pd.DataFrame: - """ - Calculates the correlation table of the FDA algorithm. - - Args: - variable_table (pd.DataFrame): variable table to check for stability. - outcome_table_binary (pd.DataFrame): outcome table with binary labels. - n_splits (int): Number of splits in the FDA algorithm (Ex: 100). - corr_type: String specifying the correlation type that we are investigating. - Must be either 'Pearson' or 'Spearman'. - seed (int): Random generator seed. - - Returns: - pd.DataFrame: Correlation table of the FDA algorithm. Rows are splits, columns are features. - """ - # Setting the seed - np.random.seed(seed) - - # Initialization - row_names = [] - corr_table = pd.DataFrame() - fraction_for_splits = 1/3 - number_of_splits = 1 - - # For each split, we calculate the correlation table - for s in range(n_splits): - row_names.append("Split_{0:03}".format(s)) - - # Keep only variables that are in both tables - _, outcome_table_binary = intersect_var_tables(variable_table, outcome_table_binary) - - # Under-sample the outcome table to equalize the number of positive and negative outcomes - #outcome_table_binary_balanced = under_sample(outcome_table_binary) - - # Get the patient teach split - patients_teach_splits = get_stratified_splits( - outcome_table_binary, - number_of_splits, - fraction_for_splits, - seed, - flag_by_cat=True - )[0] - - # Creating a table with both the variables and the outcome with - # only the patient teach splits, ranked for spearman and not for pearson - if corr_type == 'Spearman': - full_table = pd.concat([variable_table.loc[patients_teach_splits, :].rank(), - outcome_table_binary.loc[patients_teach_splits, - outcome_table_binary.columns.values[-1]]], axis=1) - - elif corr_type == 'Pearson': - # Pearson is the base method used by numpy, so we dont have to do any - # manipulations to the data like with spearman. - full_table = pd.concat([variable_table.loc[patients_teach_splits, :], - outcome_table_binary.loc[patients_teach_splits, - outcome_table_binary.columns.values[-1]]], axis=1) - else: - raise ValueError("Correlation type not recognized. Please use 'Pearson' or 'Spearman'") - - # calculate the whole correlation table for all variables. - full_table = np.corrcoef(full_table, rowvar=False)[-1][:-1].reshape((1, -1)) - corr_table = corr_table.append(pd.DataFrame(full_table)) - - # Add the metadata to the correlation table - corr_table.columns = list(variable_table.columns.values) - corr_table = corr_table.fillna(0) - corr_table.index = row_names - corr_table.Properties = {} - corr_table._metadata += ['Properties'] - corr_table.Properties['description'] = variable_table.Properties['Description'] - corr_table.Properties['userData'] = variable_table.Properties['userData'] - - return corr_table - - def __find_fda_best_mean(self, corr_tables: pd.DataFrame, min_n_feat_stable: int) -> Tuple[Dict, pd.DataFrame]: - """ - Finds the best mean correlation of all the stable variables in the table. - - Args: - corr_tables (Dict): dictionary containing the correlation tables of - dimension : [n_splits,n_features] for each table. - min_n_feat_stable (int): minimal number of stable features. - - Returns: - Tuple[Dict, pd.DataFrame]: Dict containing the name of each stable variables in every table and - pd.DataFrame containing the mean correlation of all the stable variables in the table. - """ - # Initialization - var_names_stable = {} - corr_mean_stable = corr_tables - n_features = 0 - corr_table = corr_tables - corr_table = corr_table.fillna(0) - - # Calculation of the mean correlation among the n splits (R mean) - var_names_stable = corr_table.index - - # Calculating the total number of features - n_features += var_names_stable.size - - # Getting absolute values of the mean correlation - corr_mean_stable_abs = corr_mean_stable.abs() - - # Keeping only the best features if there are more than min_n_feat_stable features - if n_features > min_n_feat_stable: - # Get min_n_feat_stable highest correlations - best_features = corr_mean_stable_abs.sort_values(ascending=False)[0:min_n_feat_stable] - var_names_stable = best_features.index.values - corr_mean_stable = best_features - - return var_names_stable, corr_mean_stable - - def __find_fda_stable(self, corr_table: pd.DataFrame, thresh_stable: float) -> Tuple[Dict, pd.DataFrame]: - """ - Finds the stable features in each correlation table - and the mean correlation of all the stable variables in the table. - - Args: - corr_tables (Dict): dictionary containing the correlation tables of - dimension : [n_splits,n_features] for each table. - thresh_stable (float): the threshold deciding if a feature is stable. - - Returns: - Tuple[Dict, pd.DataFrame]: dictionary containing the name of each stable variables in every tables - and table containing the mean correlation of all the stable variables in the table. - (The keys are the table names and the values are pd.Series). - """ - - # Initialization - corr_table.fillna(0, inplace=True) - - # Calculation of R mean - corr_mean_stable = corr_table.mean() - mean_r = corr_mean_stable - - # Calculation of min and max - min_r = corr_table.quantile(0.05) - max_r = corr_table.quantile(0.95) - - # Calculation of unstable features - unstable = (min_r < thresh_stable) & (mean_r > 0) | (max_r > -thresh_stable) & (mean_r < 0) - ind_unstable = unstable.index[unstable] - - # Stable variables - var_names_stable = unstable.index[~unstable].values - corr_mean_stable = mean_r.drop(ind_unstable) - - return var_names_stable, corr_mean_stable - - def __keep_best_text_param( - self, - corr_table: pd.DataFrame, - var_names_stable: List, - corr_mean_stable: pd.DataFrame - ) -> Tuple[List, pd.DataFrame]: - """ - Keeps the best texture features extraction parameters in the correlation tables - by dropping the variants of a given feature. - - Args: - corr_table (pd.DataFrame): Correlation table of dimension : [n_splits,n_features]. - var_names_stable (List): List of the stable variables in the table. - corr_mean_stable (pd.DataFrame): Table of the mean correlation of the stable variables in the variables table. - - Returns: - Tuple[List, pd.DataFrame]: list of the stable variables in the tables and table containing the mean - correlation of all the stable variables. - """ - - # If no stable features for the currect field, continue - if var_names_stable.size == 0: - return var_names_stable, corr_mean_stable - - # Get the actual radiomics features names from the sequential names - full_rad_names = get_full_rad_names( - corr_table.Properties['userData']['variables']['var_def'], - var_names_stable) - - # Now parsing the full names to get only the rad names and not the variant - rad_names = np.array([]) - for n in range(full_rad_names.size): - rad_names = np.append(rad_names, full_rad_names[n].split('__')[1:2]) - - # Verifying if two features are the same variant and keeping the best one - n_var = rad_names.size - var_to_drop = [] - for rad_name in rad_names: - # If all the features are unique, break - if np.unique(rad_names).size == n_var: - break - else: - ind_same = np.where(rad_names == rad_name)[0] - n_same = ind_same.size - if n_same > 1: - var_to_drop.append(list(corr_mean_stable.iloc[ind_same].sort_values().index[1:].values)) - - # Dropping the variants - if len(var_to_drop) > 0: - # convert to list of lists to list - var_to_drop = [item for sublist in var_to_drop for item in sublist] - - # From the unique values of var_to_drop, drop the variants - for var in set(var_to_drop): - var_names_stable = np.delete(var_names_stable, np.where(var_names_stable == var)) - corr_mean_stable = corr_mean_stable.drop(var) - - return var_names_stable, corr_mean_stable - - def __remove_correlated_variables( - self, - variable_table: pd.DataFrame, - rank: pd.Series, - corr_type: str, - thresh_inter_corr: float, - min_n_feat_total: int - ) -> pd.DataFrame: - """ - Removes inter-correlated variables given a certain threshold. - - Args: - variable_table (pd.DataFrame): variable table for which we want to remove intercorrelated variables. - Size: N X M (observations, features). - rank (pd.Series): Vector of correlation values per feature (of size 1 X M). - corr_type (str): String specifying the correlation type that we are investigating. - Must be 'Pearson' or 'Spearman'. - thresh_inter_corr (float): Numerical value specifying the threshold above which two variables are - considered to be correlated. - min_n_feat_total (int): Minimum number of features to keep in the table. - - Returns: - pd.DataFrame: Final variable table with the least correlated variables that are kept. - """ - # Initialization - n_features = variable_table.shape[1] - - # Compute correlation matrix - if corr_type == 'Spearman': - corr_mat = abs(np.corrcoef(variable_table.rank(), rowvar=False)) - elif corr_type == 'Pearson': - corr_mat = abs(np.corrcoef(variable_table, rowvar=False)) - else: - raise ValueError('corr_type must be either "Pearson" or "Spearman"') - - # Set diagonal elements to Nans - np.fill_diagonal(corr_mat, val=np.nan) - - # Calculate mean inter-variable correlation - mean_corr = np.nanmean(corr_mat, axis=1) - - # Looping over all features once - # rank variables once, for meaningful variable loop. - ind_loop = pd.Series(mean_corr).rank(method="first") - 1 - # Create a copy of the correlation matrix (to be modified) - corr_mat_temp = corr_mat.copy() - while True: - for f in range(n_features): - # Use index loop if not NaN - try: - i = int(ind_loop[f]) - except: - i = 0 - # Select the row of the current feature - row = corr_mat_temp[i][:] - correlated = 1*(row > thresh_inter_corr) # to turn into integers - - # While the correlations are above the threshold for the select row, we select another row - while sum(correlated) > 0 and np.isnan(row).sum != len(row): - # Find the variable with the highest correlation and drop the one with the lowest rank - ind_max = np.nanargmax(row) - ind_min = np.nanargmin(np.array([rank[i], rank[ind_max]])) - if ind_min == 0: - # Drop the current row if the current feature has the lowest correlation with outcome - corr_mat_temp[i][:] = np.nan - corr_mat_temp[:][i] = np.nan - row[:] = np.nan - else: - # Drop the feature with the highest correlation to the current feature with the lowest correlation with outcome - corr_mat_temp[ind_max][:] = np.nan - corr_mat_temp[:][ind_max] = np.nan - row[ind_max] = np.nan - - # Update the correlated vector - correlated = row > thresh_inter_corr - - # If all the rows are NaN, we keep the variable with the highest rank - if (1*np.isnan(corr_mat_temp)).sum() == corr_mat_temp.size: - ind_keep = np.nanargmax(rank) - else: - ind_keep = list() - for row in range(corr_mat_temp.shape[0]): - if 1*np.isnan(corr_mat_temp[row][:]).sum() < corr_mat_temp.shape[1]: - ind_keep.append(row) - - # if ind_keep happens to be a numpy type convert it to list for better subscripting - if isinstance(ind_keep, np.int64): - ind_keep = [ind_keep.tolist()] # work around - elif isinstance(ind_keep, np.ndarray): - ind_keep = ind_keep.tolist() - - # Update threshold if the number of variables is too small or too large - if len(ind_keep) < min_n_feat_total: - # Increase the threshold (less stringent) - thresh_inter_corr = thresh_inter_corr + 0.05 - corr_mat_temp = corr_mat.copy() # reset the correlation matrix - else: - break - - # Make sure we have the best - if len(ind_keep) != min_n_feat_total: - # Take the features with the highest rank - ind_keep = sorted(ind_keep)[:min_n_feat_total] - - # Creating new variable_table - columns = [variable_table.columns[idx] for idx in ind_keep] - variable_table = variable_table.loc[:, columns] - - return variable_table - - def apply_fda_one_space( - self, - ml: Dict, - variable_table: List, - outcome_table_binary: pd.DataFrame, - del_variants: bool = True, - logging_dict: Dict = None - ) -> List: - """ - Applies false discovery avoidance method. - - Args: - ml (dict): Machine learning dictionary containing the learning options. - variable_table (List): Table of variables. - outcome_table_binary (pd.DataFrame): Table of binary outcomes. - del_variants (bool, optional): If True, will delete the variants of the same feature. Defaults to True. - - Returns: - List: Table of variables after feature set reduction. - """ - # Initilization - n_splits = ml['fSetReduction']['FDA']['nSplits'] - corr_type = ml['fSetReduction']['FDA']['corrType'] - thresh_stable_start = ml['fSetReduction']['FDA']['threshStableStart'] - thresh_inter_corr = ml['fSetReduction']['FDA']['threshInterCorr'] - min_n_feat_stable = ml['fSetReduction']['FDA']['minNfeatStable'] - min_n_feat_total = ml['fSetReduction']['FDA']['minNfeat'] - seed = ml['fSetReduction']['FDA']['seed'] - - # Initialization - logging - if logging_dict is not None: - table_level = variable_table.Properties['Description'].split('__')[-1] - logging_dict['one_space']['unstable'][table_level] = {} - logging_dict['one_space']['inter_corr'][table_level] = {} - - # Getting the correlation table for the radiomics table - radiomics_table_temp = variable_table.copy() - outcome_table_binary_temp = outcome_table_binary.copy() - - # Get the correlation table - corr_table = self.__get_fda_corr_table( - radiomics_table_temp, - outcome_table_binary_temp, - n_splits, - corr_type, - seed - ) - - # Calculating the total numbers of features - feature_total = radiomics_table_temp.shape[1] - - # Cut unstable features (Rmin cut) - if feature_total > min_n_feat_stable: - # starting threshold (set by user) - thresh_stable = thresh_stable_start - while True: - # find which features are stable - var_names_stable, corrs_stable = self.__find_fda_stable(corr_table, thresh_stable) - - # Keep the best textural parameters per image space (deleting variants) - if del_variants: - var_names_stable, corrs_stable = self.__keep_best_text_param(corr_table, var_names_stable, corrs_stable) - - # count the number of stable features - n_stable = var_names_stable.size - - # stop if the minimum number of stable features is reached, if not, lower the threshold. - if n_stable >= min_n_feat_stable: - break - else: - thresh_stable = thresh_stable - 0.05 - - # stop if the threshold is zero or below - if thresh_stable <= 0: - break - - # take the best mean correlation - if n_stable > min_n_feat_stable: - var_names_stable, corr_mean_stable = self.__find_fda_best_mean(corrs_stable, min_n_feat_stable) - else: - # Compute mean correlation - corr_mean_stable = corr_table.mean() - - # Finalize radiomics tables before inter-correlation cut - if len(var_names_stable) > 0: - var_names = var_names_stable - if isinstance(radiomics_table_temp, pd.Series): - radiomics_table_temp = radiomics_table_temp[[var_names]] - else: - radiomics_table_temp = radiomics_table_temp[var_names] - radiomics_table_temp = finalize_rad_table(radiomics_table_temp) - else: - radiomics_table_temp = pd.DataFrame() - else: - # if there is less features than the minimal number, take them all - n_stable = feature_total - - # Compute mean correlation - corr_mean_stable = corr_table.mean() - - # Update logging - if logging_dict is not None: - logging_dict['one_space']['unstable'][table_level] = radiomics_table_temp.columns.shape[0] - - # Inter-Correlation Cut - if radiomics_table_temp.shape[1] > 1 and n_stable > min_n_feat_total: - radiomics_table_temp = self.__remove_correlated_variables( - radiomics_table_temp, - corr_mean_stable.abs(), - corr_type, - thresh_inter_corr, - min_n_feat_total - ) - - # Finalize radiomics table - radiomics_table_temp = finalize_rad_table(radiomics_table_temp) - - # Update logging - if logging_dict is not None: - logging_dict['one_space']['inter_corr'][table_level] = get_full_rad_names( - radiomics_table_temp.Properties['userData']['variables']['var_def'], - radiomics_table_temp.columns.values - ).tolist() - - return radiomics_table_temp - - def apply_fda( - self, - ml: Dict, - variable_table: List, - outcome_table_binary: pd.DataFrame, - logging: bool = True, - path_save_logging: Path = None - ) -> List: - """ - Applies false discovery avoidance method. - - Args: - ml (dict): Machine learning dictionary containing the learning options. - variable_table (List): Table of variables. - outcome_table_binary (pd.DataFrame): Table of binary outcomes. - logging (bool, optional): If True, will save a dict that tracks features selsected for each level. Defaults to True. - path_save_logging (Path, optional): Path to save the logging dict. Defaults to None. - - Returns: - List: Table of variables after feature set reduction. - """ - # Initialization - rad_tables = variable_table.copy() - n_rad_tables = len(rad_tables) - variable_tables = [] - logging_dict = {'one_space': {'unstable': {}, 'inter_corr': {}}, 'final': {}} - - # Apply FDA for each image space/radiomics table - for r in range(n_rad_tables): - if logging: - variable_tables.append(self.apply_fda_one_space(ml, rad_tables[r], outcome_table_binary, logging_dict=logging_dict)) - else: - variable_tables.append(self.apply_fda_one_space(ml, rad_tables[r], outcome_table_binary)) - - # Combine radiomics tables - variable_table = combine_rad_tables(variable_tables) - - # Apply FDA again on the combined radiomics table - variable_table = self.apply_fda_one_space(ml, variable_table, outcome_table_binary, del_variants=False) - - # Update logging dict - if logging: - logging_dict['final'] = get_full_rad_names(variable_table.Properties['userData']['variables']['var_def'], - variable_table.columns.values).tolist() - if path_save_logging is not None: - path_save_logging = Path(path_save_logging).parent / 'fda_logging_dict.json' - save_json(path_save_logging, logging_dict, cls=NumpyEncoder) - - return variable_table - - def apply_fda_balanced( - self, - ml: Dict, - variable_table: List, - outcome_table_binary: pd.DataFrame, - ) -> List: - """ - Applies false discovery avoidance method but balances the number of features on each level. - - Args: - ml (dict): Machine learning dictionary containing the learning options. - variable_table (List): Table of variables. - outcome_table_binary (pd.DataFrame): Table of binary outcomes. - logging (bool, optional): If True, will save a dict that tracks features selsected for each level. Defaults to True. - path_save_logging (Path, optional): Path to save the logging dict. Defaults to None. - - Returns: - List: Table of variables after feature set reduction. - """ - # Initilization - rad_tables = variable_table.copy() - n_rad_tables = len(rad_tables) - variable_tables_all_levels = [] - levels = [[], [], []] - - # Organize the tables by level - for r in range(n_rad_tables): - if 'morph' in rad_tables[r].Properties['Description'].lower(): - levels[0].append(rad_tables[r]) - elif 'intensity' in rad_tables[r].Properties['Description'].lower(): - levels[0].append(rad_tables[r]) - elif 'texture' in rad_tables[r].Properties['Description'].lower(): - levels[0].append(rad_tables[r]) - elif 'mean' in rad_tables[r].Properties['Description'].lower() or \ - 'laws' in rad_tables[r].Properties['Description'].lower() or \ - 'log' in rad_tables[r].Properties['Description'].lower() or \ - 'gabor' in rad_tables[r].Properties['Description'].lower() or \ - 'coif' in rad_tables[r].Properties['Description'].lower() or \ - 'wavelet' in rad_tables[r].Properties['Description'].lower(): - levels[1].append(rad_tables[r]) - elif 'glcm' in rad_tables[r].Properties['Description'].lower(): - levels[2].append(rad_tables[r]) - - # Apply FDA for each image space/radiomics table for each level - for level in levels: - variable_tables = [] - if len(level) == 0: - continue - for r in range(len(level)): - variable_tables.append(self.apply_fda_one_space(ml, level[r], outcome_table_binary)) - - # Combine radiomics tables - variable_table = combine_rad_tables(variable_tables) - - # Apply FDA again on the combined radiomics table - variable_table = self.apply_fda_one_space(ml, variable_table, outcome_table_binary, del_variants=False) - - # Add-up the tables - variable_tables_all_levels.append(variable_table) - - # Combine radiomics tables of all 3 major levels (original, linear filters and textures) - variable_table_all_levels = combine_rad_tables(variable_tables_all_levels) - - # Apply FDA again on the combined radiomics table - variable_table_all_levels = self.apply_fda_one_space(ml, variable_table_all_levels, outcome_table_binary, del_variants=False) - - return variable_table_all_levels - - def apply_random_fsr_one_space( - self, - ml: Dict, - variable_table: pd.DataFrame, - ) -> List: - seed = ml['fSetReduction']['FDA']['seed'] - - # Setting the seed - np.random.seed(seed) - - # Random select 10 columns (features) - random_df = np.random.choice(variable_table.columns.values.tolist(), 10, replace=False) - random_df = variable_table[random_df] - - return finalize_rad_table(random_df) - - def apply_random_fsr( - self, - ml: Dict, - variable_table: List, - ) -> List: - """ - Applies random feature set reduction by choosing a random number of features. - - Args: - ml (dict): Machine learning dictionary containing the learning options. - variable_table (List): Table of variables. - outcome_table_binary (pd.DataFrame): Table of binary outcomes. - - Returns: - List: Table of variables after feature set reduction. - """ - # Iinitilization - rad_tables = variable_table.copy() - n_rad_tables = len(rad_tables) - variable_tables = [] - - # Apply FDA for each image space/radiomics table - for r in range(n_rad_tables): - variable_tables.append(self.apply_random_fsr_one_space(ml, rad_tables[r])) - - # Combine radiomics tables - variable_table = combine_rad_tables(variable_tables) - - # Apply FDA again on the combined radiomics table - variable_table = self.apply_random_fsr_one_space(ml, variable_table) - - return variable_table - - def apply_fsr(self, ml: Dict, variable_table: List, outcome_table_binary: pd.DataFrame, path_save_logging: Path = None) -> List: - """ - Applies feature set reduction method. - - Args: - ml (dict): Machine learning dictionary containing the learning options. - variable_table (List): Table of variables. - outcome_table_binary (pd.DataFrame): Table of binary outcomes. - - Returns: - List: Table of variables after feature set reduction. - """ - if self.method.lower() == "fda": - variable_table = self.apply_fda(ml, variable_table, outcome_table_binary, path_save_logging=path_save_logging) - elif self.method.lower() == "fdabalanced": - variable_table = self.apply_fda_balanced(ml, variable_table, outcome_table_binary) - elif self.method.lower() == "random": - variable_table = self.apply_random_fsr(ml, variable_table) - elif self.method == "LASSO": - raise NotImplementedError("LASSO not implemented yet.") - elif self.method == "mRMR": - raise NotImplementedError("mRMR not implemented yet.") - else: - raise ValueError("FSR method is None or unknown: " + self.method) - return variable_table diff --git a/MEDimage/learning/Normalization.py b/MEDimage/learning/Normalization.py deleted file mode 100644 index ea1dfc2..0000000 --- a/MEDimage/learning/Normalization.py +++ /dev/null @@ -1,112 +0,0 @@ -import numpy as np -import pandas as pd -from neuroCombat import neuroCombat - -from ..utils.get_institutions_from_ids import get_institutions_from_ids - - -class Normalization: - def __init__( - self, - method: str = 'combat', - variable_table: pd.DataFrame = None, - covariates_df: pd.DataFrame = None, - institutions: list = None - ) -> None: - """ - Constructor of the Normalization class. - """ - self.method = method - self.variable_table = variable_table - self.covariates_df = covariates_df - self.institutions = institutions - - def apply_combat( - self, - variable_table: pd.DataFrame, - covariate_df: pd.DataFrame = None, - institutions: list = None - ) -> pd.DataFrame: - """ - Applys ComBat Normalization method to the data. - More details :ref:`this link `. - - Args: - variable_table (pd.DataFrame): pandas data frame on which Combat harmonization will be applied. - This table is of size N X F (Observations X Features) and has the IDs as index. - Requirements for this table - - - Does not contain NaNs. - - No feature has 0 variance. - - All variables are continuous (For example: Radiomics variables). - covariate_df (pd.DataFrame, optional): N X M pandas data frame, where N must equal the number of - observations in variable_table. M is the number of covariates to include in the algorithm. - institutions (list, optional): List of size n_observations X 1 with the different institutions. - - Returns: - pd.DataFrame: variable_table after Combat harmonization. - """ - # Initializing the class attributes from the arguments - if variable_table is None: - if self.variable_table is None: - raise ValueError('variable_table must be given.') - else: - self.variable_table = variable_table - if covariate_df is not None: - self.covariates_df = covariate_df - if institutions: - self.institutions = institutions - - # Intializing the institutions if not given - if self.institutions is None: - patient_ids = pd.Series(self.variable_table.index) - self.institutions = get_institutions_from_ids(patient_ids) - all_institutions = self.institutions.unique() - for n in range(all_institutions.size): - self.institutions[self.institutions == all_institutions[n]] = n+1 - self.institutions = self.institutions.to_numpy(dtype=int) - self.institutions = np.reshape(self.institutions, (-1, 1)) - - # No harmonization will be applied if there is only one institution - if np.unique(self.institutions).size < 2: - return self.variable_table - - # Initializing the covariates if not given - if self.covariates_df is not None: - self.covariates_df['institution'] = self.institutions - else: - # the covars matrix is only a row with the institution - self.covariates_df = pd.DataFrame( - self.institutions, - columns=['institution'], - index=self.variable_table.index.values - ) - - # Apply combat - n_features = self.variable_table.shape[1] - batch_col = 'institution' - if n_features == 1: - # combat does not work with a single feature so a temporary one is added, - # then removed later (this has no effect on the algorithm). - self.variable_table['temp'] = pd.Series( - np.ones(self.variable_table.shape[0]), - index=self.variable_table.index - ) - data_combat = neuroCombat( - self.variable_table.transpose(), - self.covariates_df, - batch_col - ) - self.variable_table = pd.DataFrame(self.variable_table.drop('temp', axis=1)) - vt_combat = pd.DataFrame(data_combat[:][0].transpose()) - else: - data_combat = neuroCombat( - self.variable_table.transpose(), - self.covariates_df, - batch_col - ) - vt_combat = pd.DataFrame(data_combat['data']).transpose() - - self.variable_table[:] = vt_combat.values - - return self.variable_table diff --git a/MEDimage/learning/RadiomicsLearner.py b/MEDimage/learning/RadiomicsLearner.py deleted file mode 100644 index b112a4d..0000000 --- a/MEDimage/learning/RadiomicsLearner.py +++ /dev/null @@ -1,714 +0,0 @@ -import logging -import os -import time -from copy import deepcopy -from pathlib import Path -from typing import Dict, List, Tuple - -import numpy as np -import pandas as pd -from numpyencoder import NumpyEncoder -from pycaret.classification import * -from sklearn import metrics -from sklearn.model_selection import GridSearchCV, RandomizedSearchCV -from xgboost import XGBClassifier - -from MEDimage.learning.DataCleaner import DataCleaner -from MEDimage.learning.DesignExperiment import DesignExperiment -from MEDimage.learning.FSR import FSR -from MEDimage.learning.ml_utils import (average_results, combine_rad_tables, - feature_imporance_analysis, - finalize_rad_table, get_ml_test_table, - get_radiomics_table, intersect, - intersect_var_tables, save_model) -from MEDimage.learning.Normalization import Normalization -from MEDimage.learning.Results import Results - -from ..utils.json_utils import load_json, save_json - - -class RadiomicsLearner: - def __init__(self, path_study: Path, path_settings: Path, experiment_label: str) -> None: - """ - Constructor of the class DesignExperiment. - - Args: - path_study (Path): Path to the main study folder where the outcomes, - learning patients and holdout patients dictionaries are found. - path_settings (Path): Path to the settings folder. - experiment_label (str): String specifying the label to attach to a given learning experiment in - "path_experiments". This label will be attached to the ml__$experiments_label$.json file as well - as the learn__$experiment_label$ folder. This label is used to keep track of different experiments - with different settings (e.g. radiomics, scans, machine learning algorithms, etc.). - - Returns: - None - """ - self.path_study = Path(path_study) - self.path_settings = Path(path_settings) - self.experiment_label = experiment_label - - def __load_ml_info(self, ml_dict_paths: Dict) -> Dict: - """ - Initializes the test dictionary information (training patients, test patients, ML dict, etc). - - Args: - ml_dict_paths (Dict): Dictionary containing the paths to the different files needed - to run the machine learning experiment. - - Returns: - dict: Dictionary containing the information of the machine learning test. - """ - ml_dict = dict() - - # Training and test patients - ml_dict['patientsTrain'] = load_json(ml_dict_paths['patientsTrain']) - ml_dict['patientsTest'] = load_json(ml_dict_paths['patientsTest']) - - # Outcome table for training and test patients - outcome_table = pd.read_csv(ml_dict_paths['outcomes'], index_col=0) - ml_dict['outcome_table_binary'] = outcome_table.iloc[:, [0]] - if outcome_table.shape[1] == 2: - ml_dict['outcome_table_time'] = outcome_table.iloc[:, [1]] - - # Machine learning dictionary - ml_dict['ml'] = load_json(ml_dict_paths['ml']) - ml_dict['path_results'] = ml_dict_paths['results'] - - return ml_dict - - def __find_balanced_threshold( - self, - model: XGBClassifier, - variable_table: pd.DataFrame, - outcome_table_binary: pd.DataFrame - ) -> float: - """ - Finds the balanced threshold for the given machine learning test. - - Args: - model (XGBClassifier): Trained XGBoost classifier for the given machine learning run. - variable_table (pd.DataFrame): Radiomics table. - outcome_table_binary (pd.DataFrame): Outcome table with binary labels. - - Returns: - float: Balanced threshold for the given machine learning test. - """ - # Check is there is a feature mismatch - if model.feature_names_in_.shape[0] != variable_table.columns.shape[0]: - variable_table = variable_table.loc[:, model.feature_names_in_] - - # Getting the probability responses for each patient - prob_xgb = np.zeros((variable_table.index.shape[0], 1)) * np.nan - patient_ids = list(variable_table.index.values) - for p in range(variable_table.index.shape[0]): - prob_xgb[p] = self.predict_xgb(model, variable_table.loc[[patient_ids[p]], :]) - - # Calculating the ROC curve - fpr, tpr, thresholds = metrics.roc_curve(outcome_table_binary.iloc[:, 0], prob_xgb) - - # Calculating the optimal threshold by minizing fpr (false positive rate) and maximizing tpr (true positive rate) - minimum = np.argmin(np.power(fpr, 2) + np.power(1-tpr, 2)) - - return thresholds[minimum] - - def get_hold_out_set_table(self, ml: Dict, var_id: str, patients_id: List): - """ - Loads and pre-processes different radiomics tables then combines them to be used for hold-out testing. - - Args: - ml (Dict): The machine learning dictionary containing the information of the machine learning test. - var_id (str): String specifying the ID of the radiomics variable in ml. - --> Ex: var1 - patients_id (List): List of patients of the hold-out set. - - Returns: - pd.DataFrame: Radiomics table for the hold-out set. - """ - # Loading and pre-processing - rad_var_struct = ml['variables'][var_id] - rad_tables_holdout = list() - for item in rad_var_struct['path'].values(): - # Reading the table - path_radiomics_csv = item['csv'] - path_radiomics_txt = item['txt'] - image_type = item['type'] - rad_table_holdout = get_radiomics_table(path_radiomics_csv, path_radiomics_txt, image_type, patients_id) - rad_tables_holdout.append(rad_table_holdout) - - # Combine the tables - rad_tables_holdout = combine_rad_tables(rad_tables_holdout) - rad_tables_holdout.Properties['userData']['flags_processing'] = {} - - return rad_tables_holdout - - def pre_process_variables(self, ml: Dict, outcome_table_binary: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]: - """ - Loads and pre-processes different radiomics tables from different variable types - found in the ml dict. - - Note: - only patients of the training/learning set should be found in this outcome table. - - Args: - ml (Dict): The machine learning dictionary containing the information of the machine learning test. - outcome_table_binary (pd.DataFrame): outcome table with binary labels. This table may be used to - pre-process some variables with the "FDA" feature set reduction algorithm. - - Returns: - Tuple: Two dict of processed radiomics tables, one dict for training and one for - testing (no feature set reduction). - """ - # Get a list of unique variables found in the ml variables combinations dict - variables_id = [s.split('_') for s in ml['variables']['combinations']] - variables_id = list(set([x for sublist in variables_id for x in sublist])) - - # For each variable, load the corresponding radiomics table and pre-process it - processed_var_tables, processed_var_tables_test = {var_id : self.pre_process_radiomics_table( - ml, - var_id, - outcome_table_binary - ) for var_id in variables_id} - - return processed_var_tables, processed_var_tables_test - - def pre_process_radiomics_table( - self, - ml: Dict, - var_id: str, - outcome_table_binary: pd.DataFrame, - patients_train: list - ) -> Tuple[pd.DataFrame, pd.DataFrame]: - """ - For the given variable, this function loads the corresponding radiomics tables and pre-processes them - (cleaning, normalization and feature set reduction). - - Note: - Only patients of the training/learning set should be found in the given outcome table. - - Args: - ml (Dict): The machine learning dictionary containing the information of the machine learning test - (parameters, options, etc.). - var_id (str): String specifying the ID of the radiomics variable in ml. For example: 'var1'. - outcome_table_binary (pd.DataFrame): outcome table with binary labels. This table may - be used to pre-process some variables with the "FDA" feature set reduction algorithm. - - patients_train (list): List of patients to use for training. - - Returns: - Tuple[pd.DataFrame, pd.DataFrame]: Two dataframes of processed radiomics tables, one for training - and one for testing (no feature set reduction). - """ - # Initialization - patient_ids = list(outcome_table_binary.index) - outcome_table_binary_training = outcome_table_binary.loc[patients_train] - var_names = ['var_datacleaning', 'var_normalization', 'var_fSetReduction'] - flags_preprocessing = {key: key in ml['variables'][var_id].keys() for key in var_names} - flags_preprocessing_test = flags_preprocessing.copy() - flags_preprocessing_test['var_fSetReduction'] = False - - # Pre-processing - rad_var_struct = ml['variables'][var_id] - rad_tables_learning = list() - for item in rad_var_struct['path'].values(): - # Loading the table - path_radiomics_csv = item['csv'] - path_radiomics_txt = item['txt'] - image_type = item['type'] - rad_table_learning = get_radiomics_table(path_radiomics_csv, path_radiomics_txt, image_type, patient_ids) - - # Data cleaning - if flags_preprocessing['var_datacleaning']: - cleaning_dict = ml['datacleaning'][ml['variables'][var_id]['var_datacleaning']]['feature']['continuous'] - data_cleaner = DataCleaner(rad_table_learning) - rad_table_learning = data_cleaner(cleaning_dict) - if rad_table_learning is None: - continue - - # Normalization (ComBat) - if flags_preprocessing['var_normalization']: - normalization_method = ml['variables'][var_id]['var_normalization'] - # Some information must be stored to re-apply combat for testing data - if 'combat' in normalization_method.lower(): - # Training data - rad_table_learning.Properties['userData']['normalization'] = dict() - rad_table_learning.Properties['userData']['normalization']['original_data'] = dict() - rad_table_learning.Properties['userData']['normalization']['original_data']['path_radiomics_csv'] = path_radiomics_csv - rad_table_learning.Properties['userData']['normalization']['original_data']['path_radiomics_txt'] = path_radiomics_txt - rad_table_learning.Properties['userData']['normalization']['original_data']['image_type'] = image_type - rad_table_learning.Properties['userData']['normalization']['original_data']['patient_ids'] = patient_ids - if flags_preprocessing['var_datacleaning']: - data_cln_method = ml['variables'][var_id]['var_datacleaning'] - rad_table_learning.Properties['userData']['normalization']['original_data']['datacleaning_method'] = data_cln_method - - # Apply ComBat - normalization = Normalization('combat') - rad_table_learning = normalization.apply_combat(variable_table=rad_table_learning) # Training data - else: - raise NotImplementedError(f'Normalization method: {normalization_method} not recognized.') - - # Save the table - rad_tables_learning.append(rad_table_learning) - - # Seperate training and testing data before feature set reduction - rad_tables_testing = deepcopy(rad_tables_learning) - rad_tables_training = [] - for rad_tab in rad_tables_learning: - patients_ids = intersect(patients_train, list(rad_tab.index)) - rad_tables_training.append(deepcopy(rad_tab.loc[patients_ids])) - - # Deepcopy properties - temp_properties = list() - for rad_tab in rad_tables_testing: - temp_properties.append(deepcopy(rad_tab.Properties)) - - # Feature set reduction (for training data only) - if flags_preprocessing['var_fSetReduction']: - f_set_reduction_method = ml['variables'][var_id]['var_fSetReduction']['method'] - fsr = FSR(f_set_reduction_method) - - # Apply FDA - rad_tables_training = fsr.apply_fsr( - ml, - rad_tables_training, - outcome_table_binary_training, - path_save_logging=ml['path_results'] - ) - - # Re-assign properties - for i in range(len(rad_tables_testing)): - rad_tables_testing[i].Properties = temp_properties[i] - del temp_properties - - # Finalization steps - rad_tables_training.Properties['userData']['flags_preprocessing'] = flags_preprocessing - rad_tables_testing = combine_rad_tables(rad_tables_testing) - rad_tables_testing.Properties['userData']['flags_processing'] = flags_preprocessing_test - - return rad_tables_training, rad_tables_testing - - def train_xgboost_model( - self, - var_table_train: pd.DataFrame, - outcome_table_binary_train: pd.DataFrame, - var_importance_threshold: float = 0.05, - optimal_threshold: float = None, - optimization_metric: str = 'MCC', - method : str = "pycaret", - use_gpu: bool = False, - seed: int = None, - ) -> Dict: - """ - Trains an XGBoost model for the given machine learning test. - - Args: - var_table_train (pd.DataFrame): Radiomics table for the training/learning set. - outcome_table_binary_train (pd.DataFrame): Outcome table with binary labels for the training/learning set. - var_importance_threshold (float): Threshold for the variable importance. Variables with importance below - this threshold will be removed from the model. - optimal_threshold (float, optional): Optimal threshold for the XGBoost model. If not given, it will be - computed using the training set. - optimization_metric (str, optional): String specifying the metric to use to optimize the ml model. - method (str, optional): String specifying the method to use to train the XGBoost model. - - "pycaret": Use PyCaret to train the model (automatic). - - "grid_search": Grid search with cross-validation to find the best parameters. - - "random_search": Random search with cross-validation to find the best parameters. - use_gpu (bool, optional): Boolean specifying if the GPU should be used to train the model. Default is True. - seed (int, optional): Integer specifying the seed to use for the random number generator. - - Returns: - Dict: Dictionary containing info about the trained XGBoost model. - """ - - # Safety check (make sure that the outcome table and the variable table have the same patients) - var_table_train, outcome_table_binary_train = intersect_var_tables(var_table_train, outcome_table_binary_train) - - # Finalize the new radiomics table with the remaining variables - var_table_train = finalize_rad_table(var_table_train) - - if method.lower() == "pycaret": - # Set up data for PyCaret - temp_data = pd.merge(var_table_train, outcome_table_binary_train, left_index=True, right_index=True) - - # PyCaret setup - setup( - data=temp_data, - feature_selection=True, - n_features_to_select=1-var_importance_threshold, - fold=5, - target=temp_data.columns[-1], - use_gpu=use_gpu, - feature_selection_estimator="xgboost", - session_id=seed - ) - - # Set seed - if seed is not None: - set_config('seed', seed) - - # Creating XGBoost model using PyCaret - classifier = create_model('xgboost', verbose=False) - - # Tuning XGBoost model using PyCaret - classifier = tune_model(classifier, optimize=optimization_metric) - - else: - # Initial training to filter features using variable importance - # XGB Classifier - classifier = XGBClassifier() - classifier.fit(var_table_train, outcome_table_binary_train) - var_importance = classifier.feature_importances_ - - # Normalize var_importance if necessary - if np.sum(var_importance) != 1: - var_importance_threshold = var_importance_threshold / np.sum(var_importance) - var_importance = var_importance / np.sum(var_importance) - - # Filter variables - var_table_train = var_table_train.iloc[:, var_importance >= var_importance_threshold] - - # Check if variable table is empty after filtering - if var_table_train.shape[1] == 0: - raise ValueError('Variable table is empty after variable importance filtering. Use a smaller threshold.') - - # Suggested scale_pos_weight - scale_pos_weight = 1 - (outcome_table_binary_train == 0).sum().values[0] \ - / (outcome_table_binary_train == 1).sum().values[0] - - # XGB Classifier - classifier = XGBClassifier(scale_pos_weight=scale_pos_weight) - - # Tune XGBoost parameters - params = { - 'max_depth': [3, 4, 5], - 'learning_rate': [0.1 , 0.01, 0.001], - 'n_estimators': [50, 100, 200] - } - - if method.lower() == "grid_search": - # Set up grid search with cross-validation - grid_search = GridSearchCV( - estimator=classifier, - param_grid=params, - cv=5, - n_jobs=-1, - verbose=3, - scoring='matthews_corrcoef' - ) - elif method.lower() == "random_search": - # Set up random search with cross-validation - grid_search = RandomizedSearchCV( - estimator=classifier, - param_distributions=params, - cv=5, - n_jobs=-1, - verbose=3, - scoring='matthews_corrcoef' - ) - else: - raise NotImplementedError(f'Method: {method} not recognized. Use "grid_search", "random_search", "auto" or "pycaret".') - - # Fit the grid search - grid_search.fit(var_table_train, outcome_table_binary_train) - - # Get the best parameters - best_params = grid_search.best_params_ - - # Fit the XGB Classifier with the best parameters - classifier = XGBClassifier(**best_params) - classifier.fit(var_table_train, outcome_table_binary_train) - - # Saving the information of the model in a dictionary - model_xgb = dict() - model_xgb['algo'] = 'xgb' - model_xgb['type'] = 'binary' - model_xgb['method'] = method - if optimal_threshold: - model_xgb['threshold'] = optimal_threshold - else: - try: - model_xgb['threshold'] = self.__find_balanced_threshold(classifier, var_table_train, outcome_table_binary_train) - except Exception as e: - print('Error in finding optimal threshold, it will be set to 0.5:' + str(e)) - model_xgb['threshold'] = 0.5 - model_xgb['model'] = classifier - model_xgb['var_names'] = list(classifier.feature_names_in_) - model_xgb['var_info'] = deepcopy(var_table_train.Properties['userData']) - if method == "auto": - model_xgb['optimization'] = "auto" - elif method == "pycaret": - model_xgb['optimization'] = classifier.get_params() - else: - model_xgb['optimization'] = best_params - - return model_xgb - - def test_xgb_model(self, model_dict: Dict, variable_table: pd.DataFrame, patient_list: List) -> List: - """ - Tests the XGBoost model for the given dataset patients. - - Args: - model_dict (Dict): Dictionary containing info about the trained XGBoost model. - variable_table (pd.DataFrame): Radiomics table for the test set (should not be normalized). - patient_list (List): List of patients to test. - - Returns: - List: List the model response for the training and test sets. - """ - # Initialization - n_test = len(patient_list) - var_names = model_dict['var_names'] - var_def = model_dict['var_info']['variables']['var_def'] - model_response = list() - - # Preparing the variable table - variable_table = get_ml_test_table(variable_table, var_names, var_def) - - # Test the model - for i in range(n_test): - # Get the patient IDs - patient_ids = patient_list[i] - - # Getting predictions for each patient - n_patients = len(patient_ids) - varargout = np.zeros((n_patients, 1)) * np.nan # NaN if the computation fails - for p in range(n_patients): - try: - varargout[p] = self.predict_xgb(model_dict['model'], variable_table.loc[[patient_ids[p]], :]) - except Exception as e: - print('Error in computing prediction for patient ' + str(patient_ids[p]) + ': ' + str(e)) - varargout[p] = np.nan - - # Save the predictions - model_response.append(varargout) - - return model_response - - def predict_xgb(self, xgb_model: XGBClassifier, variable_table: pd.DataFrame) -> float: - """ - Computes the prediction of the XGBoost model for the given variable table. - - Args: - xgb_model (XGBClassifier): XGBClassifier model. - variable_table (pd.DataFrame): Variable table for the prediction. - - Returns: - float: Prediction of the XGBoost model. - """ - - # Predictions - predictions = xgb_model.predict_proba(variable_table) - - # Get the probability of the positive class - predictions = predictions[:, 1][0] - - return predictions - - def ml_run(self, path_ml: Path, holdout_test: bool = True, method: str = 'auto') -> None: - """ - This function runs the machine learning test for the ceated experiment. - - Args: - path_ml (Path): Path to the main dictionary containing info about the ml current experiment. - holdout_test (bool, optional): Boolean specifying if the hold-out test should be performed. - - Returns: - None. - """ - # Set up logging file for the batch - log_file = os.path.dirname(path_ml) + '/batch.log' - logging.basicConfig(filename=log_file, level=logging.INFO, format='%(message)s', filemode='w') - - # Start the timer - batch_start = time.time() - - logging.info("\n\n********************MACHINE LEARNING RUN********************\n\n") - - # --> A. Initialization phase - # Load the test dictionary and machine learning information - ml_dict_paths = load_json(path_ml) # Test information dictionary - ml_info_dict = self.__load_ml_info(ml_dict_paths) # Machine learning information dictionary - - # Machine learning assets - patients_train = ml_info_dict['patientsTrain'] - patients_test = ml_info_dict['patientsTest'] - patients_holdout = load_json(self.path_study / 'patientsHoldOut.json') if holdout_test else None - outcome_table_binary = ml_info_dict['outcome_table_binary'] - ml = ml_info_dict['ml'] - path_results = ml_info_dict['path_results'] - ml['path_results'] = path_results - - # --> B. Machine Learning phase - # B.1. Pre-processing features - start = time.time() - logging.info("\n\n--> PRE-PROCESSING TRAINING VARIABLES") - - # Not all variables will be used to train the model, only the user-selected variable - var_id = str(ml['variables']['varStudy']) - - # Pre-processing of the radiomics tables/variables - processed_training_table, processed_testing_table = self.pre_process_radiomics_table( - ml, - var_id, - outcome_table_binary.copy(), - patients_train - ) - logging.info(f"...Done in {time.time()-start} s") - - # B.2. Pre-learning initialization - # Patient definitions (training and test sets) - patient_ids = list(outcome_table_binary.index) - patients_train = intersect(intersect(patient_ids, patients_train), processed_training_table.index) - patients_test = intersect(intersect(patient_ids, patients_test), processed_testing_table.index) - patients_holdout = intersect(patient_ids, patients_holdout) if holdout_test else None - - # Initializing outcome tables for training and test sets - outcome_table_binary_train = outcome_table_binary.loc[patients_train, :] - outcome_table_binary_test = outcome_table_binary.loc[patients_test, :] - outcome_table_binary_holdout = outcome_table_binary.loc[patients_holdout, :] if holdout_test else None - - # Serperate variable table for training sets (repetitive but double-checking) - var_table_train = processed_training_table.loc[patients_train, :] - - # Initializing XGBoost model settings - var_importance_threshold = ml['algorithms']['XGBoost']['varImportanceThreshold'] - optimal_threshold = ml['algorithms']['XGBoost']['optimalThreshold'] - optimization_metric = ml['algorithms']['XGBoost']['optimizationMetric'] - method = ml['algorithms']['XGBoost']['method'] if 'method' in ml['algorithms']['XGBoost'].keys() else method - use_gpu = ml['algorithms']['XGBoost']['useGPU'] if 'useGPU' in ml['algorithms']['XGBoost'].keys() else True - seed = ml['algorithms']['XGBoost']['seed'] if 'seed' in ml['algorithms']['XGBoost'].keys() else None - - # B.2. Training the XGBoost model - tstart = time.time() - logging.info(f"\n\n--> TRAINING XGBOOST MODEL FOR VARIABLE {var_id}") - - # Training the model - model = self.train_xgboost_model( - var_table_train, - outcome_table_binary_train, - var_importance_threshold, - optimal_threshold, - method=method, - use_gpu=use_gpu, - optimization_metric=optimization_metric, - seed=seed - ) - - # Saving the trained model using pickle - name_save_model = ml['algorithms']['XGBoost']['nameSave'] - model_id = name_save_model + '_' + str(ml['variables']['varStudy']) - path_model = os.path.dirname(path_results) + '/' + (model_id + '.pickle') - model_dict = save_model(model, str(ml['variables']['varStudy']), path_model, ml=ml) - - logging.info("{}--> DONE. TOTAL TIME OF LEARNING PROCESS: {:.2f} min".format(" " * 4, (time.time()-tstart) / 60)) - - # --> C. Testing phase - # C.1. Testing the XGBoost model and computing model response - tstart = time.time() - logging.info(f"\n\n--> TESTING XGBOOST MODEL FOR VARIABLE {var_id}") - - response_train, response_test = self.test_xgb_model( - model, - processed_testing_table, - [patients_train, patients_test] - ) - - logging.info('{}--> DONE. TOTAL TIME OF LEARNING PROCESS: {:.2f}'.format(" " * 4, (time.time() - tstart)/60)) - - if holdout_test: - # --> D. Holdoutset testing phase - # D.1. Prepare holdout test data - var_table_all_holdout = self.get_hold_out_set_table(ml, var_id, patients_holdout) - - # D.2. Testing the XGBoost model and computing model response on the holdout set - tstart = time.time() - logging.info(f"\n\n--> TESTING XGBOOST MODEL FOR VARIABLE {var_id} ON THE HOLDOUT SET") - - response_holdout = self.test_xgb_model(model, var_table_all_holdout, [patients_holdout])[0] - - logging.info('{}--> DONE. TOTAL TIME OF LEARNING PROCESS: {:.2f}'.format(" " * 4, (time.time() - tstart)/60)) - - # E. Computing performance metrics - tstart = time.time() - - # Initialize the Results class - result = Results(model_dict, model_id) - if holdout_test: - run_results = result.to_json( - response_train=response_train, - response_test=response_test, - response_holdout=response_holdout, - patients_train=patients_train, - patients_test=patients_test, - patients_holdout=patients_holdout - ) - else: - run_results = result.to_json( - response_train=response_train, - response_test=response_test, - response_holdout=None, - patients_train=patients_train, - patients_test=patients_test, - patients_holdout=None - ) - - # Calculating performance metrics for training phase and saving the ROC curve - run_results[model_id]['train']['metrics'] = result.get_model_performance( - response_train, - outcome_table_binary_train, - ) - - # Calculating performance metrics for testing phase and saving the ROC curve - run_results[model_id]['test']['metrics'] = result.get_model_performance( - response_test, - outcome_table_binary_test, - ) - - if holdout_test: - # Calculating performance metrics for holdout phase and saving the ROC curve - run_results[model_id]['holdout']['metrics'] = result.get_model_performance( - response_holdout, - outcome_table_binary_holdout, - ) - - logging.info('\n\n--> COMPUTING PERFORMANCE METRICS ... Done in {:.2f} sec'.format(time.time()-tstart)) - - # F. Saving the results dictionary - save_json(path_results, run_results, cls=NumpyEncoder) - - # Total computing time - logging.info("\n\n*********************************************************************") - logging.info('{} TOTAL COMPUTATION TIME: {:.2f} hours'.format(" " * 13, (time.time()-batch_start)/3600)) - logging.info("*********************************************************************") - - def run_experiment(self, holdout_test: bool = True, method: str = "pycaret") -> None: - """ - Run the machine learning experiment for each split/run - - Args: - holdout_test (bool, optional): Boolean specifying if the hold-out test should be performed. - method (str, optional): String specifying the method to use to train the XGBoost model. - - "pycaret": Use PyCaret to train the model (automatic). - - "grid_search": Grid search with cross-validation to find the best parameters. - - "random_search": Random search with cross-validation to find the best parameters. - - Returns: - None - """ - # Initialize the DesignExperiment class - experiment = DesignExperiment(self.path_study, self.path_settings, self.experiment_label) - - # Generate the machine learning experiment - path_file_ml_paths = experiment.generate_experiment() - - # Run the different machine learning tests for the experiment - tests_dict = load_json(path_file_ml_paths) # Tests dictionary - for run in tests_dict.keys(): - self.ml_run(tests_dict[run], holdout_test, method) - - # Average results of the different splits/runs - average_results(self.path_study / f'learn__{self.experiment_label}', save=True) - - # Analyze the features importance for all the runs - feature_imporance_analysis(self.path_study / f'learn__{self.experiment_label}') - \ No newline at end of file diff --git a/MEDimage/learning/Results.py b/MEDimage/learning/Results.py deleted file mode 100644 index 2c69654..0000000 --- a/MEDimage/learning/Results.py +++ /dev/null @@ -1,2237 +0,0 @@ -# Description: Class Results to store and analyze the results of experiments. - -import os -from pathlib import Path -from typing import List - -import matplotlib.patches as mpatches -import matplotlib.pyplot as plt -import networkx as nx -import numpy as np -import pandas as pd -import seaborn as sns -from matplotlib import pyplot as plt -from matplotlib.colors import to_rgba -from matplotlib.lines import Line2D -from networkx.drawing.nx_pydot import graphviz_layout -from numpyencoder import NumpyEncoder -from sklearn import metrics - -from MEDimage.learning.ml_utils import feature_imporance_analysis, list_metrics -from MEDimage.learning.Stats import Stats -from MEDimage.utils.json_utils import load_json, save_json -from MEDimage.utils.texture_features_names import * - - -class Results: - """ - A class to analyze the results of a given machine learning experiment, including the assessment of the model's performance, - - Args: - model_dict (dict, optional): Dictionary containing the model's parameters. Defaults to {}. - model_id (str, optional): ID of the model. Defaults to "". - - Attributes: - model_dict (dict): Dictionary containing the model's parameters. - model_id (str): ID of the model. - results_dict (dict): Dictionary containing the results of the model's performance. - """ - def __init__(self, model_dict: dict = {}, model_id: str = "") -> None: - """ - Constructor of the class Results - """ - self.model_dict = model_dict - self.model_id = model_id - self.results_dict = {} - - def __calculate_performance( - self, - response: list, - labels: pd.DataFrame, - thresh: float - ) -> dict: - """ - Computes performance metrics of given a model's response, outcome and threshold. - - Args: - response (list): List of the probabilities of class "1" for all instances (prediction) - labels (pd.Dataframe): Column vector specifying the outcome status (1 or 0) for all instances. - thresh (float): Optimal threshold selected from the ROC curve. - - Returns: - Dict: Dictionary containing the performance metrics. - """ - # Recording results - results_dict = dict() - - # Removing Nans - df = labels.copy() - outcome_name = labels.columns.values[0] - df['response'] = response - df.dropna(axis=0, how='any', inplace=True) - - # Confusion matrix elements: - results_dict['TP'] = ((df['response'] >= thresh) & (df[outcome_name] == 1)).sum() - results_dict['TN'] = ((df['response'] < thresh) & (df[outcome_name] == 0)).sum() - results_dict['FP'] = ((df['response'] >= thresh) & (df[outcome_name] == 0)).sum() - results_dict['FN'] = ((df['response'] < thresh) & (df[outcome_name] == 1)).sum() - - # Copying confusion matrix elements - TP = results_dict['TP'] - TN = results_dict['TN'] - FP = results_dict['FP'] - FN = results_dict['FN'] - - # AUC - results_dict['AUC'] = metrics.roc_auc_score(df[outcome_name], df['response']) - - # AUPRC - results_dict['AUPRC'] = metrics.average_precision_score(df[outcome_name], df['response']) - - # Sensitivity - try: - results_dict['Sensitivity'] = TP / (TP + FN) - except: - print('TP + FN = 0, Division by 0, replacing sensitivity by 0.0') - results_dict['Sensitivity'] = 0.0 - - # Specificity - try: - results_dict['Specificity'] = TN / (TN + FP) - except: - print('TN + FP= 0, Division by 0, replacing specificity by 0.0') - results_dict['Specificity'] = 0.0 - - # Balanced accuracy - results_dict['BAC'] = (results_dict['Sensitivity'] + results_dict['Specificity']) / 2 - - # Precision - results_dict['Precision'] = TP / (TP + FP) - - # NPV (Negative Predictive Value) - results_dict['NPV'] = TN / (TN + FN) - - # Accuracy - results_dict['Accuracy'] = (TP + TN) / (TP + TN + FP + FN) - - # F1 score - results_dict['F1_score'] = 2 * TP / (2 * TP + FP + FN) - - # mcc (mathews correlation coefficient) - results_dict['MCC'] = (TP * TN - FP * FN) / np.sqrt((TP + FP) * (TP + FN) * (TN + FP) * (TN + FN)) - - return results_dict - - def __get_metrics_failure_dict( - self, - metrics: list = list_metrics - ) -> dict: - """ - This function fills the metrics with NaNs in case of failure. - - Args: - metrics (list, optional): List of metrics to be filled with NaNs. - Defaults to ['AUC', 'Sensitivity', 'Specificity', 'BAC', - 'AUPRC', 'Precision', 'NPV', 'Accuracy', 'F1_score', 'MCC' - 'TP', 'TN', 'FP', 'FN']. - - Returns: - Dict: Dictionary with the metrics filled with NaNs. - """ - failure_struct = dict() - failure_struct = {metric: np.nan for metric in metrics} - - return failure_struct - - def __count_percentage_levels(self, features_dict: dict, fda: bool = False) -> list: - """ - Counts the percentage of each radiomics level in a given features dictionary. - - Args: - features_dict (dict): Dictionary of features. - fda (bool, optional): If True, meaning the features are from the FDA logging dict and will be - treated differently. Defaults to False. - - Returns: - list: List of percentages of features in each complexity levels. - """ - # Intialization - perc_levels = [0] * 7 # 4 levels and two variants for the filters - - # List all features in dict - if fda: - list_features = [feature.split('/')[-1] for feature in features_dict['final']] - else: - list_features = list(features_dict.keys()) - - # Count the percentage of levels - for feature in list_features: - level_name = feature.split('__')[1].lower() - feature_name = feature.split('__')[2].lower() - # Morph - if level_name.startswith('morph'): - perc_levels[0] += 1 - # Intensity - elif level_name.startswith('intensity'): - perc_levels[1] += 1 - # Texture - elif level_name.startswith('texture'): - perc_levels[2] += 1 - # Linear filters - elif level_name.startswith('mean') \ - or level_name.startswith('log') \ - or level_name.startswith('laws') \ - or level_name.startswith('gabor') \ - or level_name.startswith('wavelet') \ - or level_name.startswith('coif'): - # seperate intensity and texture - if feature_name.startswith('_int'): - perc_levels[3] += 1 - elif feature_name.startswith(tuple(['_glcm', '_gldzm', '_glrlm', '_glszm', '_ngtdm', '_ngldm'])): - perc_levels[4] += 1 - # Textural filters - elif level_name.startswith('glcm'): - # seperate intensity and texture - if feature_name.startswith('_int'): - perc_levels[5] += 1 - elif feature_name.startswith(tuple(['_glcm', '_gldzm', '_glrlm', '_glszm', '_ngtdm', '_ngldm'])): - perc_levels[6] += 1 - - return perc_levels / np.sum(perc_levels, axis=0) * 100 - - def __count_percentage_radiomics(self, results_dict: dict) -> list: - """ - Counts the percentage of radiomics levels for all features used for the experiment. - - Args: - results_dict (dict): Dictionary of final run results. - - Returns: - list: List of percentages of features used for the model sorted by complexity levels. - """ - # Intialization - perc_levels = [0] * 5 # 5 levels: morph, intensity, texture, linear filters, textural filters - model_name = list(results_dict.keys())[0] - radiomics_tables_dict = results_dict[model_name]['var_info']['normalization'] - - # Count the percentage of levels - for key in list(radiomics_tables_dict.keys()): - if key.lower().startswith('radtab'): - table_path = radiomics_tables_dict[key]['original_data']['path_radiomics_csv'] - table_name = table_path.split('/')[-1] - table = pd.read_csv(table_path, index_col=0) - # Morph - if 'morph' in table_name.lower(): - perc_levels[0] += table.columns.shape[0] - # Intensity - elif 'intensity' in table_name.lower(): - perc_levels[1] += table.columns.shape[0] - # Texture - elif 'texture' in table_name.lower(): - perc_levels[2] += table.columns.shape[0] - # Linear filters - elif 'mean' in table_name.lower() \ - or 'log' in table_name.lower() \ - or 'laws' in table_name.lower() \ - or 'gabor' in table_name.lower() \ - or 'wavelet' in table_name.lower() \ - or 'coif' in table_name.lower(): - perc_levels[3] += table.columns.shape[0] - # Textural filters - elif 'glcm' in table_name.lower(): - perc_levels[4] += table.columns.shape[0] - - return perc_levels / np.sum(perc_levels, axis=0) * 100 - - def __count_stable_fda(self, features_dict: dict) -> list: - """ - Counts the percentage of levels in the features dictionary. - - Args: - features_dict (dict): Dictionary of features. - - Returns: - list: List of percentages of features in each complexity levels. - """ - # Intialization - count_levels = [0] * 5 # 5 levels and two variants for the filters - - # List all features in dict - features_dict = features_dict["one_space"]["unstable"] - list_features = list(features_dict.keys()) - - # Count the percentage of levels - for feature_name in list_features: - # Morph - if feature_name.lower().startswith('morph'): - count_levels[0] += features_dict[feature_name] - # Intensity - elif feature_name.lower().startswith('intensity'): - count_levels[1] += features_dict[feature_name] - # Texture - elif feature_name.lower().startswith('texture'): - count_levels[2] += features_dict[feature_name] - # Linear filters - elif feature_name.lower().startswith('mean') \ - or feature_name.lower().startswith('log') \ - or feature_name.lower().startswith('laws') \ - or feature_name.lower().startswith('gabor') \ - or feature_name.lower().startswith('wavelet') \ - or feature_name.lower().startswith('coif'): - count_levels[3] += features_dict[feature_name] - # Textural filters - elif feature_name.lower().startswith('glcm'): - count_levels[4] += features_dict[feature_name] - - return count_levels - - def __count_patients(self, path_results: Path) -> dict: - """ - Counts the number of patients used in learning, testing and holdout. - - Args: - path_results(Path): path to the folder containing the results of the experiment. - - Returns: - Dict: Dictionary with the number of patients used in learning, testing and holdout. - """ - # Get all tests paths - list_path_tests = [path for path in path_results.iterdir() if path.is_dir()] - - # Initialize dictionaries - patients_count = { - 'train': {}, - 'test': {}, - 'holdout': {} - } - - # Process metrics - for dataset in ['train', 'test', 'holdout']: - for path_test in list_path_tests: - results_dict = load_json(path_test / 'run_results.json') - if dataset in results_dict[list(results_dict.keys())[0]].keys(): - if 'patients' in results_dict[list(results_dict.keys())[0]][dataset].keys(): - if results_dict[list(results_dict.keys())[0]][dataset]['patients']: - patients_count[dataset] = len(results_dict[list(results_dict.keys())[0]][dataset]['patients']) - else: - continue - else: - continue - break # The number of patients is the same for all the runs - - return patients_count - - def average_results(self, path_results: Path, save: bool = False) -> None: - """ - Averages the results (AUC, BAC, Sensitivity and Specifity) of all the runs of the same experiment, - for training, testing and holdout sets. - - Args: - path_results(Path): path to the folder containing the results of the experiment. - save (bool, optional): If True, saves the results in the same folder as the model. - - Returns: - None. - """ - # Get all tests paths - list_path_tests = [path for path in path_results.iterdir() if path.is_dir()] - - # Initialize dictionaries - results_avg = { - 'train': {}, - 'test': {}, - 'holdout': {} - } - - # Retrieve metrics - for dataset in ['train', 'test', 'holdout']: - dataset_dict = results_avg[dataset] - for metric in list_metrics: - metric_values = [] - for path_test in list_path_tests: - results_dict = load_json(path_test / 'run_results.json') - if dataset in results_dict[list(results_dict.keys())[0]].keys(): - if 'metrics' in results_dict[list(results_dict.keys())[0]][dataset].keys(): - metric_values.append(results_dict[list(results_dict.keys())[0]][dataset]['metrics'][metric]) - else: - continue - else: - continue - - # Fill the dictionary - if metric_values: - dataset_dict[f'{metric}_mean'] = np.nanmean(metric_values) - dataset_dict[f'{metric}_std'] = np.nanstd(metric_values) - dataset_dict[f'{metric}_max'] = np.nanmax(metric_values) - dataset_dict[f'{metric}_min'] = np.nanmin(metric_values) - dataset_dict[f'{metric}_2.5%'] = np.nanpercentile(metric_values, 2.5) - dataset_dict[f'{metric}_97.5%'] = np.nanpercentile(metric_values, 97.5) - - # Save the results - if save: - save_json(path_results / 'results_avg.json', results_avg, cls=NumpyEncoder) - return path_results / 'results_avg.json' - - return results_avg - - def get_model_performance( - self, - response: list, - outcome_table: pd.DataFrame - ) -> None: - """ - Calculates the performance of the model - Args: - response (list): List of machine learning model predictions. - outcome_table (pd.DataFrame): Outcome table with binary labels. - - Returns: - None: Updates the ``run_results`` attribute. - """ - # Calculating performance metrics for the training set - try: - # Convert list of model response to a table to facilitate the process - results_dict = dict() - patient_ids = list(outcome_table.index) - response_table = pd.DataFrame(response) - response_table.index = patient_ids - response_table._metadata += ['Properties'] - response_table.Properties = dict() - response_table.Properties['RowNames'] = patient_ids - - # Make sure the outcome table and the response table have the same patients - outcome_binary = outcome_table.loc[patient_ids, :] - outcome_binary = outcome_binary.iloc[:, 0] - response = response_table.loc[patient_ids, :] - response = response.iloc[:, 0] - - # Calculating performance - results_dict = self.__calculate_performance(response, outcome_binary.to_frame(), self.model_dict['threshold']) - - return results_dict - - except Exception as e: - print(f"Error: ", e, "filling metrics with nan...") - return self.__get_metrics_failure_dict() - - def get_optimal_level( - self, - path_experiments: Path, - experiments_labels: List[str], - metric: str = 'AUC_mean', - p_value_test: str = 'wilcoxon', - aggregate: bool = False, - ) -> None: - """ - This function plots a heatmap of the metrics values for the performance of the models in the given experiment. - - Args: - path_experiments (Path): Path to the folder containing the experiments. - experiments_labels (List): List of experiments labels to use for the plot. including variants is possible. For - example: ['experiment1_morph_CT', ['experiment1_intensity5_CT', 'experiment1_intensity10_CT'], 'experiment1_texture_CT']. - metric (str, optional): Metric to plot. Defaults to 'AUC_mean'. - p_value_test (str, optional): Method to use to calculate the p-value. Defaults to 'wilcoxon'. - Available options: - - - 'delong': Delong test. - - 'ttest': T-test. - - 'wilcoxon': Wilcoxon signed rank test. - - 'bengio': Bengio and Nadeau corrected t-test. - aggregate (bool, optional): If True, aggregates the results of all the splits and computes one final p-value. - Only valid for the Delong test when cross-validation is used. Defaults to False. - - Returns: - None. - """ - assert metric.split('_')[0] in list_metrics, f'Given metric {list_metrics} is not in the list of metrics. Please choose from {list_metrics}' - - # Extract modalities and initialize the dictionary - if type(experiments_labels[0]) == str: - experiment = '_'.join(experiments_labels[0].split('_')[:-2]) - elif type(experiments_labels[0]) == list: - experiment = '_'.join(experiments_labels[0][0].split('_')[:-2]) - - modalities = set() - for exp_label in experiments_labels: - if isinstance(exp_label, str): - modalities.add(exp_label.split("_")[-1]) - elif isinstance(exp_label, list): - for sub_exp_label in exp_label: - modalities.add(sub_exp_label.split("_")[-1]) - else: - raise ValueError(f'experiments_labels must be a list of strings or a list of list of strings, given: {type(exp_label)}') - - levels_dict = {modality: [] for modality in modalities} - optimal_lvls = [""] * len(modalities) - - # Populate the dictionary - variants = [] - for label in experiments_labels: - if isinstance(label, str): - modality = label.split("_")[-1] - levels_dict[modality].append(label.split("_")[-2]) - elif isinstance(label, list): - modality = label[0].split("_")[-1] - variants = [] - for sub_label in label: - variants.append(sub_label.split("_")[-2]) - levels_dict[modality] += [variants] - - # Prepare the data for the heatmap - for idx_m, modality in enumerate(modalities): - best_levels = [] - results_dict_best = dict() - results_dicts = [] - best_exp = "" - levels = levels_dict[modality] - - # Loop over the levels and find the best variant for each level - for level in levels: - metric_compare = -1.0 - if type(level) != list: - level = [level] - for variant in level: - exp_full_name = 'learn__' + experiment + '_' + variant + '_' + modality - if 'results_avg.json' in os.listdir(path_experiments / exp_full_name): - results_dict = load_json(path_experiments / exp_full_name / 'results_avg.json') - else: - results_dict = self.average_results(path_experiments / exp_full_name) - if metric_compare < results_dict['test'][metric]: - metric_compare = results_dict['test'][metric] - results_dict_best = results_dict - best_exp = variant - best_levels.append(best_exp) - results_dicts.append(results_dict_best) - - # Create the heatmap data using the metric of interest - heatmap_data = np.zeros((2, len(best_levels))) - - # Fill the heatmap data - for j in range(len(best_levels)): - # Get metrics and p-values - results_dict = results_dicts[j] - if aggregate and 'delong' in p_value_test: - metric_stat = round(Stats.get_aggregated_metric( - path_experiments, - experiment, - best_levels[j], - modality, - metric.split('_')[0] if '_' in metric else metric - ), 2) - else: - metric_stat = round(results_dict['test'][metric], 2) - heatmap_data[0, j] = metric_stat - - # Statistical analysis - # Initializations - optimal_lvls[idx_m] = experiment + "_" + best_levels[0] + "_" + modality - init_metric = heatmap_data[0][0] - idx_d = 0 - start_level = 0 - - # Get p-values for all the levels - while idx_d < len(best_levels) - 1: - metric_val = heatmap_data[0][idx_d+1] - # Get p-value only if the metric is improving - if metric_val > init_metric: - # Instantiate the Stats class - stats = Stats( - path_experiments, - experiment, - [best_levels[start_level], best_levels[idx_d+1]], - [modality] - ) - - # Get p-value - p_value = stats.get_p_value( - p_value_test, - metric=metric if '_' not in metric else metric.split('_')[0], - aggregate=aggregate - ) - - # If p-value is less than 0.05, change starting level - if p_value <= 0.05: - optimal_lvls[idx_m] = experiment + "_" + best_levels[idx_d+1] + "_" + modality - init_metric = metric_val - start_level = idx_d + 1 - - # Go to next column - idx_d += 1 - - return optimal_lvls - - def plot_features_importance_histogram( - self, - path_experiments: Path, - experiment: str, - level: str, - modalities: List, - sort_option: str = 'importance', - title: str = None, - save: bool = True, - figsize: tuple = (12, 12) - ) -> None: - """ - Plots a histogram of the features importance for the given experiment. - - Args: - path_experiments (Path): Path to the folder containing the experiments. - experiment (str): Name of the experiment to plot. Will be used to find the results. - level (str): Radiomics level to plot. For example: 'morph'. - modalities (List): List of imaging modalities to use for the plot. A plot for each modality. - sort_option (str, optional): Option used to sort the features. Available options: - - 'importance': Sorts the features by importance. - - 'times_selected': Sorts the features by the number of times they were selected across the different splits. - - 'both': Sorts the features by importance and then by the number of times they were selected. - title (str, optional): Title of the plot. Defaults to None. - save (bool, optional): Whether to save the plot. Defaults to True. - figsize (tuple, optional): Size of the figure. Defaults to (12, 12). - - Returns: - None. Plots the figure or saves it. - """ - - # checks - assert sort_option in ['importance', 'times_selected', 'both'], \ - f'sort_option must be either "importance", "times_selected" or "both". Given: {sort_option}' - - # For each modality, load features importance dict - for modality in modalities: - exp_full_name = 'learn__' + experiment + '_' + level + '_' + modality - - # Load features importance dict - if 'feature_importance_analysis.json' in os.listdir(path_experiments / exp_full_name): - feat_imp_dict = load_json(path_experiments / exp_full_name / 'feature_importance_analysis.json') - else: - raise FileNotFoundError(f'feature_importance_analysis.json not found in {path_experiments / exp_full_name}') - - # Organize the data in a dataframe - keys = list(feat_imp_dict.keys()) - mean_importances = [] - times_selected = [] - for key in keys: - times_selected - mean_importances.append(feat_imp_dict[key]['importance_mean']) - times_selected.append(feat_imp_dict[key]['times_selected']) - df = pd.DataFrame({'feature': keys, 'importance': mean_importances, 'times_selected': times_selected}) - df = df.sort_values(by=[sort_option], ascending=True) - - # Plot the histogram - plt.rcParams["font.weight"] = "bold" - plt.rcParams["axes.labelweight"] = "bold" - if sort_option == 'importance': - color = 'deepskyblue' - else: - color = 'darkorange' - plt.figure(figsize=figsize) - plt.xlabel(sort_option) - plt.ylabel('Features') - plt.barh(df['feature'], df[sort_option], color=color) - - # Add title - if title: - plt.title(title, weight='bold') - else: - plt.title(f'Features importance histogram \n {experiment} - {level} - {modality}', weight='bold') - plt.tight_layout() - - # Save the plot - if save: - plt.savefig(path_experiments / f'features_importance_histogram_{level}_{modality}_{sort_option}.png') - else: - plt.show() - - def plot_heatmap( - self, - path_experiments: Path, - experiments_labels: List[str], - metric: str = 'AUC_mean', - stat_extra: list = [], - plot_p_values: bool = True, - p_value_test: str = 'wilcoxon', - aggregate: bool = False, - title: str = None, - save: bool = False, - figsize: tuple = (8, 8) - ) -> None: - """ - This function plots a heatmap of the metrics values for the performance of the models in the given experiment. - - Args: - path_experiments (Path): Path to the folder containing the experiments. - experiments_labels (List): List of experiments labels to use for the plot. including variants is possible. For - example: ['experiment1_morph_CT', ['experiment1_intensity5_CT', 'experiment1_intensity10_CT'], 'experiment1_texture_CT']. - metric (str, optional): Metric to plot. Defaults to 'AUC_mean'. - stat_extra (list, optional): List of extra statistics to include in the plot. Defaults to []. - plot_p_values (bool, optional): If True plots the p-value of the choosen test. Defaults to True. - p_value_test (str, optional): Method to use to calculate the p-value. Defaults to 'wilcoxon'. Available options: - - - 'delong': Delong test. - - 'ttest': T-test. - - 'wilcoxon': Wilcoxon signed rank test. - - 'bengio': Bengio and Nadeau corrected t-test. - aggregate (bool, optional): If True, aggregates the results of all the splits and computes one final p-value. - Only valid for the Delong test when cross-validation is used. Defaults to False. - extra_xlabels (List, optional): List of extra x-axis labels. Defaults to []. - title (str, optional): Title of the plot. Defaults to None. - save (bool, optional): Whether to save the plot. Defaults to False. - figsize (tuple, optional): Size of the figure. Defaults to (8, 8). - - Returns: - None. - """ - assert metric.split('_')[0] in list_metrics, f'Given metric {list_metrics} is not in the list of metrics. Please choose from {list_metrics}' - - # Extract modalities and initialize the dictionary - if type(experiments_labels[0]) == str: - experiment = '_'.join(experiments_labels[0].split('_')[:-2]) - elif type(experiments_labels[0]) == list: - experiment = '_'.join(experiments_labels[0][0].split('_')[:-2]) - - modalities = set() - for exp_label in experiments_labels: - if isinstance(exp_label, str): - modalities.add(exp_label.split("_")[-1]) - elif isinstance(exp_label, list): - for sub_exp_label in exp_label: - modalities.add(sub_exp_label.split("_")[-1]) - else: - raise ValueError(f'experiments_labels must be a list of strings or a list of list of strings, given: {type(exp_label)}') - - levels_dict = {modality: [] for modality in modalities} - - # Populate the dictionary - variants = [] - for label in experiments_labels: - if isinstance(label, str): - modality = label.split("_")[-1] - levels_dict[modality].append(label.split("_")[-2]) - elif isinstance(label, list): - modality = label[0].split("_")[-1] - variants = [] - for sub_label in label: - variants.append(sub_label.split("_")[-2]) - levels_dict[modality] += [variants] - - # Prepare the data for the heatmap - fig, axs = plt.subplots(len(modalities), figsize=figsize) - - # Heatmap conception - for idx_m, modality in enumerate(modalities): - # Initializations - best_levels = [] - results_dict_best = dict() - results_dicts = [] - best_exp = "" - patients_count = dict.fromkeys([modality]) - levels = levels_dict[modality] - - # Loop over the levels and find the best variant for each level - for level in levels: - metric_compare = -1.0 - if type(level) != list: - level = [level] - for idx, variant in enumerate(level): - exp_full_name = 'learn__' + experiment + '_' + variant + '_' + modality - if 'results_avg.json' in os.listdir(path_experiments / exp_full_name): - results_dict = load_json(path_experiments / exp_full_name / 'results_avg.json') - else: - results_dict = self.average_results(path_experiments / exp_full_name) - if metric_compare < results_dict['test'][metric]: - metric_compare = results_dict['test'][metric] - results_dict_best = results_dict - best_exp = variant - best_levels.append(best_exp) - results_dicts.append(results_dict_best) - - # Patient count - patients_count[modality] = self.__count_patients(path_experiments / exp_full_name) - - # Create the heatmap data using the metric of interest - if plot_p_values: - heatmap_data = np.zeros((2, len(best_levels))) - else: - heatmap_data = np.zeros((1, len(best_levels))) - - # Fill the heatmap data - labels = heatmap_data.tolist() - labels_draw = heatmap_data.tolist() - heatmap_data_draw = heatmap_data.tolist() - for j in range(len(best_levels)): - # Get metrics and p-values - results_dict = results_dicts[j] - if aggregate and 'delong' in p_value_test: - metric_stat = round(Stats.get_aggregated_metric( - path_experiments, - experiment, - best_levels[j], - modality, - metric.split('_')[0] if '_' in metric else metric - ), 2) - else: - metric_stat = round(results_dict['test'][metric], 2) - if plot_p_values: - heatmap_data[0, j] = metric_stat - else: - heatmap_data[1, j] = metric_stat - - # Extra statistics - if stat_extra: - if plot_p_values: - labels[0][j] = f'{metric_stat}' - if j < len(best_levels) - 1: - labels[1][j+1] = f'{round(heatmap_data[1, j+1], 5)}' - labels[1][0] = '-' - for extra_stat in stat_extra: - if aggregate and ('sensitivity' in extra_stat.lower() or 'specificity' in extra_stat.lower()): - extra_metric_stat = round(Stats.get_aggregated_metric( - path_experiments, - experiment, - best_levels[j], - modality, - extra_stat.split('_')[0] - ), 2) - extra_stat = extra_stat.split('_')[0] + '_agg' if '_' in extra_stat else extra_stat - labels[0][j] += f'\n{extra_stat}: {extra_metric_stat}' - else: - extra_metric_stat = round(results_dict['test'][extra_stat], 2) - labels[0][j] += f'\n{extra_stat}: {extra_metric_stat}' - else: - labels[0][j] = f'{metric_stat}' - for extra_stat in stat_extra: - extra_metric_stat = round(results_dict['test'][extra_stat], 2) - labels[0][j] += f'\n{extra_stat}: {extra_metric_stat}' - else: - labels = np.array(heatmap_data).round(4).tolist() - - # Update modality name to include the number of patients for training and testing - modalities_label = [modality + f' ({patients_count[modality]["train"]} train, {patients_count[modality]["test"]} test)'] - - # Data to draw - heatmap_data_draw = heatmap_data.copy() - labels_draw = labels.copy() - labels_draw[1] = [''] * len(labels[1]) - heatmap_data_draw[1] = np.array([-1] * heatmap_data[1].shape[0]) if 'MCC' in metric else np.array([0] * heatmap_data[1].shape[0]) - - # Set up the rows (modalities and p-values) - if plot_p_values: - modalities_temp = modalities_label.copy() - modalities_label = ['p-values'] * len(modalities_temp) * 2 - for idx in range(len(modalities_label)): - if idx % 2 == 0: - modalities_label[idx] = modalities_temp[idx // 2] - - # Convert the numpy array to a DataFrame for Seaborn - df = pd.DataFrame(heatmap_data_draw, columns=best_levels, index=modalities_label) - - # To avoid bugs, convert axs to list if only one modality is used - if len(modalities) == 1: - axs = [axs] - - # Create the heatmap using seaborn - sns.heatmap( - df, - annot=labels_draw, - ax=axs[idx_m], - fmt="", - cmap="Blues", - cbar=True, - linewidths=0.5, - vmin=-1 if 'MCC' in metric else 0, - vmax=1, - annot_kws={"weight": "bold", "fontsize": 8} - ) - - # Plot p-values - if plot_p_values: - # Initializations - extent_x = axs[idx_m].get_xlim() - step_x = 1 - start_x = extent_x[0] + 0.5 - end_x = start_x + step_x - step_y = 1 / extent_x[1] - start_y = 1 - endpoints_x = [] - endpoints_y = [] - init_metric = heatmap_data[0][0] - idx_d = 0 - start_level = 0 - - # p-values for all levels - while idx_d < len(best_levels) - 1: - # Retrieve the metric value - metric_val = heatmap_data[0][idx_d+1] - - # Instantiate the Stats class - stats = Stats( - path_experiments, - experiment, - [best_levels[start_level], best_levels[idx_d+1]], - [modality] - ) - - # Get p-value only if the metric is improving - if metric_val > init_metric: - p_value = stats.get_p_value( - p_value_test, - metric=metric if '_' not in metric else metric.split('_')[0], - aggregate=aggregate - ) - - # round the pvalue - p_value = round(p_value, 3) - - # Set color, red if p-value > 0.05, green otherwise - color = 'r' if p_value > 0.05 else 'g' - - # Plot the p-value (line and value) - axs[idx_m].axhline(start_y + step_y, xmin=start_x/extent_x[1], xmax=end_x/extent_x[1], color=color) - axs[idx_m].text(start_x + step_x/2, start_y + step_y, p_value, va='center', color=color, ha='center', backgroundcolor='w') - - # Plot endpoints - endpoints_x = [start_x, end_x] - endpoints_y = [start_y + step_y, start_y + step_y] - axs[idx_m].scatter(endpoints_x, endpoints_y, color=color) - - # Move to next line - step_y += 1 / extent_x[1] - - # If p-value is less than 0.05, change starting level - if p_value <= 0.05: - init_metric = metric_val - start_x = end_x - start_level = idx_d + 1 - - # Go to next column - end_x += step_x - idx_d += 1 - - # Rotate xticks - axs[idx_m].set_xticks(axs[idx_m].get_xticks(), best_levels, rotation=45) - - # Set title - if title: - fig.suptitle(title) - else: - fig.suptitle(f'{metric} heatmap') - - # Tight layout - fig.tight_layout() - - # Save the heatmap - if save: - if title: - fig.savefig(path_experiments / f'{title}.png') - else: - fig.savefig(path_experiments / f'{metric}_heatmap.png') - else: - fig.show() - - def plot_radiomics_starting_percentage( - self, - path_experiments: Path, - experiment: str, - levels: List, - modalities: List, - title: str = None, - figsize: tuple = (15, 10), - save: bool = False - ) -> None: - """ - This function plots a pie chart of the percentage of features used in experiment per radiomics level. - - Args: - path_experiments (Path): Path to the folder containing the experiments. - experiment (str): Name of the experiment to plot. Will be used to find the results. - levels (List): List of radiomics levels to include in the plot. - modalities (List): List of imaging modalities to include in the plot. - title(str, optional): Title and name used to save the plot. Defaults to None. - figsize(tuple, optional): Size of the figure. Defaults to (15, 10). - save (bool, optional): Whether to save the plot. Defaults to False. - - Returns: - None. - """ - # Levels names - levels_names = [ - 'Morphology', - 'Intensity', - 'Texture', - 'Linear filters', - 'Textural filters' - ] - - # Initialization - colors_sns = sns.color_palette("pastel", n_colors=5) - - # Create mutliple plots for the pie charts - fig, axes = plt.subplots(len(modalities), len(levels), figsize=figsize) - - # Load the models resutls - for i, modality in enumerate(modalities): - for j, level in enumerate(levels): - exp_full_name = 'learn__' + experiment + '_' + level + '_' + modality - # Use the first test folder to get the results dict - if 'test__001' in os.listdir(path_experiments / exp_full_name): - run_results_dict = load_json(path_experiments / exp_full_name / 'test__001' / 'run_results.json') - else: - raise FileNotFoundError(f'no test file named test__001 in {path_experiments / exp_full_name}') - - # Extract percentage of features per level - perc_levels = np.round(self.__count_percentage_radiomics(run_results_dict), 2) - - # Plot the pie chart of the percentages - if len(modalities) > 1: - axes[i, j].pie( - perc_levels, - autopct= lambda p: '{:.1f}%'.format(p) if p > 0 else '', - pctdistance=0.8, - startangle=120, - rotatelabels=True, - textprops={'fontsize': 14, 'weight': 'bold'}, - colors=colors_sns) - axes[i, j].set_title(f'{level} - {modality}', fontsize=15) - else: - axes[j].pie( - perc_levels, - autopct= lambda p: '{:.1f}%'.format(p) if p > 0 else '', - pctdistance=0.8, - startangle=120, - rotatelabels=True, - textprops={'fontsize': 14, 'weight': 'bold'}, - colors=colors_sns) - axes[j].set_title(f'{level} - {modality}', fontsize=15) - - # Add legend - plt.legend(levels_names, loc='center left', bbox_to_anchor=(1, 0.5), prop={'size': 15}) - fig.tight_layout() - - if title: - fig.suptitle(title, fontsize=20) - else: - fig.suptitle(f'{experiment}: % of starting features per level', fontsize=20) - - # Save the heatmap - if save: - if title: - plt.savefig(path_experiments / f'{title}.png') - else: - plt.savefig(path_experiments / f'{experiment}_percentage_starting_features.png') - else: - plt.show() - - def plot_fda_analysis_heatmap( - self, - path_experiments: Path, - experiment: str, - levels: List, - modalities: List, - title: str = None, - save: bool = False - ) -> None: - """ - This function plots a heatmap of the percentage of stable features and final features selected by FDA for a given experiment. - - Args: - path_experiments (Path): Path to the folder containing the experiments. - experiment (str): Name of the experiment to plot. Will be used to find the results. - levels (List): List of radiomics levels to include in plot. For example: ['morph', 'intensity']. - modalities (List): List of imaging modalities to include in the plot. - title(str, optional): Title and name used to save the plot. Defaults to None. - save (bool, optional): Whether to save the plot. Defaults to False. - - Returns: - None. - """ - # Initialization - Levels names - levels_names = [ - 'Morphology', - 'Intensity', - 'Texture', - 'LF - Intensity', - 'LF - Texture', - 'TF - Intensity', - 'TF - Texture' - ] - level_names_stable = [ - 'Morphology', - 'Intensity', - 'Texture', - 'LF', - 'TF' - ] - - # Initialization - Colors - colors_sns = sns.color_palette("pastel", n_colors=5) - colors_sns_stable = sns.color_palette("pastel", n_colors=5) - colors_sns.insert(3, colors_sns[3]) - colors_sns.insert(5, colors_sns[-1]) - hatch = ['', '', '', '..', '//', '..', '//'] - - # Set hatches color - plt.rcParams['hatch.color'] = 'white' - - # Create mutliple plots for the pie charts - fig, axes = plt.subplots(len(modalities) * 2, len(levels), figsize=(18, 10)) - - # Load the models resutls - for i, modality in enumerate(modalities): - for j, level in enumerate(levels): - perc_levels_stable = [] - perc_levels_final = [] - exp_full_name = 'learn__' + experiment + '_' + level + '_' + modality - for folder in os.listdir(path_experiments / exp_full_name): - if folder.lower().startswith('test__'): - if 'fda_logging_dict.json' in os.listdir(path_experiments / exp_full_name / folder): - fda_dict = load_json(path_experiments / exp_full_name / folder / 'fda_logging_dict.json') - perc_levels_stable.append(self.__count_stable_fda(fda_dict)) - perc_levels_final.append(self.__count_percentage_levels(fda_dict, fda=True)) - else: - raise FileNotFoundError(f'no fda_logging_dict.json file in {path_experiments / exp_full_name / folder}') - - # Average the results - perc_levels_stable = np.mean(perc_levels_stable, axis=0).astype(int) - perc_levels_final = np.mean(perc_levels_final, axis=0).astype(int) - - # Plot pie chart of stable features - axes[i*2, j].pie( - perc_levels_stable, - pctdistance=0.6, - startangle=120, - radius=1.1, - rotatelabels=True, - textprops={'fontsize': 14, 'weight': 'bold'}, - colors=colors_sns_stable - ) - - # Title - axes[i*2, j].set_title(f'{level} - {modality} - Stable', fontsize=15) - - # Legends - legends = [f'{level} - {perc_levels_stable[idx]}' for idx, level in enumerate(level_names_stable)] - axes[i*2, j].legend(legends, loc='center left', bbox_to_anchor=(1, 0.5), prop={'size': 13}) - - # Plot pie chart of the final features selected - axes[i*2+1, j].pie( - perc_levels_final, - autopct= lambda p: '{:.1f}%'.format(p) if p > 0 else '', - pctdistance=0.6, - startangle=120, - radius=1.1, - rotatelabels=True, - textprops={'fontsize': 14, 'weight': 'bold'}, - colors=colors_sns, - hatch=hatch) - - # Title - axes[i*2+1, j].set_title(f'{level} - {modality} - Fianl 10', fontsize=15) - - # Legend - axes[i*2+1, j].legend(levels_names, loc='center left', bbox_to_anchor=(1, 0.5), prop={'size': 13}) - - # Add legend - plt.tight_layout() - plt.subplots_adjust(top=0.9) - - if title: - fig.suptitle(title, fontsize=20) - else: - fig.suptitle(f'{experiment}: FDA breakdown per level', fontsize=20) - - # Save the heatmap - if save: - if title: - plt.savefig(path_experiments / f'{title}.png') - else: - plt.savefig(path_experiments / f'{experiment}_fda_features.png') - else: - plt.show() - - def plot_feature_analysis( - self, - path_experiments: Path, - experiment: str, - levels: List, - modalities: List = [], - title: str = None, - save: bool = False - ) -> None: - """ - This function plots a pie chart of the percentage of the final features used to train the model per radiomics level. - - Args: - path_experiments (Path): Path to the folder containing the experiments. - experiment (str): Name of the experiment to plot. Will be used to find the results. - levels (List): List of radiomics levels to include in plot. For example: ['morph', 'intensity']. - modalities (List, optional): List of imaging modalities to include in the plot. Defaults to []. - title(str, optional): Title and name used to save the plot. Defaults to None. - save (bool, optional): Whether to save the plot. Defaults to False. - - Returns: - None. - """ - # Levels names - levels_names = [ - 'Morphology', - 'Intensity', - 'Texture', - 'Linear filters - Intensity', - 'Linear filters - Texture', - 'Textural filters - Intensity', - 'Textural filters - Texture' - ] - - # Initialization - colors_sns = sns.color_palette("pastel", n_colors=5) - colors_sns.insert(3, colors_sns[3]) - colors_sns.insert(5, colors_sns[-1]) - hatch = ['', '', '', '..', '//', '..', '//'] - - # Set hatches color - plt.rcParams['hatch.color'] = 'white' - - # Create mutliple plots for the pie charts - fig, axes = plt.subplots(len(modalities), len(levels), figsize=(15, 10)) - - # Load the models resutls - for i, modality in enumerate(modalities): - for j, level in enumerate(levels): - exp_full_name = 'learn__' + experiment + '_' + level + '_' + modality - if 'feature_importance_analysis.json' in os.listdir(path_experiments / exp_full_name): - fa_dict = load_json(path_experiments / exp_full_name / 'feature_importance_analysis.json') - else: - fa_dict = feature_imporance_analysis(path_experiments / exp_full_name) - - # Extract percentage of features per level - perc_levels = np.round(self.__count_percentage_levels(fa_dict), 2) - - # Plot the pie chart of percentages for the final features - if len(modalities) > 1: - axes[i, j].pie( - perc_levels, - autopct= lambda p: '{:.1f}%'.format(p) if p > 0 else '', - pctdistance=0.8, - startangle=120, - radius=1.3, - rotatelabels=True, - textprops={'fontsize': 14, 'weight': 'bold'}, - colors=colors_sns, - hatch=hatch) - axes[i, j].set_title(f'{level} - {modality}', fontsize=15) - else: - axes[j].pie( - perc_levels, - autopct= lambda p: '{:.1f}%'.format(p) if p > 0 else '', - pctdistance=0.8, - startangle=120, - radius=1.3, - rotatelabels=True, - textprops={'fontsize': 14, 'weight': 'bold'}, - colors=colors_sns, - hatch=hatch) - axes[j].set_title(f'{level} - {modality}', fontsize=15) - - # Add legend - plt.legend(levels_names, loc='center left', bbox_to_anchor=(1, 0.5), prop={'size': 15}) - plt.tight_layout() - - # Add title - if title: - fig.suptitle(title, fontsize=20) - else: - fig.suptitle(f'{experiment}: % of selected features per level', fontsize=20) - - # Save the heatmap - if save: - if title: - plt.savefig(path_experiments / f'{title}.png') - else: - plt.savefig(path_experiments / f'{experiment}_percentage_features.png') - else: - plt.show() - - def plot_original_level_tree( - self, - path_experiments: Path, - experiment: str, - level: str, - modalities: list, - initial_width: float = 4, - lines_weight: float = 1, - title: str = None, - figsize: tuple = (12,10), - ) -> None: - """ - Plots a tree explaining the impact of features in the original radiomics complexity level. - - Args: - path_experiments (Path): Path to the folder containing the experiments. - experiment (str): Name of the experiment to plot. Will be used to find the results. - level (List): Radiomics complexity level to use for the plot. - modalities (List, optional): List of imaging modalities to include in the plot. Defaults to []. - initial_width (float, optional): Initial width of the lines. Defaults to 1. For aesthetic purposes. - lines_weight (float, optional): Weight applied to the lines of the tree. Defaults to 2. For aesthetic purposes. - title(str, optional): Title and name used to save the plot. Defaults to None. - figsize(tuple, optional): Size of the figure. Defaults to (20, 10). - - Returns: - None. - """ - # Fill tree data for each modality - for modality in modalities: - # Initialization - selected_feat_color = 'limegreen' - optimal_lvl_color = 'darkorange' - - # Initialization - outcome - levels - styles_outcome_levels = ["dashed"] * 3 - colors_outcome_levels = ["black"] * 3 - width_outcome_levels = [initial_width] * 3 - - # Initialization - original - sublevels - styles_original_levels = ["dashed"] * 3 - colors_original_levels = ["black"] * 3 - width_original_levels = [initial_width] * 3 - - # Initialization - texture-families - styles_texture_families = ["dashed"] * 6 - colors_texture_families = ["black"] * 6 - width_texture_families = [initial_width] * 6 - families_names = ["glcm", "ngtdm", "ngldm", "glrlm", "gldzm", "glszm"] - - # Get feature importance dict - exp_full_name = 'learn__' + experiment + '_' + level + '_' + modality - if 'feature_importance_analysis.json' in os.listdir(path_experiments / exp_full_name): - fa_dict = load_json(path_experiments / exp_full_name / 'feature_importance_analysis.json') - else: - fa_dict = feature_imporance_analysis(path_experiments / exp_full_name) - - # Organize data - feature_data = { - 'features': list(fa_dict.keys()), - 'mean_importance': [fa_dict[feature]['importance_mean'] for feature in fa_dict.keys()], - } - - # Convert sample to df - df = pd.DataFrame(feature_data) - - # Apply weight to the lines - df['final_coefficient'] = df['mean_importance'] - - # Normalize the final coefficients between 0 and 1 - df['final_coefficient'] = (df['final_coefficient'] - df['final_coefficient'].min()) \ - / (df['final_coefficient'].max() - df['final_coefficient'].min()) - - # Applying the lines weight - df['final_coefficient'] *= lines_weight - - # Assign complexity level to each feature - for i, row in df['features'].items(): - level_name = row.split('__')[1].lower() - family_name = row.split('__')[2].lower() - - # Morph - if level_name.startswith('morph'): - # Update outcome-original connection - styles_outcome_levels[0] = "solid" - colors_outcome_levels[0] = selected_feat_color - width_outcome_levels[0] += df['final_coefficient'][i] - - # Update original-morph connection - styles_original_levels[0] = "solid" - colors_original_levels[0] = selected_feat_color - width_original_levels[0] += df['final_coefficient'][i] - - # Intensity - elif level_name.startswith('intensity'): - # Update outcome-original connection - styles_outcome_levels[0] = "solid" - colors_outcome_levels[0] = selected_feat_color - width_outcome_levels[0] += df['final_coefficient'][i] - - # Update original-int connection - styles_original_levels[1] = "solid" - colors_original_levels[1] = selected_feat_color - width_original_levels[1] += df['final_coefficient'][i] - - # Texture - elif level_name.startswith('texture'): - # Update outcome-original connection - styles_outcome_levels[0] = "solid" - colors_outcome_levels[0] = selected_feat_color - width_outcome_levels[0] += df['final_coefficient'][i] - - # Update original-texture connection - styles_original_levels[2] = "solid" - colors_original_levels[2] = selected_feat_color - width_original_levels[2] += df['final_coefficient'][i] - - # Determine the most important level - index_best_level = np.argmax(width_outcome_levels) - colors_outcome_levels[index_best_level] = optimal_lvl_color - - # Update color for the best sub-level - colors_original_levels[np.argmax(width_original_levels)] = optimal_lvl_color - - # If texture features are the optimal - if np.argmax(width_original_levels) == 2: - for i, row in df['features'].items(): - level_name = row.split('__')[1].lower() - family_name = row.split('__')[2].lower() - - # Update texture-families connection - if level_name.startswith('texture'): - if family_name.startswith('_glcm'): - styles_texture_families[0] = "solid" - colors_texture_families[0] = selected_feat_color - width_texture_families[0] += df['final_coefficient'][i] - elif family_name.startswith('_ngtdm'): - styles_texture_families[1] = "solid" - colors_texture_families[1] = selected_feat_color - width_texture_families[1] += df['final_coefficient'][i] - elif family_name.startswith('_ngldm'): - styles_texture_families[2] = "solid" - colors_texture_families[2] = selected_feat_color - width_texture_families[2] += df['final_coefficient'][i] - elif family_name.startswith('_glrlm'): - styles_texture_families[3] = "solid" - colors_texture_families[3] = selected_feat_color - width_texture_families[3] += df['final_coefficient'][i] - elif family_name.startswith('_gldzm'): - styles_texture_families[4] = "solid" - colors_texture_families[4] = selected_feat_color - width_texture_families[4] += df['final_coefficient'][i] - elif family_name.startswith('_glszm'): - styles_texture_families[5] = "solid" - colors_texture_families[5] = selected_feat_color - width_texture_families[5] += df['final_coefficient'][i] - else: - raise ValueError(f'Family of the feature {family_name} not recognized') - - # Update color - colors_texture_families[np.argmax(width_texture_families)] = optimal_lvl_color - - # Find best texture family to continue path - best_family_name = "" - index_best_family = np.argmax(width_texture_families) - best_family_name = families_names[index_best_family] - features_names = texture_features_all[index_best_family] - - # Update texture-families-features connection - width_texture_families_feature = [initial_width] * len(features_names) - colors_texture_families_feature = ["black"] * len(features_names) - styles_texture_families_feature = ["dashed"] * len(features_names) - for i, row in df['features'].items(): - level_name = row.split('__')[1].lower() - family_name = row.split('__')[2].lower() - feature_name = row.split('__') - if level_name.startswith('texture') and family_name.startswith('_' + best_family_name): - for feature in features_names: - if feature in feature_name: - colors_texture_families_feature[features_names.index(feature)] = selected_feat_color - styles_texture_families_feature[features_names.index(feature)] = "solid" - width_texture_families_feature[features_names.index(feature)] += df['final_coefficient'][i] - break - - # Update color for the best texture family - colors_texture_families_feature[np.argmax(width_texture_families_feature)] = optimal_lvl_color - - # For esthetic purposes - experiment_sep = experiment.replace('_', '\n') - - # Design the graph - G = nx.Graph() - - # Original level - G.add_edge(experiment_sep, 'Original', color=optimal_lvl_color, width=np.sum(width_original_levels), style="solid") - if styles_original_levels[0] == "solid": - G.add_edge('Original', 'Morph', color=colors_original_levels[0], width=width_original_levels[0], style=styles_original_levels[0]) - if styles_original_levels[1] == "solid": - G.add_edge('Original', 'Int', color=colors_original_levels[1], width=width_original_levels[1], style=styles_original_levels[1]) - if styles_original_levels[2] == "solid": - G.add_edge('Original', 'Text', color=colors_original_levels[2], width=width_original_levels[2], style=styles_original_levels[2]) - - # Continue path to the textural features if they are the optimal level - if np.argmax(width_original_levels) == 2: - # Put best level index in the middle - nodes_order = [0, 1, 2, 3, 4, 5] - nodes_order.insert(3, nodes_order.pop(nodes_order.index(np.argmax(width_texture_families)))) - - # Reorder nodes names - nodes_names = ['GLCM', 'NGTDM', 'NGLDM', 'GLRLM', 'GLDZM', 'GLSZM'] - nodes_names = [nodes_names[i] for i in nodes_order] - colors_texture_families = [colors_texture_families[i] for i in nodes_order] - width_texture_families = [width_texture_families[i] for i in nodes_order] - styles_texture_families = [styles_texture_families[i] for i in nodes_order] - - # Add texture features families nodes - for idx, node_name in enumerate(nodes_names): - G.add_edge( - 'Text', - node_name, - color=colors_texture_families[idx], - width=width_texture_families[idx], - style=styles_texture_families[idx] - ) - - # Continue path to the textural features - best_node_name = best_family_name.upper() - for idx, feature in enumerate(features_names): - G.add_edge( - best_node_name, - feature.replace('_', '\n'), - color=colors_texture_families_feature[idx], - width=width_texture_families_feature[idx], - style=styles_texture_families_feature[idx] - ) - - # Graph layout - pos = graphviz_layout(G, root=experiment_sep, prog="dot") - - # Create the plot: figure and axis - fig = plt.figure(figsize=figsize, dpi=300) - ax = fig.add_subplot(1, 1, 1) - - # Get the attributes of the edges - colors = nx.get_edge_attributes(G,'color').values() - widths = nx.get_edge_attributes(G,'width').values() - style = nx.get_edge_attributes(G,'style').values() - - # Draw the graph - cmap = [to_rgba('b')] * len(pos) - nx.draw( - G, - pos=pos, - ax=ax, - edge_color=colors, - width=list(widths), - with_labels=True, - node_color=cmap, - node_size=1700, - font_size=8, - font_color='white', - font_weight='bold', - node_shape='o', - style=style - ) - - # Create custom legend - custom_legends = [ - Line2D([0], [0], color=selected_feat_color, lw=4, linestyle='solid', label=f'Selected (thickness reflects impact)'), - Line2D([0], [0], color='black', lw=4, linestyle='dashed', label='Not selected'), - Line2D([0], [0], color=optimal_lvl_color, lw=4, linestyle='solid', label='Path with highest impact') - ] - - # Update keys according to the optimal level - figure_keys = [] - if styles_original_levels[0] == "solid": - figure_keys.append(mpatches.Patch(color='none', label='Morph: Morphological')) - if styles_original_levels[1] == "solid": - figure_keys.append(mpatches.Patch(color='none', label='Int: Intensity')) - if styles_original_levels[2] == "solid": - figure_keys.append(mpatches.Patch(color='none', label='Text: Textural')) - - # Set title - if title: - ax.set_title(title, fontsize=20) - else: - ax.set_title( - f'Radiomics explanation tree - Original level:'\ - + f'\nExperiment: {experiment}'\ - + f'\nLevel: {level}'\ - + f'\nModality: {modality}', fontsize=20 - ) - - # Apply the custom legend - legend = plt.legend(handles=custom_legends, loc='upper right', fontsize=15, frameon=True, title = "Legend") - legend.get_frame().set_edgecolor('black') - legend.get_frame().set_linewidth(2.0) - - # Abbrevations legend - legend_keys = plt.legend(handles=figure_keys, loc='center right', fontsize=15, frameon=True, title = "Abbreviations", handlelength=0) - legend_keys.get_frame().set_edgecolor('black') - legend_keys.get_frame().set_linewidth(2.0) - - # Options legend - plt.gca().add_artist(legend_keys) - plt.gca().add_artist(legend) - - # Tight layout - fig.tight_layout() - - # Save the plot (Mandatory, since the plot is not well displayed on matplotlib) - fig.savefig(path_experiments / f'Original_level_{experiment}_{level}_{modality}_explanation_tree.png', dpi=300) - - def plot_lf_level_tree( - self, - path_experiments: Path, - experiment: str, - level: str, - modalities: list, - initial_width: float = 4, - lines_weight: float = 1, - title: str = None, - figsize: tuple = (12,10), - ) -> None: - """ - Plots a tree explaining the impact of features in the linear filters radiomics complexity level. - - Args: - path_experiments (Path): Path to the folder containing the experiments. - experiment (str): Name of the experiment to plot. Will be used to find the results. - level (List): Radiomics complexity level to use for the plot. - modalities (List, optional): List of imaging modalities to include in the plot. Defaults to []. - initial_width (float, optional): Initial width of the lines. Defaults to 1. For aesthetic purposes. - lines_weight (float, optional): Weight applied to the lines of the tree. Defaults to 2. For aesthetic purposes. - title(str, optional): Title and name used to save the plot. Defaults to None. - figsize(tuple, optional): Size of the figure. Defaults to (20, 10). - - Returns: - None. - """ - # Fill tree data - for modality in modalities: - # Initialization - selected_feat_color = 'limegreen' - optimal_lvl_color = 'darkorange' - - # Initialization - outcome - levels - styles_outcome_levels = ["dashed"] * 3 - colors_outcome_levels = ["black"] * 3 - width_outcome_levels = [initial_width] * 3 - - # Initialization - lf - sublevels - filters_names = ['mean', 'log', 'laws', 'gabor', 'coif'] - styles_lf_levels = ["dashed"] * 2 - colors_lf_levels = ["black"] * 2 - width_lf_levels = [initial_width] * 2 - - # Initialization - texture-families - styles_texture_families = ["dashed"] * 6 - colors_texture_families = ["black"] * 6 - width_texture_families = [initial_width] * 6 - families_names = ["glcm", "ngtdm", "ngldm", "glrlm", "gldzm", "glszm"] - - # Get feature importance dict - exp_full_name = 'learn__' + experiment + '_' + level + '_' + modality - if 'feature_importance_analysis.json' in os.listdir(path_experiments / exp_full_name): - fa_dict = load_json(path_experiments / exp_full_name / 'feature_importance_analysis.json') - else: - fa_dict = feature_imporance_analysis(path_experiments / exp_full_name) - - # Organize data - feature_data = { - 'features': list(fa_dict.keys()), - 'mean_importance': [fa_dict[feature]['importance_mean'] for feature in fa_dict.keys()], - } - - # Convert sample to df - df = pd.DataFrame(feature_data) - - # Apply weight to the lines - df['final_coefficient'] = df['mean_importance'] - - # Normalize the final coefficients between 0 and 1 - df['final_coefficient'] = (df['final_coefficient'] - df['final_coefficient'].min()) \ - / (df['final_coefficient'].max() - df['final_coefficient'].min()) - - # Applying the lines weight - df['final_coefficient'] *= lines_weight - - # Finding linear filters features and updating the connections - for i, row in df['features'].items(): - level_name = row.split('__')[1].lower() - family_name = row.split('__')[2].lower() - - # Linear filters - if level_name.startswith('mean') \ - or level_name.startswith('log') \ - or level_name.startswith('laws') \ - or level_name.startswith('gabor') \ - or level_name.startswith('wavelet') \ - or level_name.startswith('coif'): - - # Update outcome-original connection - styles_outcome_levels[1] = "solid" - colors_outcome_levels[1] = selected_feat_color - width_outcome_levels[1] += df['final_coefficient'][i] - - # Find the best performing filter - width_lf_filters = [initial_width] * 5 - for i, row in df['features'].items(): - level_name = row.split('__')[1].lower() - family_name = row.split('__')[2].lower() - if level_name.startswith('mean'): - width_lf_filters[0] += df['final_coefficient'][i] - elif level_name.startswith('log'): - width_lf_filters[1] += df['final_coefficient'][i] - elif level_name.startswith('laws'): - width_lf_filters[2] += df['final_coefficient'][i] - elif level_name.startswith('gabor'): - width_lf_filters[3] += df['final_coefficient'][i] - elif level_name.startswith('wavelet'): - width_lf_filters[4] += df['final_coefficient'][i] - elif level_name.startswith('coif'): - width_lf_filters[4] += df['final_coefficient'][i] - - # Get best filter - index_best_filter = np.argmax(width_lf_filters) - best_filter = filters_names[index_best_filter] - - # Seperate intensity and texture then update the connections - for i, row in df['features'].items(): - level_name = row.split('__')[1].lower() - family_name = row.split('__')[2].lower() - if level_name.startswith(best_filter): - if family_name.startswith('_int'): - width_lf_levels[0] += df['final_coefficient'][i] - elif family_name.startswith(tuple(['_glcm', '_gldzm', '_glrlm', '_glszm', '_ngtdm', '_ngldm'])): - width_lf_levels[1] += df['final_coefficient'][i] - - # If Texture features are more impacful, update the connections - if width_lf_levels[1] > width_lf_levels[0]: - colors_lf_levels[1] = optimal_lvl_color - styles_lf_levels[1] = "solid" - - # Update lf-texture-families connection - for i, row in df['features'].items(): - level_name = row.split('__')[1].lower() - family_name = row.split('__')[2].lower() - if not family_name.startswith('_int') and level_name.startswith(best_filter): - if family_name.startswith('_glcm'): - styles_texture_families[0] = "solid" - colors_texture_families[0] = selected_feat_color - width_texture_families[0] += df['final_coefficient'][i] - elif family_name.startswith('_ngtdm'): - styles_texture_families[1] = "solid" - colors_texture_families[1] = selected_feat_color - width_texture_families[1] += df['final_coefficient'][i] - elif family_name.startswith('_ngldm'): - styles_texture_families[2] = "solid" - colors_texture_families[2] = selected_feat_color - width_texture_families[2] += df['final_coefficient'][i] - elif family_name.startswith('_glrlm'): - styles_texture_families[3] = "solid" - colors_texture_families[3] = selected_feat_color - width_texture_families[3] += df['final_coefficient'][i] - elif family_name.startswith('_gldzm'): - styles_texture_families[4] = "solid" - colors_texture_families[4] = selected_feat_color - width_texture_families[4] += df['final_coefficient'][i] - elif family_name.startswith('_glszm'): - styles_texture_families[5] = "solid" - colors_texture_families[5] = selected_feat_color - width_texture_families[5] += df['final_coefficient'][i] - else: - raise ValueError(f'Family of the feature {family_name} not recognized') - - # Update color - colors_texture_families[np.argmax(width_texture_families)] = optimal_lvl_color - - else: - colors_lf_levels[0] = optimal_lvl_color - styles_lf_levels[0] = "solid" - - # If texture features are the optimal level, continue path - if width_lf_levels[1] > width_lf_levels[0]: - - # Get best texture family - best_family_name = "" - index_best_family = np.argmax(width_texture_families) - best_family_name = families_names[index_best_family] - features_names = texture_features_all[index_best_family] - - # Update texture-families-features connection - width_texture_families_feature = [initial_width] * len(features_names) - colors_texture_families_feature = ["black"] * len(features_names) - styles_texture_families_feature = ["dashed"] * len(features_names) - for i, row in df['features'].items(): - level_name = row.split('__')[1].lower() - family_name = row.split('__')[2].lower() - feature_name = row.split('__') - if family_name.startswith('_' + best_family_name) and level_name.startswith(best_filter): - for feature in features_names: - if feature in feature_name: - colors_texture_families_feature[features_names.index(feature)] = selected_feat_color - styles_texture_families_feature[features_names.index(feature)] = "solid" - width_texture_families_feature[features_names.index(feature)] += df['final_coefficient'][i] - break - - # Update color for the best texture family - colors_texture_families_feature[np.argmax(width_texture_families_feature)] = optimal_lvl_color - - # For esthetic purposes - experiment_sep = experiment.replace('_', '\n') - - # Design the graph - G = nx.Graph() - - # Linear filters level - G.add_edge(experiment_sep, 'LF', color=optimal_lvl_color, width=np.sum(width_lf_filters), style=styles_outcome_levels[1]) - - # Add best filter - best_filter = best_filter.replace('_', '\n') - G.add_edge('LF', best_filter.upper(), color=optimal_lvl_color, width=width_lf_filters[index_best_filter], style="solid") - - # Int or Text - if width_lf_levels[1] <= width_lf_levels[0]: - G.add_edge(best_filter.upper(), 'LF\nInt', color=colors_lf_levels[0], width=width_lf_levels[0], style=styles_lf_levels[0]) - else: - G.add_edge(best_filter.upper(), 'LF\nText', color=colors_lf_levels[1], width=width_lf_levels[1], style=styles_lf_levels[1]) - - # Put best level index in the middle - nodes_order = [0, 1, 2, 3, 4, 5] - nodes_order.insert(3, nodes_order.pop(nodes_order.index(np.argmax(width_texture_families)))) - - # Reorder nodes names - nodes_names = ['LF\nGLCM', 'LF\nNGTDM', 'LF\nNGLDM', 'LF\nGLRLM', 'LF\nGLDZM', 'LF\nGLSZM'] - nodes_names = [nodes_names[i] for i in nodes_order] - colors_texture_families = [colors_texture_families[i] for i in nodes_order] - width_texture_families = [width_texture_families[i] for i in nodes_order] - styles_texture_families = [styles_texture_families[i] for i in nodes_order] - - # Add texture features families nodes - for idx, node_name in enumerate(nodes_names): - G.add_edge( - 'LF\nText', - node_name, - color=colors_texture_families[idx], - width=width_texture_families[idx], - style=styles_texture_families[idx] - ) - - # Continue path to the textural features - best_node_name = f'LF\n{best_family_name.upper()}' - for idx, feature in enumerate(features_names): - G.add_edge( - best_node_name, - feature.replace('_', '\n'), - color=colors_texture_families_feature[idx], - width=width_texture_families_feature[idx], - style=styles_texture_families_feature[idx] - ) - - # Graph layout - pos = graphviz_layout(G, root=experiment_sep, prog="dot") - - # Create the plot: figure and axis - fig = plt.figure(figsize=figsize, dpi=300) - ax = fig.add_subplot(1, 1, 1) - - # Get the attributes of the edges - colors = nx.get_edge_attributes(G,'color').values() - widths = nx.get_edge_attributes(G,'width').values() - style = nx.get_edge_attributes(G,'style').values() - - # Draw the graph - cmap = [to_rgba('b')] * len(pos) - nx.draw( - G, - pos=pos, - ax=ax, - edge_color=colors, - width=list(widths), - with_labels=True, - node_color=cmap, - node_size=1700, - font_size=8, - font_color='white', - font_weight='bold', - node_shape='o', - style=style - ) - - # Create custom legend - custom_legends = [ - Line2D([0], [0], color=selected_feat_color, lw=4, linestyle='solid', label=f'Selected (thickness reflects impact)'), - Line2D([0], [0], color='black', lw=4, linestyle='dashed', label='Not selected'), - Line2D([0], [0], color=optimal_lvl_color, lw=4, linestyle='solid', label='Path with highest impact') - ] - - # Update keys according to the optimal level - figure_keys = [] - figure_keys.append(mpatches.Patch(color='none', label='LF: Linear Filters')) - if width_lf_levels[1] > width_lf_levels[0]: - figure_keys.append(mpatches.Patch(color='none', label='Text: Textural')) - else: - figure_keys.append(mpatches.Patch(color='none', label='Int: Intensity')) - - # Set title - if title: - ax.set_title(title, fontsize=20) - else: - ax.set_title( - f'Radiomics explanation tree:'\ - + f'\nExperiment: {experiment}'\ - + f'\nLevel: {level}'\ - + f'\nModality: {modality}', fontsize=20 - ) - - # Apply the custom legend - legend = plt.legend(handles=custom_legends, loc='upper right', fontsize=15, frameon=True, title = "Legend") - legend.get_frame().set_edgecolor('black') - legend.get_frame().set_linewidth(2.0) - - # Abbrevations legend - legend_keys = plt.legend(handles=figure_keys, loc='center right', fontsize=15, frameon=True, title = "Abbreviations", handlelength=0) - legend_keys.get_frame().set_edgecolor('black') - legend_keys.get_frame().set_linewidth(2.0) - - # Options legend - plt.gca().add_artist(legend_keys) - plt.gca().add_artist(legend) - - # Tight layout - fig.tight_layout() - - # Save the plot (Mandatory, since the plot is not well displayed on matplotlib) - fig.savefig(path_experiments / f'LF_level_{experiment}_{level}_{modality}_explanation_tree.png', dpi=300) - - def plot_tf_level_tree( - self, - path_experiments: Path, - experiment: str, - level: str, - modalities: list, - initial_width: float = 4, - lines_weight: float = 1, - title: str = None, - figsize: tuple = (12,10), - ) -> None: - """ - Plots a tree explaining the impact of features in the textural filters radiomics complexity level. - - Args: - path_experiments (Path): Path to the folder containing the experiments. - experiment (str): Name of the experiment to plot. Will be used to find the results. - level (List): Radiomics complexity level to use for the plot. - modalities (List, optional): List of imaging modalities to include in the plot. Defaults to []. - initial_width (float, optional): Initial width of the lines. Defaults to 1. For aesthetic purposes. - lines_weight (float, optional): Weight applied to the lines of the tree. Defaults to 2. For aesthetic purposes. - title(str, optional): Title and name used to save the plot. Defaults to None. - figsize(tuple, optional): Size of the figure. Defaults to (20, 10). - - Returns: - None. - """ - # Fill tree data - for modality in modalities: - # Initialization - selected_feat_color = 'limegreen' - optimal_lvl_color = 'darkorange' - - # Initialization - outcome - levels - styles_outcome_levels = ["dashed"] * 3 - colors_outcome_levels = ["black"] * 3 - width_outcome_levels = [initial_width] * 3 - - # Initialization - tf - sublevels - styles_tf_levels = ["dashed"] * 2 - colors_tf_levels = ["black"] * 2 - width_tf_levels = [initial_width] * 2 - - # Initialization - tf - best filter - width_tf_filters = [initial_width] * len(glcm_features_names) - - # Initialization - texture-families - styles_texture_families = ["dashed"] * 6 - colors_texture_families = ["black"] * 6 - width_texture_families = [initial_width] * 6 - families_names = ["glcm", "ngtdm", "ngldm", "glrlm", "gldzm", "glszm"] - - # Get feature importance dict - exp_full_name = 'learn__' + experiment + '_' + level + '_' + modality - if 'feature_importance_analysis.json' in os.listdir(path_experiments / exp_full_name): - fa_dict = load_json(path_experiments / exp_full_name / 'feature_importance_analysis.json') - else: - fa_dict = feature_imporance_analysis(path_experiments / exp_full_name) - - # Organize data - feature_data = { - 'features': list(fa_dict.keys()), - 'mean_importance': [fa_dict[feature]['importance_mean'] for feature in fa_dict.keys()], - } - - # Convert sample to df - df = pd.DataFrame(feature_data) - - # Apply weight to the lines - df['final_coefficient'] = df['mean_importance'] - - # Normalize the final coefficients between 0 and 1 - df['final_coefficient'] = (df['final_coefficient'] - df['final_coefficient'].min()) \ - / (df['final_coefficient'].max() - df['final_coefficient'].min()) - - # Applying the lines weight - df['final_coefficient'] *= lines_weight - - # Filling the lines data for textural filters features and updating the connections - for i, row in df['features'].items(): - level_name = row.split('__')[1].lower() - family_name = row.split('__')[2].lower() - - # Textural filters - if level_name.startswith('glcm'): - # Update outcome-original connection - styles_outcome_levels[2] = "solid" - colors_outcome_levels[2] = optimal_lvl_color - width_outcome_levels[2] += df['final_coefficient'][i] - - # Update tf-best filter connection - for feature in glcm_features_names: - if feature + '__' in row: - width_tf_filters[glcm_features_names.index(feature)] += df['final_coefficient'][i] - break - - # Get best filter - index_best_filter = np.argmax(width_tf_filters) - best_filter = glcm_features_names[index_best_filter] - - # Seperate intensity and texture then update the connections - for i, row in df['features'].items(): - level_name = row.split('__')[1].lower() - family_name = row.split('__')[2].lower() - if level_name.startswith('glcm') and best_filter + '__' in row: - if family_name.startswith('_int'): - width_tf_levels[0] += df['final_coefficient'][i] - elif family_name.startswith(tuple(['_glcm', '_gldzm', '_glrlm', '_glszm', '_ngtdm', '_ngldm'])): - width_tf_levels[1] += df['final_coefficient'][i] - - # If Texture features are more impacful, update the connections - if width_tf_levels[1] > width_tf_levels[0]: - colors_tf_levels[1] = optimal_lvl_color - styles_tf_levels[1] = "solid" - - # Update tf-texture-families connection - for i, row in df['features'].items(): - level_name = row.split('__')[1].lower() - family_name = row.split('__')[2].lower() - if level_name.startswith('glcm') and best_filter + '__' in row: - if family_name.startswith('_glcm'): - styles_texture_families[0] = "solid" - colors_texture_families[0] = selected_feat_color - width_texture_families[0] += df['final_coefficient'][i] - elif family_name.startswith('_ngtdm'): - styles_texture_families[1] = "solid" - colors_texture_families[1] = selected_feat_color - width_texture_families[1] += df['final_coefficient'][i] - elif family_name.startswith('_ngldm'): - styles_texture_families[2] = "solid" - colors_texture_families[2] = selected_feat_color - width_texture_families[2] += df['final_coefficient'][i] - elif family_name.startswith('_glrlm'): - styles_texture_families[3] = "solid" - colors_texture_families[3] = selected_feat_color - width_texture_families[3] += df['final_coefficient'][i] - elif family_name.startswith('_gldzm'): - styles_texture_families[4] = "solid" - colors_texture_families[4] = selected_feat_color - width_texture_families[4] += df['final_coefficient'][i] - elif family_name.startswith('_glszm'): - styles_texture_families[5] = "solid" - colors_texture_families[5] = selected_feat_color - width_texture_families[5] += df['final_coefficient'][i] - - # Get best texture family - best_family_name = "" - index_best_family = np.argmax(width_texture_families) - best_family_name = families_names[index_best_family] - features_names = texture_features_all[index_best_family] - - # Update texture-families-features connection - width_texture_families_feature = [initial_width] * len(features_names) - colors_texture_families_feature = ["black"] * len(features_names) - styles_texture_families_feature = ["dashed"] * len(features_names) - for i, row in df['features'].items(): - level_name = row.split('__')[1].lower() - family_name = row.split('__')[2].lower() - feature_name = row.split('__') - if level_name.startswith('glcm') and family_name.startswith('_' + best_family_name) and best_filter + '__' in row: - for feature in features_names: - if feature in feature_name: - colors_texture_families_feature[features_names.index(feature)] = selected_feat_color - styles_texture_families_feature[features_names.index(feature)] = "solid" - width_texture_families_feature[features_names.index(feature)] += df['final_coefficient'][i] - break - - # Update color for the best texture family - colors_texture_families_feature[np.argmax(width_texture_families_feature)] = optimal_lvl_color - - # Update color - colors_texture_families[np.argmax(width_texture_families)] = optimal_lvl_color - else: - colors_tf_levels[0] = optimal_lvl_color - styles_tf_levels[0] = "solid" - - # For esthetic purposes - experiment_sep = experiment.replace('_', '\n') - - # Design the graph - G = nx.Graph() - G.add_edge(experiment_sep, 'TF', color=colors_outcome_levels[2], width=width_outcome_levels[2], style=styles_outcome_levels[2]) - - # Add best filter - best_filter = best_filter.replace('_', '\n') - G.add_edge('TF', best_filter.upper(), color=optimal_lvl_color, width=width_tf_filters[index_best_filter], style="solid") - - # Check which level is the best (intensity or texture) - if width_tf_levels[1] <= width_tf_levels[0]: - G.add_edge(best_filter.upper(), 'TF\nInt', color=colors_tf_levels[0], width=width_tf_levels[0], style=styles_tf_levels[0]) - else: - G.add_edge(best_filter.upper(), 'TF\nText', color=colors_tf_levels[1], width=width_tf_levels[1], style=styles_tf_levels[1]) - - # Put best level index in the middle - nodes_order = [0, 1, 2, 3, 4, 5] - nodes_order.insert(3, nodes_order.pop(nodes_order.index(np.argmax(width_texture_families)))) - - # Reorder nodes names - nodes_names = ['TF\nGLCM', 'TF\nNGTDM', 'TF\nNGLDM', 'TF\nGLRLM', 'TF\nGLDZM', 'TF\nGLSZM'] - nodes_names = [nodes_names[i] for i in nodes_order] - colors_texture_families = [colors_texture_families[i] for i in nodes_order] - width_texture_families = [width_texture_families[i] for i in nodes_order] - styles_texture_families = [styles_texture_families[i] for i in nodes_order] - - # Add texture features families nodes - for idx, node_names in enumerate(nodes_names): - G.add_edge( - 'TF\nText', - node_names, - color=colors_texture_families[idx], - width=width_texture_families[idx], - style=styles_texture_families[idx] - ) - - # Continue path to the textural features - best_node_name = f'TF\n{best_family_name.upper()}' - for idx, feature in enumerate(features_names): - G.add_edge( - best_node_name, - feature.replace('_', '\n'), - color=colors_texture_families_feature[idx], - width=width_texture_families_feature[idx], - style=styles_texture_families_feature[idx] - ) - - # Graph layout - pos = graphviz_layout(G, root=experiment_sep, prog="dot") - - # Create the plot: figure and axis - fig = plt.figure(figsize=figsize, dpi=300) - ax = fig.add_subplot(1, 1, 1) - - # Get the attributes of the edges - colors = nx.get_edge_attributes(G,'color').values() - widths = nx.get_edge_attributes(G,'width').values() - style = nx.get_edge_attributes(G,'style').values() - - # Draw the graph - cmap = [to_rgba('b')] * len(pos) - nx.draw( - G, - pos=pos, - ax=ax, - edge_color=colors, - width=list(widths), - with_labels=True, - node_color=cmap, - node_size=1700, - font_size=8, - font_color='white', - font_weight='bold', - node_shape='o', - style=style - ) - - # Create custom legend - custom_legends = [ - Line2D([0], [0], color=selected_feat_color, lw=4, linestyle='solid', label=f'Selected (thickness reflects impact)'), - Line2D([0], [0], color='black', lw=4, linestyle='dashed', label='Not selected') - ] - figure_keys = [] - - # Update keys according to the optimal level - figure_keys = [mpatches.Patch(color='none', label='TF: Linear Filters')] - if width_tf_levels[1] > width_tf_levels[0]: - figure_keys.append(mpatches.Patch(color='none', label='Text: Textural')) - else: - figure_keys.append(mpatches.Patch(color='none', label='Int: Intensity')) - - custom_legends.append( - Line2D([0], [0], color=optimal_lvl_color, lw=4, linestyle='solid', label='Path with highest impact') - ) - - # Set title - if title: - ax.set_title(title, fontsize=20) - else: - ax.set_title( - f'Radiomics explanation tree:'\ - + f'\nExperiment: {experiment}'\ - + f'\nLevel: {level}'\ - + f'\nModality: {modality}', fontsize=20 - ) - - # Apply the custom legend - legend = plt.legend(handles=custom_legends, loc='upper right', fontsize=15, frameon=True, title = "Legend") - legend.get_frame().set_edgecolor('black') - legend.get_frame().set_linewidth(2.0) - - # Abbrevations legend - legend_keys = plt.legend(handles=figure_keys, loc='center right', fontsize=15, frameon=True, title = "Abbreviations", handlelength=0) - legend_keys.get_frame().set_edgecolor('black') - legend_keys.get_frame().set_linewidth(2.0) - - # Options legend - plt.gca().add_artist(legend_keys) - plt.gca().add_artist(legend) - - # Tight layout - fig.tight_layout() - - # Save the plot (Mandatory, since the plot is not well displayed on matplotlib) - fig.savefig(path_experiments / f'TF_{experiment}_{level}_{modality}_explanation_tree.png', dpi=300) - - def to_json( - self, - response_train: list = None, - response_test: list = None, - response_holdout: list = None, - patients_train: list = None, - patients_test: list = None, - patients_holdout: list = None - ) -> dict: - """ - Creates a dictionary with the results of the model using the class attributes. - - Args: - response_train (list): List of machine learning model predictions for the training set. - response_test (list): List of machine learning model predictions for the test set. - patients_train (list): List of patients in the training set. - patients_test (list): List of patients in the test set. - patients_holdout (list): List of patients in the holdout set. - - Returns: - Dict: Dictionary with the the responses of the model and the patients used for training, testing and holdout. - """ - run_results = dict() - run_results[self.model_id] = self.model_dict - - # Training results info - run_results[self.model_id]['train'] = dict() - run_results[self.model_id]['train']['patients'] = patients_train - run_results[self.model_id]['train']['response'] = response_train.tolist() if response_train is not None else [] - - # Testing results info - run_results[self.model_id]['test'] = dict() - run_results[self.model_id]['test']['patients'] = patients_test - run_results[self.model_id]['test']['response'] = response_test.tolist() if response_test is not None else [] - - # Holdout results info - run_results[self.model_id]['holdout'] = dict() - run_results[self.model_id]['holdout']['patients'] = patients_holdout - run_results[self.model_id]['holdout']['response'] = response_holdout.tolist() if response_holdout is not None else [] - - # keep a copy of the results - self.results_dict = run_results - - return run_results diff --git a/MEDimage/learning/Stats.py b/MEDimage/learning/Stats.py deleted file mode 100644 index 8af2a54..0000000 --- a/MEDimage/learning/Stats.py +++ /dev/null @@ -1,694 +0,0 @@ -# Description: All the functions related to statistics (p-values, metrics, etc.) - -import os -from pathlib import Path -from typing import List, Tuple -import warnings - -import numpy as np -import pandas as pd -import scipy -from sklearn import metrics - -from MEDimage.utils.json_utils import load_json - - -class Stats: - """ - A class to perform statistical analysis on experiment results. - - This class provides methods to retrieve patient IDs, predictions, and metrics from experiment data, - as well as compute the p-values for model comparison using various methods. - - Args: - path_experiment (Path): Path to the folder containing the experiment data. - experiment (str): Name of the experiment. - levels (List): List of radiomics levels to analyze. - modalities (List): List of modalities to analyze. - - Attributes: - path_experiment (Path): Path to the folder containing the experiment data. - experiment (str): Name of the experiment. - levels (List): List of radiomics levels to analyze. - modalities (List): List of modalities to analyze. - """ - def __init__(self, path_experiment: Path, experiment: str = "", levels: List = [], modalities: List = []): - # Initialization - self.path_experiment = path_experiment - self.experiment = experiment - self.levels = levels - self.modalities = modalities - - # Safety assertion - self.__safety_assertion() - - def __get_models_dicts(self, split_idx: int) -> Path: - """ - Retrieves the models dictionaries for a given split. - - Args: - split_idx (int): Index of the split. - - Returns: - List: List of paths to the models dictionaries. - """ - # Get level and modality - if len(self.modalities) == 1: - # Load ground truths and predictions - path_json_1 = self.__get_path_json(self.levels[0], self.modalities[0], split_idx) - path_json_2 = self.__get_path_json(self.levels[1], self.modalities[0], split_idx) - else: - # Load ground truths and predictions - path_json_1 = self.__get_path_json(self.levels[0], self.modalities[0], split_idx) - path_json_2 = self.__get_path_json(self.levels[0], self.modalities[1], split_idx) - return path_json_1, path_json_2 - - def __safety_assertion(self): - """ - Asserts that the input parameters are correct. - """ - if len(self.modalities) == 1: - assert len(self.levels) == 2, \ - "For statistical analysis, the number of levels must be 2 for a single modality, or 1 for two modalities" - elif len(self.modalities) == 2: - assert len(self.levels) == 1, \ - "For statistical analysis, the number of levels must be 1 for two modalities, or 2 for a single modality" - else: - raise ValueError("The number of modalities must be 1 or 2") - - def __get_path_json(self, level: str, modality: str, split_idx: int) -> Path: - """ - Retrieves the path to the models dictionary for a given split. - - Args: - level (str): Radiomics level. - modality (str): Modality. - split_idx (int): Index of the split. - - Returns: - Path: Path to the models dictionary. - """ - return self.path_experiment / f'learn__{self.experiment}_{level}_{modality}' / f'test__{split_idx:03d}' / 'run_results.json' - - def __get_patients_and_predictions( - self, - split_idx: int - ) -> tuple: - """ - Retrieves patient IDs, predictions of both models for a given split. - - Args: - split_idx (int): Index of the split. - - Returns: - tuple: Tuple containing the patient IDs, predictions of the first model and predictions of the second model. - """ - # Get models dicts - path_json_1, path_json_2 = self.__get_models_dicts(split_idx) - - # Load models dicts - model_one = load_json(path_json_1) - model_two = load_json(path_json_2) - - # Get name models - name_model_one = list(model_one.keys())[0] - name_model_two = list(model_two.keys())[0] - - # Get predictions - predictions_one = np.array(model_one[name_model_one]['test']['response']) - predictions_one = np.reshape(predictions_one, (predictions_one.shape[0])).tolist() - predictions_two = np.array(model_two[name_model_two]['test']['response']) - predictions_two = np.reshape(predictions_two, (predictions_two.shape[0])).tolist() - - # Get patients ids - patients_ids_one = model_one[name_model_one]['test']['patients'] - patients_ids_two = model_two[name_model_two]['test']['patients'] - - # Check if the number of patients is the same - patients_delete = [] - if len(patients_ids_one) > len(patients_ids_two): - # Warn the user - warnings.warn("The number of patients is different for both models. Patients will be deleted to match the number of patients.") - - # Delete patients - for patient_id in patients_ids_one: - if patient_id not in patients_ids_two: - patients_delete.append(patient_id) - predictions_one.pop(patients_ids_one.index(patient_id)) - for patient in patients_delete: - patients_ids_one.remove(patient) - elif len(patients_ids_one) < len(patients_ids_two): - # Warn the user - warnings.warn("The number of patients is different for both models. Patients will be deleted to match the number of patients.") - - # Delete patients - for patient_id in patients_ids_two: - if patient_id not in patients_ids_one: - patients_delete.append(patient_id) - predictions_two.pop(patients_ids_two.index(patient_id)) - for patient in patients_delete: - patients_ids_two.remove(patient) - - # Check if the patient IDs are the same - if patients_ids_one != patients_ids_two: - raise ValueError("The patient IDs must be the same for both models") - - # Check if the number of predictions is the same - if len(predictions_one) != len(predictions_two): - raise ValueError("The number of predictions must be the same for both models") - - return patients_ids_one, predictions_one, predictions_two - - def __calc_pvalue(self, aucs: np.array, sigma: float) -> float: - """ - Computes p-values of the AUCs distribution. - - Args: - aucs(np.array): 1D array of AUCs. - sigma (flaot): AUC DeLong covariances - - Returns: - flaot: p-value of the AUCs. - """ - l = np.array([[1, -1]]) - z = np.abs(np.diff(aucs)) / np.sqrt(np.dot(np.dot(l, sigma), l.T)) - p_value = 2 * scipy.stats.norm.sf(z, loc=0, scale=1) - return p_value - - def __corrected_std(self, differences: np.array, n_train: int, n_test: int) -> float: - """ - Corrects standard deviation using Nadeau and Bengio's approach. - - Args: - differences (np.array): Vector containing the differences in the score metrics of two models. - n_train (int): Number of samples in the training set. - n_test (int): Number of samples in the testing set. - - Returns: - float: Variance-corrected standard deviation of the set of differences. - - Reference: - `Statistical comparison of models - .` - """ - # kr = k times r, r times repeated k-fold crossvalidation, - # kr equals the number of times the model was evaluated - kr = len(differences) - corrected_var = np.var(differences, ddof=1) * (1 / kr + n_test / n_train) - corrected_std = np.sqrt(corrected_var) - return corrected_std - - def __compute_midrank(self, x: np.array) -> np.array: - """ - Computes midranks for Delong p-value. - Args: - x(np.array): 1D array of probabilities. - - Returns: - np.array: Midranks. - """ - J = np.argsort(x) - Z = x[J] - N = len(x) - T = np.zeros(N, dtype=np.float) - i = 0 - while i < N: - j = i - while j < N and Z[j] == Z[i]: - j += 1 - T[i:j] = 0.5*(i + j - 1) - i = j - T2 = np.empty(N, dtype=np.float) - # Note(kazeevn) +1 is due to Python using 0-based indexing - # instead of 1-based in the AUC formula in the paper - T2[J] = T + 1 - return T2 - - def __fast_delong(self, predictions_sorted_transposed: np.array, label_1_count: int) -> Tuple[float, float]: - """ - Computes the empricial AUC and its covariance using the fast version of DeLong's method. - - Args: - predictions_sorted_transposed (np.array): a 2D numpy.array[n_classifiers, n_examples] - sorted such as the examples with label "1" are first. - label_1_count (int): number of examples with label "1". - - Returns: - Tuple(float, float): (AUC value, DeLong covariance) - - Reference: - `Python fast delong implementation .` - @article{sun2014fast, - title={Fast Implementation of DeLong's Algorithm for - Comparing the Areas Under Correlated Receiver Operating Characteristic Curves}, - author={Xu Sun and Weichao Xu}, - journal={IEEE Signal Processing Letters}, - volume={21}, - number={11}, - pages={1389--1393}, - year={2014}, - publisher={IEEE} - } - """ - # Short variables are named as they are in the paper - m = label_1_count - n = predictions_sorted_transposed.shape[1] - m - positive_examples = predictions_sorted_transposed[:, :m] - negative_examples = predictions_sorted_transposed[:, m:] - k = predictions_sorted_transposed.shape[0] - - tx = np.empty([k, m], dtype=np.float) - ty = np.empty([k, n], dtype=np.float) - tz = np.empty([k, m + n], dtype=np.float) - for r in range(k): - tx[r, :] = self.__compute_midrank(positive_examples[r, :]) - ty[r, :] = self.__compute_midrank(negative_examples[r, :]) - tz[r, :] = self.__compute_midrank(predictions_sorted_transposed[r, :]) - aucs = tz[:, :m].sum(axis=1) / m / n - float(m + 1.0) / 2.0 / n - v01 = (tz[:, :m] - tx[:, :]) / n - v10 = 1.0 - (tz[:, m:] - ty[:, :]) / m - sx = np.cov(v01) - sy = np.cov(v10) - delongcov = sx / m + sy / n - - return aucs, delongcov - - def __compute_ground_truth_statistics(self, ground_truth: np.array) -> Tuple[np.array, int]: - """ - Computes the order of the ground truth and the number of positive examples. - - Args: - ground_truth(np.array): np.array of 0 and 1. - - Returns: - Tuple[np.array, int]: ground truth ordered and the number of positive examples. - """ - assert np.array_equal(np.unique(ground_truth), [0, 1]) - order = (-ground_truth).argsort() - label_1_count = int(ground_truth.sum()) - return order, label_1_count - - def __get_metrics(self, metric: str, split_idx: int) -> tuple: - """ - Initializes the p-value information that will be used to compute the p-values across all different methods. - - Args: - metric (str): Metric to retrieve. - split_idx (int): Index of the split. - - Returns: - tuple: Tuple containing the metrics of the first model and metrics of the second model. - """ - # Get models dicts - path_json_1, path_json_2 = self.__get_models_dicts(split_idx) - - # Load models dicts - model_one = load_json(path_json_1) - model_two = load_json(path_json_2) - - # Get name models - name_model_one = list(model_one.keys())[0] - name_model_two = list(model_two.keys())[0] - - # Get predictions - metric_one = model_one[name_model_one]['test']['metrics'][metric] - metric_two = model_two[name_model_two]['test']['metrics'][metric] - - return metric_one, metric_two - - def __delong_roc_test(self, ground_truth: np.array, predictions_one: list, predictions_two: list) -> float: - """ - Computes log(p-value) for hypothesis that two ROC AUCs are different - - Args: - ground_truth(np.array): np.array of 0 and 1 - predictions_one(np.array): np.array of floats of the probability of being class 1 for the first model. - predictions_two(np.array): np.array of floats of the probability of being class 1 for the second model. - - Returns: - flaot: p-value of the AUCs. - """ - order, label_1_count = self.__compute_ground_truth_statistics(ground_truth) - predictions_sorted_transposed = np.vstack((predictions_one, predictions_two))[:, order] - aucs, delongcov = self.__fast_delong(predictions_sorted_transposed, label_1_count) - return self.__calc_pvalue(aucs, delongcov) - - @staticmethod - def get_aggregated_metric( - path_experiment: Path, - experiment: str, - level: str, - modality: str, - metric: str - ) -> float: - """ - Calculates the p-value of the Delong test for the given experiment. - - Args: - path_experiment (Path): Path to the folder containing the experiment. - experiment (str): Name of the experiment. - level (str): Radiomics level. For example: 'morph'. - modality (str): Modality to analyze. - metric (str): Metric to analyze. - - Returns: - float: p-value of the Delong test. - """ - - # Load outcomes dataframe - try: - outcomes = pd.read_csv(path_experiment / "outcomes.csv", sep=',') - except: - outcomes = pd.read_csv(path_experiment.parent / "outcomes.csv", sep=',') - - # Initialization - predictions_all = list() - patients_ids_all = list() - nb_split = len([x[0] for x in os.walk(path_experiment / f'learn__{experiment}_{level}_{modality}')]) - 1 - - # For each split - for i in range(1, nb_split + 1): - # Load ground truths and predictions - path_json = path_experiment / f'learn__{experiment}_{level}_{modality}' / f'test__{i:03d}' / 'run_results.json' - - # Load models dicts - model = load_json(path_json) - - # Get name models - name_model = list(model.keys())[0] - - # Get Model's threshold - thresh = model[name_model]['threshold'] - - # Get predictions - predictions = np.array(model[name_model]['test']['response']) - predictions = np.reshape(predictions, (predictions.shape[0])).tolist() - - # Bring all predictions to 0.5 - predictions = [prediction - thresh + 0.5 if thresh >= 0.5 else prediction + 0.5 - thresh for prediction in predictions] - predictions_all.extend(predictions) - - # Get patients ids - patients_ids = model[name_model]['test']['patients'] - - # After verification, add-up patients IDs - patients_ids_all.extend(patients_ids) - - # Get ground truth for selected patients - ground_truth = [] - for patient in patients_ids_all: - ground_truth.append(outcomes[outcomes['PatientID'] == patient][outcomes.columns[-1]].values[0]) - - # to numpy array - ground_truth = np.array(ground_truth) - - # Get aggregated metric - # AUC - if metric == 'AUC': - auc = metrics.roc_auc_score(ground_truth, predictions_all) - return auc - - # AUPRC - elif metric == 'AUPRC': - auc = metrics.average_precision_score(ground_truth, predictions_all) - - # Confusion matrix-based metrics - else: - TP = ((np.array(predictions_all) >= 0.5) & (ground_truth == 1)).sum() - TN = ((np.array(predictions_all) < 0.5) & (ground_truth == 0)).sum() - FP = ((np.array(predictions_all) >= 0.5) & (ground_truth == 0)).sum() - FN = ((np.array(predictions_all) < 0.5) & (ground_truth == 1)).sum() - - # Asserts - assert TP + FN != 0, "TP + FN = 0, Division by 0" - assert TN + FP != 0, "TN + FP = 0, Division by 0" - - # Sensitivity - if metric == 'Sensitivity': - sensitivity = TP / (TP + FN) - return sensitivity - - # Specificity - elif metric == 'Specificity': - specificity = TN / (TN + FP) - return specificity - - else: - raise ValueError(f"Metric {metric} not supported. Supported metrics: AUC, AUPRC, Sensitivity, Specificity.\ - Update file Stats.py to add the new metric.") - - def get_aggregated_delong_p_value(self) -> float: - """ - Calculates the p-value of the Delong test for the given experiment. - - Returns: - float: p-value of the Delong test. - """ - - # Load outcomes dataframe - try: - outcomes = pd.read_csv(self.path_experiment / "outcomes.csv", sep=',') - except: - outcomes = pd.read_csv(self.path_experiment.parent / "outcomes.csv", sep=',') - - # Initialization - predictions_one_all = list() - predictions_two_all = list() - patients_ids_all = list() - nb_split = len([x[0] for x in os.walk(self.path_experiment / f'learn__{self.experiment}_{self.levels[0]}_{self.modalities[0]}')]) - 1 - - # For each split - for i in range(1, nb_split + 1): - # Get predictions and patients ids - patients_ids, predictions_one, predictions_two = self.__get_patients_and_predictions(i) - - # Add-up all information - predictions_one_all.extend(predictions_one) - predictions_two_all.extend(predictions_two) - patients_ids_all.extend(patients_ids) - - # Get ground truth for selected patients - ground_truth = [] - for patient in patients_ids_all: - ground_truth.append(outcomes[outcomes['PatientID'] == patient][outcomes.columns[-1]].values[0]) - - # to numpy array - ground_truth = np.array(ground_truth) - - # Get p-value - pvalue = self.__delong_roc_test(ground_truth, predictions_one_all, predictions_two_all).item() - - # Compute the median p-value of all splits - return pvalue - - def get_bengio_p_value(self) -> float: - """ - Computes Bengio's right-tailed paired t-test with corrected variance. - - Returns: - float: p-value of the Bengio test. - """ - - # Initialization - metrics_one_all = list() - metrics_two_all = list() - nb_split = len([x[0] for x in os.walk(self.path_experiment / f'learn__{self.experiment}_{self.levels[0]}_{self.modalities[0]}')]) - 1 - - # For each split - for i in range(1, nb_split + 1): - # Get models dicts - path_json_1, path_json_2 = self.__get_models_dicts(i) - - # Load patients train and test lists - patients_train = load_json(path_json_1.parent / 'patientsTrain.json') - patients_test = load_json(path_json_1.parent / 'patientsTest.json') - n_train = len(patients_train) - n_test = len(patients_test) - - # Load models dicts - model_one = load_json(path_json_1) - model_two = load_json(path_json_2) - - # Get name models - name_model_one = list(model_one.keys())[0] - name_model_two = list(model_two.keys())[0] - - # Get predictions - metric_one = model_one[name_model_one]['test']['metrics']['AUC'] - metric_two = model_two[name_model_two]['test']['metrics']['AUC'] - - # Add-up all information - metrics_one_all.append(metric_one) - metrics_two_all.append(metric_two) - - # Check if the number of predictions is the same - if len(metrics_one_all) != len(metrics_two_all): - raise ValueError("The number of metrics must be the same for both models") - - # Get differences - differences = np.array(metrics_one_all) - np.array(metrics_two_all) - df = differences.shape[0] - 1 - - # Get corrected std - mean = np.mean(differences) - std = self.__corrected_std(differences, n_train, n_test) - - # Get p-value - t_stat = mean / std - p_val = scipy.stats.t.sf(np.abs(t_stat), df) # right-tailed t-test - - return p_val - - def get_delong_p_value( - self, - aggregate: bool = False, - ) -> float: - """ - Calculates the p-value of the Delong test for the given experiment. - - Args: - aggregate (bool, optional): If True, aggregates the results of all the splits and computes one final p-value. - - Returns: - float: p-value of the Delong test. - """ - - # Check if aggregation is needed - if aggregate: - return self.get_aggregated_delong_p_value() - - # Load outcomes dataframe - try: - outcomes = pd.read_csv(self.path_experiment / "outcomes.csv", sep=',') - except: - outcomes = pd.read_csv(self.path_experiment.parent / "outcomes.csv", sep=',') - - # Initialization - nb_split = len([x[0] for x in os.walk(self.path_experiment / f'learn__{self.experiment}_{self.levels[0]}_{self.modalities[0]}')]) - 1 - list_p_values_temp = list() - - # For each split - for i in range(1, nb_split + 1): - # Get predictions and patients ids - patients_ids, predictions_one, predictions_two = self.__get_patients_and_predictions(i) - - # Get ground truth for selected patients - ground_truth = [] - for patient in patients_ids: - ground_truth.append(outcomes[outcomes['PatientID'] == patient][outcomes.columns[-1]].values[0]) - - # to numpy array - ground_truth = np.array(ground_truth) - - # Get p-value - pvalue = self.__delong_roc_test(ground_truth, predictions_one, predictions_two).item() - - list_p_values_temp.append(pvalue) - - # Compute the median p-value of all splits - return np.median(list_p_values_temp) - - def get_ttest_p_value(self, metric: str = 'AUC',) -> float: - """ - Calculates the p-value using the t-test for two related samples of scores. - - Args: - metric (str, optional): Metric to use for comparison. Defaults to 'AUC'. - - Returns: - float: p-value of the Delong test. - """ - - # Initialization - metric = metric.split('_')[0] if '_' in metric else metric - metrics_one_all = list() - metrics_two_all = list() - nb_split = len([x[0] for x in os.walk(self.path_experiment / f'learn__{self.experiment}_{self.levels[0]}_{self.modalities[0]}')]) - 1 - - # For each split - for i in range(1, nb_split + 1): - # Get metrics of the first and second model - metric_one, metric_two = self.__get_metrics(metric, i) - - # Add-up all information - metrics_one_all.append(metric_one) - metrics_two_all.append(metric_two) - - # Check if the number of predictions is the same - if len(metrics_one_all) != len(metrics_two_all): - raise ValueError("The number of metrics must be the same for both models") - - # Compute p-value by performing paired t-test - _, p_value = scipy.stats.ttest_rel(metrics_one_all, metrics_two_all) - - return p_value - - def get_wilcoxin_p_value(self, metric: str = 'AUC',) -> float: - """ - Calculates the p-value using the t-test for two related samples of scores. - - Args: - metric (str, optional): Metric to analyze. Defaults to 'AUC'. - - Returns: - float: p-value of the Delong test. - """ - - # Initialization - metric = metric.split('_')[0] if '_' in metric else metric - metrics_one_all = list() - metrics_two_all = list() - nb_split = len([x[0] for x in os.walk(self.path_experiment / f'learn__{self.experiment}_{self.levels[0]}_{self.modalities[0]}')]) - 1 - - # For each split - for i in range(1, nb_split + 1): - # Get metrics of the first and second model - metric_one, metric_two = self.__get_metrics(metric, i) - - # Add-up all information - metrics_one_all.append(metric_one) - metrics_two_all.append(metric_two) - - # Check if the number of predictions is the same - if len(metrics_one_all) != len(metrics_two_all): - raise ValueError("The number of metrics must be the same for both models") - - # Compute p-value by performing wilcoxon signed rank test - _, p_value = scipy.stats.wilcoxon(metrics_one_all, metrics_two_all) - - return p_value - - def get_p_value( - self, - method: str, - metric: str = 'AUC', - aggregate: bool = False - ) -> float: - """ - Calculates the p-value of the given method. - - Args: - method (str): Method to use to calculate the p-value. Available options: - - 'delong': Delong test. - - 'ttest': T-test. - - 'wilcoxon': Wilcoxon signed rank test. - - 'bengio': Bengio and Nadeau corrected t-test. - metric (str, optional): Metric to analyze. Defaults to 'AUC'. - aggregate (bool, optional): If True, aggregates the results of all the splits and computes one final p-value. - - Returns: - float: p-value of the Delong test. - """ - # Assertions - assert method in ['delong', 'ttest', 'wilcoxon', 'bengio'], \ - f'method must be either "delong", "ttest", "wilcoxon" or "bengio". Given: {method}' - - # Get p-value - if method == 'delong': - return self.get_delong_p_value(aggregate) - elif method == 'ttest': - return self.get_ttest_p_value(metric) - elif method == 'wilcoxon': - return self.get_wilcoxin_p_value(metric) - elif method == 'bengio': - return self.get_bengio_p_value() diff --git a/MEDimage/learning/__init__.py b/MEDimage/learning/__init__.py deleted file mode 100644 index 9c355f8..0000000 --- a/MEDimage/learning/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from . import * -from .cleaning_utils import * -from .DataCleaner import DataCleaner -from .DesignExperiment import DesignExperiment -from .FSR import FSR -from .ml_utils import * -from .Normalization import Normalization -from .RadiomicsLearner import RadiomicsLearner -from .Results import Results -from .Stats import Stats diff --git a/MEDimage/learning/cleaning_utils.py b/MEDimage/learning/cleaning_utils.py deleted file mode 100644 index 58d4708..0000000 --- a/MEDimage/learning/cleaning_utils.py +++ /dev/null @@ -1,107 +0,0 @@ -import random -from typing import List - -import numpy as np -import pandas as pd - - -def check_min_n_per_cat( - variable_table: pd.DataFrame, - var_names: List[str], - min_n_per_cat: float, - type: str) -> pd.DataFrame: - """ - This Function is different from matlab, it takes the whole variable_table - and the name of the var_of_type to fit the way pandas works - - Args: - variable_table (pd.DataFrame): Table of variables. - var_names (list): List of variable names. - min_n_per_cat (float): Minimum number of observations per category. - type (str): Type of variable. - - Returns: - pd.DataFrame: Table of variables with categories under ``min_n_per_cat``. - """ - - for name in var_names: - table = variable_table[var_names] - cats = pd.Categorical(table[name]).categories - for cat in cats: - flag_cat = (table == cat) - if sum(flag_cat[name]) < min_n_per_cat: - if type == 'hcategorical': - table.mask(flag_cat, np.nan) - if type == 'icategorical': - table.mask(flag_cat, '') - variable_table[var_names] = table - - return variable_table - -def check_max_percent_cat(variable_table, var_names, max_percent_cat) -> pd.Series: - """ - This Function is different from matlab, it takes the whole variable_table - and the name of the var_of_type to fit the way pandas works - - Args: - variable_table (pd.DataFrame): Table of variables. - var_names (list): List of variable names. - max_percent_cat (float): Maximum number of observations per category. - - Returns: - pd.DataFrame: Table of variables with categories over ``max_percent_cat``. - """ - - n_observation = variable_table.shape[0] - flag_var_out = pd.Series(np.zeros(var_names.size, dtype=bool)) - n = 0 - for name in var_names: - cats = pd.Categorical(variable_table[name]).categories - for cat in cats: - if (variable_table[name] == cat).sum()/n_observation > max_percent_cat: - flag_var_out[n] = True - break - n += 1 - return flag_var_out - -def one_hot_encode_table(variable_table: pd.DataFrame) -> pd.DataFrame: - """ - Converts a table of categorical variables into a table of one-hot encoded variables. - - Args: - variable_table (pd.DataFrame): Table of variables. - - Returns: - variable_table (pd.DataFrame): Table of variables with one-hot encoded variables. - """ - - #INITIALIZATION - var_icat = variable_table.Properties['userData']['variables']['icategorical'] - n_var_icat = var_icat.size - if n_var_icat == 0: - return variable_table - - # ONE-HOT ENCODING - for var_name in var_icat: - categories = variable_table[var_name].unique() - categories = np.asarray(list(filter(lambda v: v == v, categories))) # get rid of nan - categories.sort() - n_categories = categories.size - name_encoded = [] - position_to_add = variable_table.columns.get_loc(var_name)+1 - if n_categories == 2: - n_categories = 1 - for c in range(n_categories): - cat = categories[c] - new_name = f"{var_name}__{cat}" - data_to_add = (variable_table[var_name] == cat).astype(int) - variable_table.insert(loc=position_to_add, column=new_name, value=data_to_add) - name_encoded.append(new_name) - variable_table.Properties['userData']['variables']["one_hot"] = dict() - variable_table.Properties['userData']['variables']["one_hot"][var_name] = name_encoded - variable_table = variable_table.drop(var_name, axis=1) - - # UPDATING THE VARIABLE TYPES - variable_table.Properties['userData']['variables']["icategorical"] = np.array([]) - variable_table.Properties['userData']['variables']["hcategorical"] = np.append([], name_encoded) - return variable_table diff --git a/MEDimage/learning/ml_utils.py b/MEDimage/learning/ml_utils.py deleted file mode 100644 index edaa744..0000000 --- a/MEDimage/learning/ml_utils.py +++ /dev/null @@ -1,1015 +0,0 @@ -import csv -import json -import os -import pickle -import re -import string -from copy import deepcopy -from pathlib import Path -from typing import Dict, List, Tuple, Union - -import matplotlib.pyplot as plt -import numpy as np -import pandas -import pandas as pd -import seaborn as sns -from numpyencoder import NumpyEncoder -from sklearn.model_selection import StratifiedKFold - -from MEDimage.utils import get_institutions_from_ids -from MEDimage.utils.get_full_rad_names import get_full_rad_names -from MEDimage.utils.json_utils import load_json, save_json - - -# Define useful constants -# Metrics to process -list_metrics = [ - 'AUC', 'AUPRC', 'BAC', 'Sensitivity', 'Specificity', - 'Precision', 'NPV', 'F1_score', 'Accuracy', 'MCC', - 'TN', 'FP', 'FN', 'TP' -] - -def average_results(path_results: Path, save: bool = False) -> None: - """ - Averages the results (AUC, BAC, Sensitivity and Specifity) of all the runs of the same experiment, - for training, testing and holdout sets. - - Args: - path_results(Path): path to the folder containing the results of the experiment. - save (bool, optional): If True, saves the results in the same folder as the model. - - Returns: - None. - """ - # Get all tests paths - list_path_tests = [path for path in path_results.iterdir() if path.is_dir()] - - # Initialize dictionaries - results_avg = { - 'train': {}, - 'test': {}, - 'holdout': {} - } - - # Metrics to process - metrics = ['AUC', 'AUPRC', 'BAC', 'Sensitivity', 'Specificity', - 'Precision', 'NPV', 'F1_score', 'Accuracy', 'MCC', - 'TN', 'FP', 'FN', 'TP'] - - # Process metrics - for dataset in ['train', 'test', 'holdout']: - dataset_dict = results_avg[dataset] - for metric in metrics: - metric_values = [] - for path_test in list_path_tests: - results_dict = load_json(path_test / 'run_results.json') - if dataset in results_dict[list(results_dict.keys())[0]].keys(): - if 'metrics' in results_dict[list(results_dict.keys())[0]][dataset].keys(): - metric_values.append(results_dict[list(results_dict.keys())[0]][dataset]['metrics'][metric]) - else: - continue - else: - continue - - # Fill the dictionary - if metric_values: - dataset_dict[f'{metric}_mean'] = np.nanmean(metric_values) - dataset_dict[f'{metric}_std'] = np.nanstd(metric_values) - dataset_dict[f'{metric}_max'] = np.nanmax(metric_values) - dataset_dict[f'{metric}_min'] = np.nanmin(metric_values) - dataset_dict[f'{metric}_2.5%'] = np.nanpercentile(metric_values, 2.5) - dataset_dict[f'{metric}_97.5%'] = np.nanpercentile(metric_values, 97.5) - - # Save the results - if save: - save_json(path_results / 'results_avg.json', results_avg, cls=NumpyEncoder) - return path_results / 'results_avg.json' - - return results_avg - -def combine_rad_tables(rad_tables: List) -> pd.DataFrame: - """ - Combines a list of radiomics tables into one single table. - - Args: - rad_tables (List): List of radiomics tables. - - Returns: - pd.DataFrame: Single combined radiomics table. - """ - # Initialization - n_tables = len(rad_tables) - - base_idx = 0 - for idx, table in enumerate(rad_tables): - if not table.empty: - base_idx = idx - break - # Finding patient intersection - for t in range(n_tables): - if rad_tables[t].shape[1] > 0 and t != base_idx: - rad_tables[base_idx], rad_tables[t] = intersect_var_tables(rad_tables[base_idx], rad_tables[t]) - - # Check for NaNs - '''for table in rad_tables: - assert(table.isna().sum().sum() == 0)''' - - # Initializing the radiomics table template - radiomics_table = pd.DataFrame() - radiomics_table.Properties = {} - radiomics_table._metadata += ['Properties'] - radiomics_table.Properties['userData'] = {} - radiomics_table.Properties['VariableNames'] = [] - radiomics_table.Properties['userData']['normalization'] = {} - - # Combining radiomics table one by one - count = 0 - continuous = [] - str_names = '||' - for t in range(n_tables): - rad_table_id = 'radTab' + str(t+1) - if rad_tables[t].shape[1] > 0 and rad_tables[t].shape[0] > 0: - features = rad_tables[t].columns.values - description = rad_tables[t].Properties['Description'] - full_rad_names = get_full_rad_names(rad_tables[t].Properties['userData']['variables']['var_def'], - features) - if 'normalization' in rad_tables[t].Properties['userData']: - radiomics_table.Properties['userData']['normalization'][rad_table_id] = rad_tables[t].Properties[ - 'userData']['normalization'] - for f, feature in enumerate(features): - count += 1 - var_name = 'radVar' + str(count) - radiomics_table[var_name] = rad_tables[t][feature] - radiomics_table.Properties['VariableNames'].append(var_name) - continuous.append(var_name) - if description: - str_names += 'radVar' + str(count) + ':' + description + '___' + full_rad_names[f] + '||' - else: - str_names += 'radVar' + str(count) + ':' + full_rad_names[f] + '||' - - # Updating the radiomics table properties - radiomics_table.Properties['Description'] = '' - radiomics_table.Properties['DimensionNames'] = ['PatientID'] - radiomics_table.Properties['userData']['variables'] = {} - radiomics_table.Properties['userData']['variables']['var_def'] = str_names - radiomics_table.Properties['userData']['variables']['continuous'] = continuous - - return radiomics_table - -def combine_tables_from_list(var_list: List, combination: List) -> pd.DataFrame: - """ - Concatenates all variable tables in ``var_list`` according to ``var_ids``. - - Unlike ``combine_rad_tables`` This method concatenates variable tables instead of creating a new table from - the intersection of the tables. - - Args: - var_list (List): List of tables. Each key is a given var_id and holds a radiomic table. - --> Ex: .var1: variable table 1 - .var2: variable table 2 - .var3: variable table 3 - combination (list): List of strings to identify the table to combine in var_list. - --> Ex: {'var1','var3'} - - Returns: - pd.DataFrame: variable_table: Combined radiomics table. - """ - def concatenate_varid(var_names, var_id): - return np.asarray([var_id + "__" + var_name for var_name in var_names.tolist()]) - - # Initialization - variables = dict() - variables['continuous'] = np.array([]) - variable_tables = list() - - # Using the first table as template - var_id = combination[0] - variable_table = deepcopy(var_list[var_id]) # first table from the list - variable_table.Properties = deepcopy(var_list[var_id].Properties) - new_columns = [var_id + '__' + col for col in variable_table.columns] - variable_table.columns = new_columns - variable_table.Properties['VariableNames'] = new_columns - variable_table.Properties['userData'] = dict() # Re-Initializing - variable_table.Properties['userData'][var_id] = deepcopy(var_list[var_id].Properties['userData']) - variables['continuous'] = np.concatenate((variables['continuous'], var_list[var_id].Properties[ - 'userData']['variables']['continuous'])) - variable_tables.append(variable_table) - - # Concatenating all other tables - for var_id in combination[1:]: - variable_table.Properties['userData'][var_id] = var_list[var_id].Properties['userData'] - patient_ids = intersect(list(variable_table.index), (var_list[var_id].index)) - var_list[var_id] = var_list[var_id].loc[patient_ids] - variable_table = variable_table.loc[patient_ids] - old_columns = list(variable_table.columns) - old_properties = deepcopy(variable_table.Properties) # for unknown reason Properties are erased after concat - variable_table = pd.concat([variable_table, var_list[var_id]], axis=1) - variable_table.columns = old_columns + [var_id + "__" + col for col in var_list[var_id].columns] - variable_table.Properties = old_properties - variable_table.Properties['VariableNames'] = list(variable_table.columns) - variables['continuous'] = np.concatenate((variables['continuous'], var_list[var_id].Properties['userData']['variables']['continuous'])) - - # Updating the radiomics table properties - variable_table.Properties['Description'] = "Data table" - variables['continuous'] = concatenate_varid(variables['continuous'], var_id) - variable_table.Properties['userData']['variables'] = variables - - return variable_table - -def convert_comibnations_to_list(combinations_string: str) -> Tuple[List, List]: - """ - Converts a cell of strings specifying variable ids combinations to - a cell of cells of strings. - - Args: - combinations_string (str): Cell of strings specifying var_ids combinations - separated by underscores. - --> Ex: {'var1_var2';'var2_var3';'var1_var2_var3'} - - Rerturs: - - List: List of strings of the seperated var_ids. - --> Ex: {{'var1','var2'};{'var2','var3'};{'var1','var2','var3'}} - - List: List of strings specifying the "alphabetical" IDs of combined variables - in ``combinations``. var1 --> A, var2 -> B, etc. - --> Ex: {'model_AB';'model_BC';'model_ABC'} - """ - # Building combinations - combinations = [s.split('_') for s in combinations_string] - - # Building model_ids - alphabet = string.ascii_uppercase - model_ids = list() - for combination in combinations: - model_ids.append('model_' + ''.join([alphabet[int(var[3:])-1] for var in combination])) - - return combinations, model_ids - -def count_class_imbalance(path_csv_outcomes: Path) -> Dict: - """ - Counts the class imbalance in a given outcome table. - - Args: - path_csv_outcomes (Path): Path to the outcome table. - - Returns: - Dict: Dictionary containing the count of each class. - """ - # Initialization - outcomes = pandas.read_csv(path_csv_outcomes, sep=',') - outcomes.dropna(inplace=True) - outcomes.reset_index(inplace=True, drop=True) - name_outcome = outcomes.columns[-1] - - # Counting the percentage of each class - class_0_perc = np.sum(outcomes[name_outcome] == 0) / len(outcomes) - class_1_perc = np.sum(outcomes[name_outcome] == 1) / len(outcomes) - - return {'class_0': class_0_perc, 'class_1': class_1_perc} - -def create_experiment_folder(path_outcome_folder: str, method: str = 'Random') -> str: - """ - Creates the experiment folder where the hold-out splits will be saved and returns the path - to the folder. - - Args: - path_outcome_folder (str): Full path to the outcome folder (folder containing the outcome table etc). - method (str): String specifying the split type. Default is 'Random'. - - Returns: - str: Full path to the experiment folder. - """ - - # Creating the outcome folder if it does not exist - if not os.path.isdir(path_outcome_folder): - os.makedirs(path_outcome_folder) - - # Creating the experiment folder if it does not exist - list_outcome = os.listdir(path_outcome_folder) - if not list_outcome: - flag_exist_split = False - else: - n_exist = 0 - flag_exist_split = False - for i in range(len(list_outcome)): - if 'holdOut__' + method + '__' in list_outcome[i]: - n_exist = n_exist + 1 - flag_exist_split = True - - # If path experiment folder exists already, create a new one (sequentially) - if not flag_exist_split: - path_split = str(path_outcome_folder) + '/holdOut__' + method + '__001' - else: - path_split = str(path_outcome_folder) + '/holdOut__' + method + '__' + \ - str(n_exist+1).zfill(3) - - os.mkdir(path_split) - return path_split - -def create_holdout_set( - path_outcome_file: Union[str, Path], - outcome_name: str, - path_save_experiments: Union[str, Path] = None, - method: str = 'random', - percentage: float = 0.2, - n_split: int = 1, - seed : int = 1) -> None: - """ - Creates a hold-out patient set to be used for final independent testing after a final - model is chosen. All the information is saved in a JSON file. - - Args: - path_outcome_file (str): Full path to where the outcome CSV file is stored. - outcome_name (str): Name of the outcome. For example, 'OS' for overral survivor. - path_save_experiments (str): Full path to the folder where the experiments - will be saved. - method (str): Method to use for creating the hold-out set. Options are: - - 'random': Randomly selects patients for the hold-out set. - - 'all_learn': No hold-out set is created. All patients are used for learning. - - 'institution': TODO. - percentage (float): Percentage of patients to use for the hold-out set. Default is 0.2. - n_split (int): Number of splits to create. Default is 1. - seed (int): Seed to use for the random split. Default is 1. - - Returns: - None. - """ - # Initilization - outcome_name = outcome_name.upper() - outcome_table = pandas.read_csv(path_outcome_file, sep=',') - outcome_table.dropna(inplace=True) - outcome_table.reset_index(inplace=True, drop=True) - patient_ids = outcome_table['PatientID'] - - # Creating experiment folders and patient test split(s) - outcome_name = re.sub(r'\W', "", outcome_name) - path_outcome = str(path_save_experiments) + '/' + outcome_name - name_outcome_in_table_binary = outcome_name + '_binary' - - # Column names in the outcome table - with open(path_outcome_file, 'r') as infile: - reader = csv.DictReader(infile, delimiter=',') - var_names = reader.fieldnames - - # Include time to event if it exists - flag_time = False - if(outcome_name + '_eventFreeTime' in str(var_names)): - name_outcome_in_table_time = outcome_name + '_eventFreeTime' - flag_time = True - - # Check if the outcome name for binary is correct - if name_outcome_in_table_binary not in outcome_table.columns: - name_outcome_in_table_binary = var_names[-1] - - # Run the split - # Random - if 'random' in method.lower(): - # Creating the experiment folder - path_split = create_experiment_folder(path_outcome, 'random') - - # Getting the random split - patients_learn_temp, patients_hold_out_temp = get_stratified_splits( - outcome_table[['PatientID', name_outcome_in_table_binary]], - n_split, percentage, seed, False) - - # Getting the patient IDs in the learning and hold-out sets - if n_split > 1: - patients_learn = np.empty((n_split, len(patients_learn_temp[0])), dtype=object) - patients_hold_out = np.empty((n_split, len(patients_hold_out_temp[0])), dtype=object) - for s in range(n_split): - patients_learn[s] = patient_ids[patients_learn_temp[s]] - patients_hold_out[s] = patient_ids[patients_hold_out_temp[s]] - else: - patients_learn = patient_ids[patients_learn_temp.values.tolist()] - patients_learn.reset_index(inplace=True, drop=True) - patients_hold_out = patient_ids[patients_hold_out_temp.values.tolist()] - patients_hold_out.reset_index(inplace=True, drop=True) - - # All Learn - elif 'all_learn' in method.lower(): - # Creating the experiment folder - path_split = create_experiment_folder(path_outcome, 'all_learn') - - # Getting the split (all Learn so no hold out) - patients_learn = patient_ids - patients_hold_out = [] - else: - raise ValueError('Method not recognized. Use "random" or "all_learn".') - - # Creating final outcome table and saving it - if flag_time: - outcomes = outcome_table[ - ['PatientID', name_outcome_in_table_binary, name_outcome_in_table_time]] - else: - outcomes = outcome_table[['PatientID', name_outcome_in_table_binary]] - - # Finalize the outcome table - outcomes = outcomes.dropna(inplace=False) # Drop NaNs - outcomes.reset_index(inplace=True, drop=True) # Reset index - - # Save the outcome table - paths_exp_outcomes = str(path_split + '/outcomes.csv') - outcomes.to_csv(paths_exp_outcomes, index=False) - - # Save dict of patientsLearn - paths_exp_patientsLearn = str(path_split) + '/patientsLearn.json' - patients_learn.to_json(paths_exp_patientsLearn, orient='values', indent=4) - - # Save dict of patientsHoldOut - if method == 'random': - paths_exp_patients_hold_out = str(path_split) + '/patientsHoldOut.json' - patients_hold_out.to_json(paths_exp_patients_hold_out, orient='values', indent=4) - - # Save dict of all the paths - data={ - "outcomes" : paths_exp_outcomes, - "patientsLearn": paths_exp_patientsLearn, - "patientsHoldOut": paths_exp_patients_hold_out, - "pathWORK": path_split - } - else: - data={ - "outcomes" : paths_exp_outcomes, - "patientsLearn": paths_exp_patientsLearn, - "pathWORK": path_split - } - paths_exp = str(path_split + '/paths_exp.json') - with open(paths_exp, 'w') as f: - json.dump(data, f, indent=4) - - # Return the path to the experiment and path to split - return path_split, paths_exp - -def cross_validation_split( - outcome: List[Union[int, float]], - n_splits: int = 5, - seed: int = None - ) -> Tuple[List[List[int]], List[List[int]]]: - """ - Perform stratified cross-validation split. - - Args: - outcome (list): Outcome variable (binary). - n_splits (int, optional): Number of folds. Default is 5. - seed (int or None, optional): Random seed for reproducibility. Default is None. - - Returns: - train_indices_list (list of lists): List of training indices for each fold. - test_indices_list (list of lists): List of testing indices for each fold. - """ - - skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed) - train_data_list = [] - test_data_list = [] - patient_ids = pd.Series(outcome.index) - - for train_indices, test_indices in skf.split(X=outcome, y=outcome): - train_data_list.append(patient_ids[train_indices]) - test_data_list.append(patient_ids[test_indices]) - - train_data_array = np.array(train_data_list, dtype=object) - test_data_array = np.array(test_data_list, dtype=object) - - return train_data_array, test_data_array - -def find_best_model(path_results: Path, metric: str = 'AUC', second_metric: str = 'AUC') -> Tuple[Dict, Path]: - """ - Find the best model with the highest performance on the test set - in a given path based on a given metric. - - Args: - path_results (Path): Path to the results folder. - metric (str): Metric to use to find the best model in case of a tie. Default is 'AUC'. - - Returns: - Tuple[Dict, Path]: Tuple containing the best model result dict and the path to the best model. - """ - list_metrics = [ - 'AUC', 'Sensitivity', 'Specificity', - 'BAC', 'AUPRC', 'Precision', - 'NPV', 'Accuracy', 'F1_score', 'MCC', - 'TP', 'TN', 'FP', 'FN' - ] - assert metric in list_metrics, f'Given metric {metric} is not in the list of metrics. Please choose from {list_metrics}' - - # Get all tests paths - list_path_tests = [path for path in path_results.iterdir() if path.is_dir()] - - # Initialization - metric_best = -1 - second_metric_best = -1 - path_result_best = None - - # Get all models and their metrics (AUC especially) - for path_test in list_path_tests: - if not (path_test / 'run_results.json').exists(): - continue - results_dict = load_json(path_test / 'run_results.json') - metric_test = results_dict[list(results_dict.keys())[0]]['test']['metrics'][metric] - if metric_test > metric_best: - metric_best = metric_test - path_result_best = path_test - elif metric_test == metric_best: - second_metric_test = results_dict[list(results_dict.keys())[0]]['test']['metrics'][second_metric] - if second_metric_test > second_metric_best: - second_metric_best = second_metric_test - path_result_best = path_test - - # Load best model result dict - results_dict_best = load_json(path_result_best / 'run_results.json') - - # Load model - model_name = list(results_dict_best.keys())[0] - with open(path_result_best / f'{model_name}.pickle', 'rb') as file: - model = pickle.load(file) - - return model, results_dict_best - -def feature_imporance_analysis(path_results: Path): - """ - Averages the results (AUC, BAC, Sensitivity and Specifity) of all the runs of the same experiment, - for training, testing and holdout sets. - - Args: - path_results(Path): path to the folder containing the results of the experiment. - save (bool, optional): If True, saves the results in the same folder as the model. - - Returns: - None. - """ - # Get all tests paths - list_path_tests = [path for path in path_results.iterdir() if path.is_dir()] - - # Initialization - results_avg_temp = {} - results_avg = {} - - # Process metrics - for path_test in list_path_tests: - variables = [] - list_models = list(path_test.glob('*.pickle')) - if len(list_models) == 0 or len(list_models) > 1: - raise ValueError(f'Path {path_test} does not contain a single model.') - model_obj = list_models[0] - with open(model_obj, "rb") as f: - model_dict = pickle.load(f) - if model_dict["var_names"]: - variables = get_full_rad_names(model_dict['var_info']['variables']['var_def'], model_dict["var_names"]) - for index, var in enumerate(variables): - var = var.split("\\")[-1] # Remove the path for windows - var = var.split("/")[-1] # Remove the path for linux - if var not in results_avg_temp: - results_avg_temp[var] = { - 'importance_mean': [], - 'times_selected': 0 - } - - results_avg_temp[var]['importance_mean'].append(model_dict['model'].feature_importances_[index]) - results_avg_temp[var]['times_selected'] += 1 - for var in results_avg_temp: - results_avg[var] = { - 'importance_mean': np.sum(results_avg_temp[var]['importance_mean']) / len(list_path_tests), - 'times_selected': results_avg_temp[var]['times_selected'] - } - - del results_avg_temp - - save_json(path_results / 'feature_importance_analysis.json', results_avg, cls=NumpyEncoder) - -def get_ml_test_table(variable_table: pd.DataFrame, var_names: List, var_def: str) -> pd.DataFrame: - """ - Gets the test table with the variables that are present in the training table. - - Args: - variable_table (pd.DataFrame): Table with the variables to use for the ML model that - will be matched with the training table. - var_names (List): List of variable names used for the ML model . - var_def (str): String of the full variables names used for the ML model. - - Returns: - pd.DataFrame: Table with the variables that are present in the training table. - """ - - # Get the full variable names for training - full_radvar_names_trained = get_full_rad_names(var_def, var_names).tolist() - - # Get the full variable names for testing - full_rad_var_names_test = get_full_rad_names( - variable_table.Properties['userData']['variables']['var_def'], - variable_table.columns.values - ).tolist() - - # Get the indexes of the variables that are present in the training table - indexes = [] - for radvar in full_radvar_names_trained: - try: - indexes.append(full_rad_var_names_test.index(radvar)) - except ValueError as e: - print(e) - raise ValueError('The variable ' + radvar + ' is not present in the test table.') - - # Get the test table with the variables that are present in the training table - variable_table = variable_table.iloc[:, indexes] - - # User data - var_def - str_names = '||' - for v in range(len(var_names)): - str_names += var_names[v] + ':' + full_radvar_names_trained[v] + '||' - - # Update metadata and variable names - variable_table.columns = var_names - variable_table.Properties['VariableNames'] = var_names - variable_table.Properties['userData']['variables']['var_def'] = str_names - variable_table.Properties['userData']['variables']['continuous'] = var_names - - # Rename columns to s sequential names again - return variable_table - -def finalize_rad_table(rad_table: pd.DataFrame) -> pd.DataFrame: - """ - Finalizes the variable names and the associated metadata. Used to have sequential variable - names and UserData with only variable names present in the table. - - Args: - rad_table (pd.DataFrame): radiomics table to be finalized. - - Returns: - pd.DataFrame: Finalized radiomics table. - """ - - # Initialization - var_names = rad_table.columns.values - full_rad_names = get_full_rad_names(rad_table.Properties['userData']['variables']['var_def'], var_names) - - # User data - var_def - str_names = '||' - for v in range(var_names.size): - var_names[v] = 'radVar' + str(v+1) - str_names = str_names + var_names[v] + ':' + full_rad_names[v] + '||' - - # Update metadata and variable names - rad_table.columns = var_names - rad_table.Properties['VariableNames'] = var_names - rad_table.Properties['userData']['variables']['var_def'] = str_names - rad_table.Properties['userData']['variables']['continuous'] = var_names - - return rad_table - -def get_radiomics_table( - path_radiomics_csv: Path, - path_radiomics_txt: Path, - image_type: str, - patients_ids: List = None - ) -> pd.DataFrame: - """ - Loads the radiomics table from the .csv file and the associated metadata. - - Args: - path_radiomics_csv (Path): full path to the csv file of radiomics table. - --> Ex: /home/myStudy/FEATURES/radiomics__PET(GTV)__image.csv - path_radiomics_txt: full path to the radiomics variable definitions in text format (associated - to path_radiomics_csv). - -> Ex: /home/myStudy/FEATURES/radiomics__PET(GTV)__image.txt - image_type (str): String specifying the type of image on which the radiomics - features were computed. - --> Format: $scan$($roiType$)__$imSpace$ - --> Ex: PET(tumor)__HHH_coif1 - patients_ids (list, optional): List of strings specifying the patientIDs of - patients to fetch from the radiomics table. If this - argument is not present, all patients are fetched. - --> Ex: {'Cervix-UCSF-001';Cervix-McGill-004} - - Returns: - pd.DataFrame: radiomics table - """ - # Read CSV table - radiomics_table = pd.read_csv(path_radiomics_csv, index_col=0) - if patients_ids is not None: - patients_ids = intersect(patients_ids, list(radiomics_table.index)) - radiomics_table = radiomics_table.loc[patients_ids] - - # Read the associated TXT file - with open(path_radiomics_txt, 'r') as f: - user_data = f.read() - - # Grouping the information - radiomics_table._metadata += ["Properties"] - radiomics_table.Properties = dict() - radiomics_table.Properties['userData'] = dict() - radiomics_table.Properties['userData']['variables'] = dict() - radiomics_table.Properties['userData']['variables']['var_def'] = user_data - radiomics_table.Properties['Description'] = image_type - - # Only continuous will be used for now but this design will facilitate the use of - # other categories in the future. - # radiomics = continous. - radiomics_table.Properties['userData']['variables']['continuous'] = np.asarray(list(radiomics_table.columns.values)) - - return radiomics_table - -def get_splits(outcome: pd.DataFrame, n_split: int, test_split_proportion: float) -> Tuple[List, List]: - """ - Splits the given outcome table in two sets. - - Args: - outcome (pd.DataFrame): Table with a single outcome column of 0's and 1's. - n_splits (int): Integer specifying the number of splits to create. - test_split_proportion (float): Float between 0 and 1 specifying the proportion - of patients to include in the test set. - - Returns: - train_sets List of indexes for the train_sets. - test_sets: List of indexes for the test_sets. - - """ - - ind_neg = np.where(outcome == 0) - n_neg = len(ind_neg[0]) - ind_pos = np.where(outcome == 1) - n_pos = len(ind_pos[0]) - n_neg_test = round(test_split_proportion * n_neg) - n_pos_test = round(test_split_proportion * n_pos) - - n_inst = len(outcome) - n_test = n_pos_test + n_neg_test - n_train = n_inst - n_test - - if(n_split==1): - train_sets = np.zeros(n_train) - test_sets = np.zeros(n_test) - else: - train_sets = np.zeros((n_split, n_train)) - test_sets = np.zeros((n_split, n_test)) - - for s in range(n_split): - ind_pos_test = np.random.choice(ind_pos[0], n_pos_test, replace=False) - ind_neg_test = np.random.choice(ind_neg[0], n_neg_test, replace=False) - - ind_test = np.concatenate((ind_pos_test,ind_neg_test)) - ind_test.sort() - - ind_train = np.arange(n_inst) - ind_train = np.delete(ind_train, ind_test) - ind_train.sort() - - if(n_split>1): - train_sets[s] = ind_train - test_sets[s] = ind_test - else: - train_sets = ind_train - test_sets = ind_test - - return train_sets, test_sets - -def get_stratified_splits( - outcome_table: pd.DataFrame, - n_splits: int, - test_split_proportion: float, - seed: int, - flag_by_cat: bool=False - ) -> Tuple[List, List]: - """ - Sub-divides a given outcome dataset into multiple stratified patient splits. - The stratification is performed per class proportion (or by institution). - - Args: - outcome_table: Table with a single outcome column of 0's and 1's. - The rows of the table must define the patient IDs: $Cancer-$Institution-$Number. - n_splits: Integer specifying the number of splits to create. - test_split_proportion: Float between 0 and 1 specifying the proportion - of patients to include in the test set. - seed: Integer specifying the random generator seed to use for random splitting. - flag_by_cat (optional): Logical flag specifying if we are to produce - the split by taking into account the institutions in the outcome table. - If true, patients in Training and testing splits have the same prortion - of events per instiution as originally found in the initial data. Default: False. - - Returns: - List: patients_train_splits, list of size nTrainXnSplit, where each entry - is a string specifying a "Training" patient. - List: patients_test_splits, list of size nTestXnSplit, where each entry - is a string specifying a "testing" patient - """ - patient_ids = pd.Series(outcome_table.index) - patients_train_splits = [] - patients_test_splits = [] - - # Take into account the institutions in the outcome table - if flag_by_cat: - institution_cat_vector = get_institutions_from_ids(patient_ids) - all_categories = np.unique(institution_cat_vector) - n_cat = len(all_categories) - # Split for each institution - for i in range(n_cat): - np.random.seed(seed) - cat = all_categories[i] - flag_cat = institution_cat_vector == cat - patient_ids_cat = patient_ids[flag_cat] - patient_ids_cat.reset_index(inplace=True, drop=True) - - # Split train and test sets - train_sets, test_sets = get_splits(outcome_table[flag_cat.values], n_splits, test_split_proportion) - - if n_splits > 1: - temp_patients_train = np.empty((n_splits, len(train_sets[0])), dtype=object) - temp_patientsTest = np.empty((n_splits, len(test_sets[0])), dtype=object) - for s in range(n_splits): - temp_patients_train[s] = patient_ids_cat[train_sets[s]] - temp_patientsTest[s] = patient_ids_cat[test_sets[s]] - else: - temp_patients_train = patient_ids_cat[train_sets] - temp_patients_train.reset_index(inplace=True, drop=True) - temp_patientsTest = patient_ids_cat[test_sets] - temp_patientsTest.reset_index(inplace=True, drop=True) - - # Initialize the train and test patients list (1st iteration) - if i==0: - patients_train_splits=temp_patients_train - patients_test_splits=temp_patientsTest - - # Add new patients to the train and test patients list (other iterations) - if i>0: - if n_splits>1: - patients_train_splits = np.append(patients_train_splits, temp_patients_train, axis=1) - patients_test_splits = np.append(patients_test_splits, temp_patientsTest, axis=1) - - else: - patients_train_splits = np.append(patients_train_splits, temp_patients_train) - patients_test_splits = np.append(patients_test_splits, temp_patientsTest) - - # Do not take into account the institutions in the outcome table - else: - # Split train and test sets - train_sets, test_sets = get_splits(outcome_table, n_splits, test_split_proportion) - if n_splits > 1: - patients_train_splits = np.empty((n_splits, len(train_sets[0])), dtype=object) - patients_test_splits = np.empty((n_splits, len(test_sets[0])), dtype=object) - for s in range(n_splits): - patients_train_splits[s] = patient_ids[train_sets[s]] - patients_test_splits[s] = patient_ids[test_sets[s]] - else: - patients_train_splits = patient_ids[train_sets] - patients_train_splits.reset_index(inplace=True, drop=True) - patients_test_splits = patient_ids[test_sets] - patients_test_splits.reset_index(inplace=True, drop=True) - - return patients_train_splits, patients_test_splits - -def get_patient_id_classes(outcome_table: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]: - """ - Yields the patients from the majority class and the minority class in the given outcome table. - Only supports binary classes. - - Args: - outcome_table(pd.DataFrame): outcome table with binary labels. - - Returns: - pd.DataFrame: Majority class patientIDs. - pd.DataFrame: Minority class patientIDs. - """ - ones = outcome_table.loc[outcome_table.iloc[0:].values == 1].index - zeros = outcome_table.loc[outcome_table.iloc[0:].values == 0].index - if ones.size > zeros.size: - return ones, zeros - - return zeros, ones - -def intersect(list1: List, list2: List, sort: bool = False) -> List: - """ - Returns the intersection of two list. - - Args: - list1 (List): the first list. - list2 (List): the second list. - order (bool): if True, the intersection is sorted. - - Returns: - List: the intersection of the two lists. - """ - - intersection = list(filter(lambda x: x in list1, list2)) - if sort: - return sorted(intersection) - return intersection - -def intersect_var_tables(var_table1: pd.DataFrame, var_table2: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]: - """ - This function takes 2 variable table, compares the indexes and drops the - ones that are not in both, then returns the 2 table. - - Args: - var_table1 (pd.DataFrame): first variable table. - var_table2 (pd.DataFrame): second variable table. - - Returns: - pd.DataFrame: first variable table with the same indexes as the second. - pd.DataFrame: second variable table with the same indexes as the first. - """ - # Find the unique values in var_table1 that are not in var_table2 - missing = np.setdiff1d(var_table1.index.to_numpy(), var_table2.index.to_numpy()) - if missing.size > 0: - var_table1 = var_table1.drop(missing) - - # Find the unique values in var_table2 that are not in var_table1 - missing = np.setdiff1d(var_table2.index.to_numpy(), var_table1.index.to_numpy()) - if missing.size > 0: - var_table2 = var_table2.drop(missing) - - return var_table1, var_table2 - -def under_sample(outcome_table_binary: pd.DataFrame) -> pd.DataFrame: - """ - Performs under-sampling to obtain an equal number of outcomes in the binary outcome table. - - Args: - outcome_table_binary (pd.DataFrame): outcome table with binary labels. - - Returns: - pd.DataFrame: outcome table with balanced binary labels. - """ - - # We place them prematurely in maj and min and correct it afterwards - n_maj = (outcome_table_binary == 0).sum().values[0] - n_min = (outcome_table_binary == 1).sum().values[0] - if n_maj == n_min: - return outcome_table_binary - elif n_min > n_maj: - n_min, n_maj = n_maj, n_min - - # Sample the patients from the majority class - patient_ids_maj, patient_ids_min = get_patient_id_classes(outcome_table_binary) - patient_ids_min = list(patient_ids_min) - patient_ids_numpy = patient_ids_maj.to_numpy() - np.random.shuffle(patient_ids_numpy) - patient_ids_sample = list(patient_ids_numpy[0:n_min]) - new_ids = patient_ids_min + patient_ids_sample - - return outcome_table_binary.loc[new_ids, :] - -def save_model(model: Dict, var_id: str, path_model: Path, ml: Dict = None, name_type: str = "") -> Dict: - """ - Saves a given model locally as a pickle object and outputs a dictionary - containing the model's information. - - Args: - model (Dict): The model dict to save. - var_id (str): The stduied variable. For ex: 'var3'. - path_model (str): The path to save the model. - ml (Dict, optional): Dicionary containing the settings of the machine learning experiment. - name_type (str, optional): String specifying the type of the variable. For examlpe: "RadiomicsIntensity". Default is "". - - Returns: - Dict: A dictionary containing the model's information. - """ - # Saving model - with open(path_model, "wb") as f: - pickle.dump(model, f) - - # Getting the "var_names" string - if ml is not None: - var_names = ml['variables'][var_id]['nameType'] - elif name_type != "": - var_names = name_type - else: - var_names = [var_id] - - # Recording model info - model_info = dict() - model_info['path'] = path_model - model_info['var_ids'] = var_id - model_info['var_type'] = var_names - - try: # This part may fail if model training failed. - model_info['var_names'] = model['var_names'] - model_info['var_info'] = model['var_info'] - if 'normalization' in model_info['var_info'].keys(): - if 'normalization_table' in model_info['var_info']['normalization'].keys(): - normalization_struct = write_table_structure(model_info['var_info']['normalization']['normalization_table']) - model_info['var_info']['normalization']['normalization_table'] = normalization_struct - model_info['threshold'] = model['threshold'] - except Exception as e: - print("Failed to create a fully model info") - print(e) - - return model_info - -def write_table_structure(data_table: pd.DataFrame) -> Dict: - """ - Writes the structure of a table in a dictionary. - - Args: - data_table (pd.DataFrame): a table. - - Returns: - Dict: a dictionary containing the table's structure. - """ - # Initialization - data_struct = dict() - - if len(data_table.index) != 0: - data_struct['index'] = list(data_table.index) - - # Creating the structure - for column in data_table.columns: - data_struct[column] = data_table[column] - - return data_struct diff --git a/MEDimage/processing/__init__.py b/MEDimage/processing/__init__.py deleted file mode 100644 index 32514ea..0000000 --- a/MEDimage/processing/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from . import * -from .compute_suv_map import * -from .discretisation import * -from .interpolation import * -from .resegmentation import * -from .segmentation import * diff --git a/MEDimage/processing/compute_suv_map.py b/MEDimage/processing/compute_suv_map.py deleted file mode 100644 index 48e7783..0000000 --- a/MEDimage/processing/compute_suv_map.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -import numpy as np -import pydicom - - -def compute_suv_map(raw_pet: np.ndarray, - dicom_h: pydicom.Dataset) -> np.ndarray: - """Computes the suv_map of a raw input PET volume. It is assumed that - the calibration factor was applied beforehand to the PET volume. - **E.g: raw_pet = raw_pet*RescaleSlope + RescaleIntercept.** - - Args: - raw_pet (ndarray):3D array representing the PET volume in raw format. - dicom_h (pydicom.dataset.FileDataset): DICOM header of one of the - corresponding slice of ``raw_pet``. - - Returns: - ndarray: ``raw_pet`` converted to SUVs (standard uptake values). - """ - def dcm_hhmmss(date_str: str) -> float: - """"Converts to seconds - - Args: - date_str (str): date string - - Returns: - float: total seconds - """ - # Converts to seconds - if not isinstance(date_str, str): - date_str = str(date_str) - hh = float(date_str[0:2]) - mm = float(date_str[2:4]) - ss = float(date_str[4:6]) - tot_sec = hh*60.0*60.0 + mm*60.0 + ss - return tot_sec - - def pydicom_has_tag(dcm_seq, tag): - # Checks if tag exists - return get_pydicom_meta_tag(dcm_seq, tag, test_tag=True) - - def get_pydicom_meta_tag(dcm_seq, tag, tag_type=None, default=None, - test_tag=False): - # Reads dicom tag - # Initialise with default - tag_value = default - # Read from header using simple itk - try: - tag_value = dcm_seq[tag].value - except KeyError: - if test_tag: - return False - if test_tag: - return True - # Find empty entries - if tag_value is not None: - if tag_value == "": - tag_value = default - # Cast to correct type (meta tags are usually passed as strings) - if tag_value is not None: - # String - if tag_type == "str": - tag_value = str(tag_value) - # Float - elif tag_type == "float": - tag_value = float(tag_value) - # Multiple floats - elif tag_type == "mult_float": - tag_value = [float(str_num) for str_num in tag_value] - # Integer - elif tag_type == "int": - tag_value = int(tag_value) - # Multiple floats - elif tag_type == "mult_int": - tag_value = [int(str_num) for str_num in tag_value] - # Boolean - elif tag_type == "bool": - tag_value = bool(tag_value) - - return tag_value - - # Get patient weight - if pydicom_has_tag(dcm_seq=dicom_h, tag=(0x0010, 0x1030)): - weight = get_pydicom_meta_tag(dcm_seq=dicom_h, tag=(0x0010, 0x1030), - tag_type="float") * 1000.0 # in grams - else: - weight = None - if weight is None: - weight = 75000.0 # estimation - try: - # Get Scan time - scantime = dcm_hhmmss(date_str=get_pydicom_meta_tag( - dcm_seq=dicom_h, tag=(0x0008, 0x0032), tag_type="str")) - # Start Time for the Radiopharmaceutical Injection - injection_time = dcm_hhmmss(date_str=get_pydicom_meta_tag( - dcm_seq=dicom_h[0x0054, 0x0016][0], - tag=(0x0018, 0x1072), tag_type="str")) - # Half Life for Radionuclide - half_life = get_pydicom_meta_tag( - dcm_seq=dicom_h[0x0054, 0x0016][0], - tag=(0x0018, 0x1075), tag_type="float") - # Total dose injected for Radionuclide - injected_dose = get_pydicom_meta_tag( - dcm_seq=dicom_h[0x0054, 0x0016][0], - tag=(0x0018, 0x1074), tag_type="float") - # Calculate decay - decay = np.exp(-np.log(2)*(scantime-injection_time)/half_life) - # Calculate the dose decayed during procedure - injected_dose_decay = injected_dose*decay # in Bq - except KeyError: - # 90 min waiting time, 15 min preparation - decay = np.exp(-np.log(2)*(1.75*3600)/6588) - injected_dose_decay = 420000000 * decay # 420 MBq - - # Calculate SUV - suv_map = raw_pet * weight / injected_dose_decay - - return suv_map diff --git a/MEDimage/processing/discretisation.py b/MEDimage/processing/discretisation.py deleted file mode 100644 index 12376b9..0000000 --- a/MEDimage/processing/discretisation.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from copy import deepcopy -from typing import Tuple - -import numpy as np -from skimage.exposure import equalize_hist - - -def equalization(vol_re: np.ndarray) -> np.ndarray: - """Performs histogram equalisation of the ROI imaging intensities. - - Note: - This is a pure "what is contained within the roi" equalization. this is - not influenced by the :func:`user_set_min_val()` used for FBS discretisation. - - Args: - vol_re (ndarray): 3D array of the image volume that will be studied with - NaN value for the excluded voxels (voxels outside the ROI mask). - - Returns: - ndarray: Same input image volume but with redistributed intensities. - """ - - # AZ: This was made part of the function call - # n_g = 64 - # This is the default we will use. It means that when using 'FBS', - # n_q should be chosen wisely such - # that the total number of grey levels does not exceed 64, for all - # patients (recommended). - # This choice was amde by considering that the best equalization - # performance for "histeq.m" is obtained with low n_g. - # WARNING: The effective number of grey levels coming out of "histeq.m" - # may be lower than n_g. - - # CONSERVE THE INDICES OF THE ROI - x_gl = np.ravel(vol_re) - ind_roi = np.where(~np.isnan(vol_re)) - x_gl = x_gl[~np.isnan(x_gl)] - - # ADJUST RANGE BETWEEN 0 and 1 - min_val = np.min(x_gl) - max_val = np.max(x_gl) - x_gl_01 = (x_gl - min_val)/(max_val - min_val) - - # EQUALIZATION - # x_gl_equal = equalize_hist(x_gl_01, nbins=n_g) - # AT THE MOMENT, WE CHOOSE TO USE THE DEFAULT NUMBER OF BINS OF - # equalize_hist.py (256) - x_gl_equal = equalize_hist(x_gl_01) - # RE-ADJUST TO CORRECT RANGE - x_gl_equal = (x_gl_equal - np.min(x_gl_equal)) / \ - (np.max(x_gl_equal) - np.min(x_gl_equal)) - x_gl_equal = x_gl_equal * (max_val - min_val) - x_gl_equal = x_gl_equal + min_val - - # RECONSTRUCT THE VOLUME WITH EQUALIZED VALUES - vol_equal_re = deepcopy(vol_re) - - vol_equal_re[ind_roi] = x_gl_equal - - return vol_equal_re - -def discretize(vol_re: np.ndarray, - discr_type: str, - n_q: float=None, - user_set_min_val: float=None, - ivh=False) -> Tuple[np.ndarray, float]: - """Quantisizes the image intensities inside the ROI. - - Note: - For 'FBS' type, it is assumed that re-segmentation with - proper range was already performed - - Args: - vol_re (ndarray): 3D array of the image volume that will be studied with - NaN value for the excluded voxels (voxels outside the ROI mask). - discr_type (str): Discretisaion approach/type must be: "FBS", "FBN", "FBSequal" - or "FBNequal". - n_q (float): Number of bins for FBS algorithm and bin width for FBN algorithm. - user_set_min_val (float): Minimum of range re-segmentation for FBS discretisation, - for FBN discretisation, this value has no importance as an argument - and will not be used. - ivh (bool): Must be set to True for IVH (Intensity-Volume histogram) features. - - Returns: - 2-element tuple containing - - - ndarray: Same input image volume but with discretised intensities. - - float: bin width. - """ - - # AZ: NOTE: the "type" variable that appeared in the MATLAB source code - # matches the name of a standard python function. I have therefore renamed - # this variable "discr_type" - - # PARSING ARGUMENTS - vol_quant_re = deepcopy(vol_re) - - if n_q is None: - return None - - if not isinstance(n_q, float): - n_q = float(n_q) - - if discr_type not in ["FBS", "FBN", "FBSequal", "FBNequal"]: - raise ValueError( - "discr_type must either be \"FBS\", \"FBN\", \"FBSequal\" or \"FBNequal\".") - - # DISCRETISATION - if discr_type in ["FBS", "FBSequal"]: - if user_set_min_val is not None: - min_val = deepcopy(user_set_min_val) - else: - min_val = np.nanmin(vol_quant_re) - else: - min_val = np.nanmin(vol_quant_re) - - max_val = np.nanmax(vol_quant_re) - - if discr_type == "FBS": - w_b = n_q - w_d = w_b - vol_quant_re = np.floor((vol_quant_re - min_val) / w_b) + 1.0 - elif discr_type == "FBN": - w_b = (max_val - min_val) / n_q - w_d = 1.0 - vol_quant_re = np.floor( - n_q * ((vol_quant_re - min_val)/(max_val - min_val))) + 1.0 - vol_quant_re[vol_quant_re == np.nanmax(vol_quant_re)] = n_q - elif discr_type == "FBSequal": - w_b = n_q - w_d = w_b - vol_quant_re = equalization(vol_quant_re) - vol_quant_re = np.floor((vol_quant_re - min_val) / w_b) + 1.0 - elif discr_type == "FBNequal": - w_b = (max_val - min_val) / n_q - w_d = 1.0 - vol_quant_re = vol_quant_re.astype(np.float32) - vol_quant_re = equalization(vol_quant_re) - vol_quant_re = np.floor( - n_q * ((vol_quant_re - min_val)/(max_val - min_val))) + 1.0 - vol_quant_re[vol_quant_re == np.nanmax(vol_quant_re)] = n_q - if ivh and discr_type in ["FBS", "FBSequal"]: - vol_quant_re = min_val + (vol_quant_re - 0.5) * w_b - - return vol_quant_re, w_d diff --git a/MEDimage/processing/interpolation.py b/MEDimage/processing/interpolation.py deleted file mode 100644 index f172ce2..0000000 --- a/MEDimage/processing/interpolation.py +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -import logging -from copy import deepcopy -from typing import List - -import numpy as np - -from ..MEDscan import MEDscan -from ..processing.segmentation import compute_box -from ..utils.image_volume_obj import image_volume_obj -from ..utils.imref import imref3d, intrinsicToWorld, worldToIntrinsic -from ..utils.interp3 import interp3 - - -def interp_volume( - vol_obj_s: image_volume_obj, - medscan: MEDscan= None, - vox_dim: List = None, - interp_met: str = None, - round_val: float = None, - image_type: str = None, - roi_obj_s: image_volume_obj = None, - box_string: str = None, - texture: bool = False) -> image_volume_obj: - """3D voxel interpolation on the input volume. - - Args: - vol_obj_s (image_volume_obj): Imaging that will be interpolated. - medscan (object): The MEDscan class object. - vox_dim (array): Array of the voxel dimension. The following format is used - [Xin,Yin,Zslice], where Xin and Yin are the X (left to right) and - Y (bottom to top) IN-PLANE resolutions, and Zslice is the slice spacing, - no matter the orientation of the volume (i.e. axial , sagittal, coronal). - interp_met (str): {nearest, linear, spline, cubic} optional, Interpolation method - round_val (float): Rounding value. Must be between 0 and 1 for ROI interpolation - and to a power of 10 for Image interpolation. - image_type (str): 'image' for imaging data interpolation and 'roi' for ROI mask data interpolation. - roi_obj_s (image_volume_obj): Mask data, will be used to compute a new specific box - and the new imref3d object for the imaging data. - box_string (str): Specifies the size if the box containing the ROI - - - 'full': full imaging data as output. - - 'box': computes the smallest bounding box. - - Ex: 'box10': 10 voxels in all three dimensions are added to \ - the smallest bounding box. The number after 'box' defines the \ - number of voxels to add. - - Ex: '2box': Computes the smallest box and outputs double its \ - size. The number before 'box' defines the multiplication in size. - texture (bool): If True, the texture voxel spacing of ``MEDscan`` will be used for interpolation. - - Returns: - ndarray: 3D array of 1's and 0's defining the ROI mask. - """ - try: - # PARSING ARGUMENTS - if vox_dim is None: - if medscan is None: - return deepcopy(vol_obj_s) - else: - if texture: - vox_dim = medscan.params.process.scale_text - else: - vox_dim = medscan.params.process.scale_non_text - if np.sum(vox_dim) == 0: - return deepcopy(vol_obj_s) - if len(vox_dim) == 2: - two_d = True - else: - two_d = False - - if image_type is None: - raise ValueError( - "The type of input image should be specified as \"image\" or \"roi\".") - elif image_type not in ["image", "roi"]: - raise ValueError( - "The type of input image should either be \"image\" or \"roi\".") - elif image_type == "image": - if not interp_met: - if medscan: - interp_met = medscan.params.process.vol_interp - else: - raise ValueError("Interpolation method or MEDscan instance should be provided.") - if interp_met not in ["linear", "cubic", "spline"]: - raise ValueError( - "Interpolation method for images should either be \"linear\", \"cubic\" or \"spline\".") - if medscan and not round_val: - round_val = medscan.params.process.gl_round - if round_val is not None: - if np.mod(np.log10(round_val), 1): - raise ValueError("\"round_val\" should be a power of 10.") - else: - if not interp_met: - if medscan: - interp_met = medscan.params.process.roi_interp - else: - raise ValueError("Interpolation method or MEDscan instance should be provided.") - if interp_met not in ["nearest", "linear", "cubic"]: - raise ValueError( - "Interpolation method for images should either be \"nearest\", \"linear\" or \"cubic\".") - if medscan and not round_val: - round_val = medscan.params.process.roi_pv - if round_val is not None: - if round_val < 0.0 or round_val > 1.0: - raise ValueError("\"round_val\" must be between 0.0 and 1.0.") - else: - raise ValueError("\"round_val\" must be provided for \"roi\".") - if medscan and not box_string: - box_string = medscan.params.process.box_string - if roi_obj_s is None or box_string is None: - use_box = False - else: - use_box = True - - # --> QUERIED POINTS: NEW INTERPOLATED VOLUME: "q" or "Q". - # --> SAMPLED POINTS: ORIGINAL VOLUME: "s" or "S". - # --> Always using XYZ coordinates (unless specifically noted), - # not MATLAB IJK, so beware! - - # INITIALIZATION - res_q = vox_dim - if two_d: - # If 2D, the resolution of the slice dimension of he queried volume is - # set to the same as the sampled volume. - res_q = np.concatenate((res_q, vol_obj_s.spatialRef.PixelExtentInWorldZ)) - - res_s = np.array([vol_obj_s.spatialRef.PixelExtentInWorldX, - vol_obj_s.spatialRef.PixelExtentInWorldY, - vol_obj_s.spatialRef.PixelExtentInWorldZ]) - - if np.array_equal(res_s, res_q): - return deepcopy(vol_obj_s) - - spatial_ref_s = vol_obj_s.spatialRef - extent_s = np.array([spatial_ref_s.ImageExtentInWorldX, - spatial_ref_s.ImageExtentInWorldY, - spatial_ref_s.ImageExtentInWorldZ]) - low_limits_s = np.array([spatial_ref_s.XWorldLimits[0], - spatial_ref_s.YWorldLimits[0], - spatial_ref_s.ZWorldLimits[0]]) - - # CREATING QUERIED "imref3d" OBJECT CENTERED ON SAMPLED VOLUME - - # Switching to IJK (matlab) reference frame for "imref3d" computation. - # Putting a "ceil", according to IBSI standards. This is safer than "round". - size_q = np.ceil(np.around(np.divide(extent_s, res_q), - decimals=3)).astype(int).tolist() - - if two_d: - # If 2D, forcing the size of the queried volume in the slice dimension - # to be the same as the sample volume. - size_q[2] = vol_obj_s.spatialRef.ImageSize[2] - - spatial_ref_q = imref3d(imageSize=size_q, - pixelExtentInWorldX=res_q[0], - pixelExtentInWorldY=res_q[1], - pixelExtentInWorldZ=res_q[2]) - - extent_q = np.array([spatial_ref_q.ImageExtentInWorldX, - spatial_ref_q.ImageExtentInWorldY, - spatial_ref_q.ImageExtentInWorldZ]) - low_limits_q = np.array([spatial_ref_q.XWorldLimits[0], - spatial_ref_q.YWorldLimits[0], - spatial_ref_q.ZWorldLimits[0]]) - diff = extent_q - extent_s - new_low_limits_q = low_limits_s - diff/2 - spatial_ref_q.XWorldLimits = spatial_ref_q.XWorldLimits - \ - (low_limits_q[0] - new_low_limits_q[0]) - spatial_ref_q.YWorldLimits = spatial_ref_q.YWorldLimits - \ - (low_limits_q[1] - new_low_limits_q[1]) - spatial_ref_q.ZWorldLimits = spatial_ref_q.ZWorldLimits - \ - (low_limits_q[2] - new_low_limits_q[2]) - - # REDUCE THE SIZE OF THE VOLUME PRIOR TO INTERPOLATION - # TODO check that compute_box vol and roi are intended to be the same! - if use_box: - _, _, tempSpatialRef = compute_box( - vol=roi_obj_s.data, roi=roi_obj_s.data, spatial_ref=vol_obj_s.spatialRef, - box_string=box_string) - - size_temp = tempSpatialRef.ImageSize - - # Getting world boundaries (center of voxels) of the new box - x_bound, y_bound, z_bound = intrinsicToWorld(R=tempSpatialRef, - xIntrinsic=np.array( - [0.0, size_temp[0]-1.0]), - yIntrinsic=np.array( - [0.0, size_temp[1]-1.0]), - zIntrinsic=np.array([0.0, size_temp[2]-1.0])) - - # Getting the image positions of the boundaries of the new box, IN THE - # FULL QUERIED FRAME OF REFERENCE (centered on the sampled frame of - # reference). - x_bound, y_bound, z_bound = worldToIntrinsic( - R=spatial_ref_q, xWorld=x_bound, yWorld=y_bound, zWorld=z_bound) - - # Rounding to the nearest image position integer - x_bound = np.round(x_bound).astype(int) - y_bound = np.round(y_bound).astype(int) - z_bound = np.round(z_bound).astype(int) - - size_q = np.array([x_bound[1] - x_bound[0] + 1, y_bound[1] - - y_bound[0] + 1, z_bound[1] - z_bound[0] + 1]) - - # Converting back to world positions ion order to correctly define - # edges of the new box and thus center it onto the full queried - # reference frame - x_bound, y_bound, z_bound = intrinsicToWorld(R=spatial_ref_q, - xIntrinsic=x_bound, - yIntrinsic=y_bound, - zIntrinsic=z_bound) - - new_low_limits_q[0] = x_bound[0] - res_q[0]/2 - new_low_limits_q[1] = y_bound[0] - res_q[1]/2 - new_low_limits_q[2] = z_bound[0] - res_q[2]/2 - - spatial_ref_q = imref3d(imageSize=size_q, - pixelExtentInWorldX=res_q[0], - pixelExtentInWorldY=res_q[1], - pixelExtentInWorldZ=res_q[2]) - - spatial_ref_q.XWorldLimits -= spatial_ref_q.XWorldLimits[0] - \ - new_low_limits_q[0] - spatial_ref_q.YWorldLimits -= spatial_ref_q.YWorldLimits[0] - \ - new_low_limits_q[1] - spatial_ref_q.ZWorldLimits -= spatial_ref_q.ZWorldLimits[0] - \ - new_low_limits_q[2] - - # CREATING QUERIED XYZ POINTS - x_q = np.arange(size_q[0]) - y_q = np.arange(size_q[1]) - z_q = np.arange(size_q[2]) - x_q, y_q, z_q = np.meshgrid(x_q, y_q, z_q, indexing='ij') - x_q, y_q, z_q = intrinsicToWorld( - R=spatial_ref_q, xIntrinsic=x_q, yIntrinsic=y_q, zIntrinsic=z_q) - - # CONVERTING QUERIED XZY POINTS TO INTRINSIC COORDINATES IN THE SAMPLED - # REFERENCE FRAME - x_q, y_q, z_q = worldToIntrinsic( - R=spatial_ref_s, xWorld=x_q, yWorld=y_q, zWorld=z_q) - - # INTERPOLATING VOLUME - data = interp3(v=vol_obj_s.data, x_q=x_q, y_q=y_q, z_q=z_q, method=interp_met) - vol_obj_q = image_volume_obj(data=data, spatial_ref=spatial_ref_q) - - # ROUNDING - if image_type == "image": - # Grey level rounding for "image" type - if round_val is not None and (type(round_val) is int or type(round_val) is float): - # DELETE NEXT LINE WHEN THE RADIOMICS PARAMETER OPTIONS OF - # interp.glRound ARE FIXED - round_val = (-np.log10(round_val)).astype(int) - vol_obj_q.data = np.around(vol_obj_q.data, decimals=round_val) - else: - vol_obj_q.data[vol_obj_q.data >= round_val] = 1.0 - vol_obj_q.data[vol_obj_q.data < round_val] = 0.0 - - except Exception as e: - if medscan: - if medscan.params.radiomics.scale_name: - message = f"\n PROBLEM WITH INTERPOLATION:\n {e}" - logging.error(message) - medscan.radiomics.image.update( - {(medscan.params.radiomics.scale_name ): 'ERROR_PROCESSING'}) - else: - message = f"\n PROBLEM WITH INTERPOLATION:\n {e}" - logging.error(message) - medscan.radiomics.image.update( - {('scale'+(str(medscan.params.process.scale_non_text[0])).replace('.','dot')): 'ERROR_PROCESSING'}) - else: - print(f"\n PROBLEM WITH INTERPOLATION:\n {e}") - - return vol_obj_q diff --git a/MEDimage/processing/resegmentation.py b/MEDimage/processing/resegmentation.py deleted file mode 100644 index 6fa1b11..0000000 --- a/MEDimage/processing/resegmentation.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from copy import deepcopy -import numpy as np -from numpy import ndarray - - -def range_re_seg(vol: np.ndarray, - roi: np.ndarray, - im_range=None) -> ndarray: - """Removes voxels from the intensity mask that fall outside - the given range (intensities outside the range are set to 0). - - Args: - vol (ndarray): Imaging data. - roi (ndarray): ROI mask with values of 0's and 1's. - im_range (ndarray): 1-D array with shape (1,2) of the re-segmentation intensity range. - - Returns: - ndarray: Intensity mask with intensities within the re-segmentation range. - """ - - if im_range is not None and len(im_range) == 2: - roi = deepcopy(roi) - roi[vol < im_range[0]] = 0 - roi[vol > im_range[1]] = 0 - - return roi - -def outlier_re_seg(vol: np.ndarray, - roi: np.ndarray, - outliers="") -> np.ndarray: - """Removes voxels with outlier intensities from the given mask - using the Collewet method. - - Args: - vol (ndarray): Imaging data. - roi (ndarray): ROI mask with values of 0 and 1. - outliers (str, optional): Algo used to define outliers. - (For now this methods only implements "Collewet" method). - - Returns: - ndarray: An array with values of 0 and 1. - - Raises: - ValueError: If `outliers` is not "Collewet" or None. - - Todo: - * Delete outliers argument or implements others outlining methods. - """ - - if outliers != '': - roi = deepcopy(roi) - - if outliers == "Collewet": - u = np.mean(vol[roi == 1]) - sigma = np.std(vol[roi == 1]) - - roi[vol > (u + 3*sigma)] = 0 - roi[vol < (u - 3*sigma)] = 0 - else: - raise ValueError("Outlier segmentation not defined.") - - return roi diff --git a/MEDimage/processing/segmentation.py b/MEDimage/processing/segmentation.py deleted file mode 100644 index 7d1efb6..0000000 --- a/MEDimage/processing/segmentation.py +++ /dev/null @@ -1,912 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -import logging -from copy import deepcopy -from typing import List, Sequence, Tuple, Union - -import numpy as np -from nibabel import Nifti1Image -from scipy.ndimage import center_of_mass - -from ..MEDscan import MEDscan -from ..utils.image_volume_obj import image_volume_obj -from ..utils.imref import imref3d, intrinsicToWorld, worldToIntrinsic -from ..utils.inpolygon import inpolygon -from ..utils.interp3 import interp3 -from ..utils.mode import mode -from ..utils.parse_contour_string import parse_contour_string -from ..utils.strfind import strfind - -_logger = logging.getLogger(__name__) - - -def get_roi_from_indexes( - medscan: MEDscan, - name_roi: str, - box_string: str - ) -> Tuple[image_volume_obj, image_volume_obj]: - """Extracts the ROI box (+ smallest box containing the region of interest) - and associated mask from the indexes saved in ``medscan`` scan. - - Args: - medscan (MEDscan): The MEDscan class object. - name_roi (str): name of the ROI since the a volume can have multiple - ROIs. - box_string (str): Specifies the size if the box containing the ROI - - - 'full': Full imaging data as output. - - 'box': computes the smallest bounding box. - - Ex: 'box10': 10 voxels in all three dimensions are added to \ - the smallest bounding box. The number after 'box' defines the \ - number of voxels to add. - - Ex: '2box': Computes the smallest box and outputs double its \ - size. The number before 'box' defines the multiplication in \ - size. - - Returns: - 2-element tuple containing - - - ndarray: vol_obj, 3D array of imaging data defining the smallest box \ - containing the region of interest. - - ndarray: roi_obj, 3D array of 1's and 0's defining the ROI in ROIbox. - """ - # This takes care of the "Volume resection" step - # as well using the argument "box". No fourth - # argument means 'interp' by default. - - # PARSING OF ARGUMENTS - try: - name_structure_set = [] - delimiters = ["\+", "\-"] - n_contour_data = len(medscan.data.ROI.indexes) - - name_roi, vect_plus_minus = get_sep_roi_names(name_roi, delimiters) - contour_number = np.zeros(len(name_roi)) - - if name_structure_set is None: - name_structure_set = [] - - if name_structure_set: - name_structure_set, _ = get_sep_roi_names(name_structure_set, delimiters) - if len(name_roi) != len(name_structure_set): - raise ValueError( - "The numbers of defined ROI names and Structure Set names are not the same") - - for i in range(0, len(name_roi)): - for j in range(0, n_contour_data): - name_temp = medscan.data.ROI.get_roi_name(key=j) - if name_temp == name_roi[i]: - if name_structure_set: - # FOR DICOM + RTSTRUCT - name_set_temp = medscan.data.ROI.get_name_set(key=j) - if name_set_temp == name_structure_set[i]: - contour_number[i] = j - break - else: - contour_number[i] = j - break - - n_roi = np.size(contour_number) - # contour_string IS FOR EXAMPLE '3' or '1-3+2' - contour_string = str(contour_number[0].astype(int)) - - for i in range(1, n_roi): - if vect_plus_minus[i-1] == 1: - sign = '+' - elif vect_plus_minus[i-1] == -1: - sign = '-' - contour_string = contour_string + sign + \ - str(contour_number[i].astype(int)) - - if not (box_string == "full" or "box" in box_string): - raise ValueError( - "The third argument must either be \"full\" or contain the word \"box\".") - - contour_number, operations = parse_contour_string(contour_string) - - # INTIALIZATIONS - if type(contour_number) is int: - n_contour = 1 - contour_number = [contour_number] - else: - n_contour = len(contour_number) - - # Note: sData is a nested dictionary not an object - spatial_ref = medscan.data.volume.spatialRef - vol = medscan.data.volume.array.astype(np.float32) - - # APPLYING OPERATIONS ON ALL MASKS - roi = medscan.data.get_indexes_by_roi_name(name_roi[0]) - for c in np.arange(start=1, stop=n_contour): - if operations[c-1] == "+": - roi += medscan.data.get_indexes_by_roi_name(name_roi[c]) - elif operations[c-1] == "-": - roi -= medscan.data.get_indexes_by_roi_name(name_roi[c]) - else: - raise ValueError("Unknown operation on ROI.") - - roi[roi >= 1.0] = 1.0 - roi[roi < 1.0] = 0.0 - - # COMPUTING THE BOUNDING BOX - vol, roi, new_spatial_ref = compute_box(vol=vol, roi=roi, - spatial_ref=spatial_ref, - box_string=box_string) - - # ARRANGE OUTPUT - vol_obj = image_volume_obj(data=vol, spatial_ref=new_spatial_ref) - roi_obj = image_volume_obj(data=roi, spatial_ref=new_spatial_ref) - - except Exception as e: - message = f"\n PROBLEM WITH PRE-PROCESSING OF FEATURES IN get_roi_from_indexes():\n {e}" - logging.error(message) - print(message) - - if medscan: - medscan.radiomics.image.update( - {('scale'+(str(medscan.params.process.scale_non_text[0])).replace('.', 'dot')): 'ERROR_PROCESSING'}) - - return vol_obj, roi_obj - -def get_sep_roi_names(name_roi_in: str, - delimiters: List) -> Tuple[List[int], - np.ndarray]: - """Seperated ROI names present in the given ROI name. An ROI name can - have multiple ROI names seperated with curly brackets and delimeters. - Note: - Works only for delimiters "+" and "-". - Args: - name_roi_in (str): Name of ROIs that will be extracted from the imagign volume. \ - Separated with curly brackets and delimeters. Ex: '{ED}+{ET}'. - delimiters (List): List of delimeters of "+" and "-". - Returns: - 2-element tuple containing - - - List[int]: List of ROI names seperated and excluding curly brackets. - - ndarray: array of 1's and -1's that defines the regions that will \ - included and/or excluded in/from the imaging data. - Examples: - >>> get_sep_roi_names('{ED}+{ET}', ['+', '-']) - ['ED', 'ET'], [1] - >>> get_sep_roi_names('{ED}-{ET}', ['+', '-']) - ['ED', 'ET'], [-1] - """ - # EX: - #name_roi_in = '{GTV-1}' - #delimiters = ['\\+','\\-'] - - # FINDING "+" and "-" - ind_plus = strfind(string=name_roi_in, pattern=delimiters[0]) - vect_plus = np.ones(len(ind_plus)) - ind_minus = strfind(string=name_roi_in, pattern=delimiters[1]) - vect_minus = np.ones(len(ind_minus)) * -1 - ind = np.argsort(np.hstack((ind_plus, ind_minus))) - vect_plus_minus = np.hstack((vect_plus, vect_minus))[ind] - ind = np.hstack((ind_plus, ind_minus))[ind].astype(int) - n_delim = np.size(vect_plus_minus) - - # MAKING SURE "+" and "-" ARE NOT INSIDE A ROIname - ind_start = strfind(string=name_roi_in, pattern="{") - n_roi = len(ind_start) - ind_stop = strfind(string=name_roi_in, pattern="}") - ind_keep = np.ones(n_delim, dtype=bool) - for d in np.arange(n_delim): - for r in np.arange(n_roi): - # Thus not indise a ROI name - if (ind_stop[r] - ind[d]) > 0 and (ind[d] - ind_start[r]) > 0: - ind_keep[d] = False - break - - ind = ind[ind_keep] - vect_plus_minus = vect_plus_minus[ind_keep] - - # PARSING ROI NAMES - if ind.size == 0: - # Excluding the "{" and "}" at the start and end of the ROIname - name_roi_out = [name_roi_in[1:-1]] - else: - n_ind = len(ind) - # Excluding the "{" and "}" at the start and end of the ROIname - name_roi_out = [name_roi_in[1:(ind[0]-1)]] - for i in np.arange(start=1, stop=n_ind): - # Excluding the "{" and "}" at the start and end of the ROIname - name_roi_out += [name_roi_in[(ind[i-1]+2):(ind[i]-1)]] - name_roi_out += [name_roi_in[(ind[-1]+2):-1]] - - return name_roi_out, vect_plus_minus - -def roi_extract(vol: np.ndarray, - roi: np.ndarray) -> np.ndarray: - """Replaces volume intensities outside the ROI with NaN. - - Args: - vol (ndarray): Imaging data. - roi (ndarray): ROI mask with values of 0's and 1's. - - Returns: - ndarray: Imaging data with original intensities in the ROI \ - and NaN for intensities outside the ROI. - """ - - vol_re = deepcopy(vol) - vol_re[roi == 0] = np.nan - - return vol_re - -def get_polygon_mask(roi_xyz: np.ndarray, - spatial_ref: imref3d) -> np.ndarray: - """Computes the indexes of the ROI (Region of interest) enclosing box - in all dimensions. - - Args: - roi_xyz (ndarray): array of (x,y,z) triplets defining a contour in the - Patient-Based Coordinate System extracted from DICOM RTstruct. - spatial_ref (imref3d): imref3d object (same functionality of MATLAB imref3d class). - - Returns: - ndarray: 3D array of 1's and 0's defining the ROI mask. - """ - - # COMPUTING MASK - s_z = spatial_ref.ImageSize.copy() - roi_mask = np.zeros(s_z) - # X,Y,Z in intrinsic image coordinates - X, Y, Z = worldToIntrinsic(R=spatial_ref, - xWorld=roi_xyz[:, 0], - yWorld=roi_xyz[:, 1], - zWorld=roi_xyz[:, 2]) - - points = np.transpose(np.vstack((X, Y, Z))) - - K = np.round(points[:, 2]) # Must assign the points to one slice - closed_contours = np.unique(roi_xyz[:, 3]) - x_q = np.arange(s_z[0]) - y_q = np.arange(s_z[1]) - x_q, y_q = np.meshgrid(x_q, y_q) - - for c_c in np.arange(len(closed_contours)): - ind = roi_xyz[:, 3] == closed_contours[c_c] - # Taking the mode, just in case. But normally, numel(unique(K(ind))) - # should evaluate to 1, as closed contours are meant to be defined on - # a given slice - select_slice = mode(K[ind]).astype(int) - inpoly = inpolygon(x_q=x_q, y_q=y_q, x_v=points[ind, 0], y_v=points[ind, 1]) - roi_mask[:, :, select_slice] = np.logical_or( - roi_mask[:, :, select_slice], inpoly) - - return roi_mask - -def voxel_to_spatial(affine: np.ndarray, - voxel_pos: list) -> np.array: - """Convert voxel position into spatial position. - - Args: - affine (ndarray): Affine matrix. - voxel_pos (list): A list that correspond to the location in voxel. - - Returns: - ndarray: A numpy array that correspond to the spatial position in mm. - """ - m = affine[:3, :3] - translation = affine[:3, 3] - return m.dot(voxel_pos) + translation - -def spatial_to_voxel(affine: np.ndarray, - spatial_pos: list) -> np.array: - """Convert spatial position into voxel position - - Args: - affine (ndarray): Affine matrix. - spatial_pos (list): A list that correspond to the spatial location in mm. - - Returns: - ndarray: A numpy array that correspond to the position in the voxel. - """ - affine = np.linalg.inv(affine) - m = affine[:3, :3] - translation = affine[:3, 3] - return m.dot(spatial_pos) + translation - -def crop_nifti_box(image: Nifti1Image, - roi: Nifti1Image, - crop_shape: List[int], - center: Union[Sequence[int], None] = None) -> Tuple[Nifti1Image, - Nifti1Image]: - """Crops the Nifti image and ROI. - - Args: - image (Nifti1Image): Class for the file NIfTI1 format image that will be cropped. - roi (Nifti1Image): Class for the file NIfTI1 format ROI that will be cropped. - crop_shape (List[int]): The dimension of the region to crop in term of number of voxel. - center (Union[Sequence[int], None]): A list that indicate the center of the cropping box - in term of spatial position. - - Returns: - Tuple[Nifti1Image, Nifti1Image] : Two Nifti images of the cropped image and roi - """ - assert np.sum(np.array(crop_shape) % 2) == 0, "All elements of crop_shape should be even number." - - image_data = image.get_fdata() - roi_data = roi.get_fdata() - - radius = [int(x / 2) - 1 for x in crop_shape] - if center is None: - center = list(np.array(list(center_of_mass(roi_data))).astype(int)) - - center_min = np.floor(center).astype(int) - center_max = np.ceil(center).astype(int) - - # If center_max and center_min are equal we add 1 to center_max to avoid trouble with crop. - for i in range(3): - center_max[i] += 1 if center_max[i] == center_min[i] else 0 - - img_shape = image.header['dim'][1:4] - - # Pad the image and the ROI if its necessary - padding = [] - for rad, cent_min, cent_max, shape in zip(radius, center_min, center_max, img_shape): - padding.append( - [abs(min(cent_min - rad, 0)), max(cent_max + rad + 1 - shape, 0)] - ) - - image_data = np.pad(image_data, tuple([tuple(x) for x in padding])) - roi_data = np.pad(roi_data, tuple([tuple(x) for x in padding])) - - center_min = [center_min[i] + padding[i][0] for i in range(3)] - center_max = [center_max[i] + padding[i][0] for i in range(3)] - - # Crop the image - image_data = image_data[center_min[0] - radius[0]:center_max[0] + radius[0] + 1, - center_min[1] - radius[1]:center_max[1] + radius[1] + 1, - center_min[2] - radius[2]:center_max[2] + radius[2] + 1] - roi_data = roi_data[center_min[0] - radius[0]:center_max[0] + radius[0] + 1, - center_min[1] - radius[1]:center_max[1] + radius[1] + 1, - center_min[2] - radius[2]:center_max[2] + radius[2] + 1] - - # Update the image and the ROI - image = Nifti1Image(image_data, affine=image.affine, header=image.header) - roi = Nifti1Image(roi_data, affine=roi.affine, header=roi.header) - - return image, roi - -def crop_box(image_data: np.ndarray, - roi_data: np.ndarray, - crop_shape: List[int], - center: Union[Sequence[int], None] = None) -> Tuple[np.ndarray, np.ndarray]: - """Crops the imaging data and the ROI mask. - - Args: - image_data (ndarray): Imaging data that will be cropped. - roi_data (ndarray): Mask data that will be cropped. - crop_shape (List[int]): The dimension of the region to crop in term of number of voxel. - center (Union[Sequence[int], None]): A list that indicate the center of the cropping box - in term of spatial position. - - Returns: - Tuple[ndarray, ndarray] : Two numpy arrays of the cropped image and roi - """ - assert np.sum(np.array(crop_shape) % 2) == 0, "All elements of crop_shape should be even number." - - radius = [int(x / 2) - 1 for x in crop_shape] - if center is None: - center = list(np.array(list(center_of_mass(roi_data))).astype(int)) - - center_min = np.floor(center).astype(int) - center_max = np.ceil(center).astype(int) - - # If center_max and center_min are equal we add 1 to center_max to avoid trouble with crop. - for i in range(3): - center_max[i] += 1 if center_max[i] == center_min[i] else 0 - - img_shape = image_data.shape - - # Pad the image and the ROI if its necessary - padding = [] - for rad, cent_min, cent_max, shape in zip(radius, center_min, center_max, img_shape): - padding.append( - [abs(min(cent_min - rad, 0)), max(cent_max + rad + 1 - shape, 0)] - ) - - image_data = np.pad(image_data, tuple([tuple(x) for x in padding])) - roi_data = np.pad(roi_data, tuple([tuple(x) for x in padding])) - - center_min = [center_min[i] + padding[i][0] for i in range(3)] - center_max = [center_max[i] + padding[i][0] for i in range(3)] - - # Crop the image - image_data = image_data[center_min[0] - radius[0]:center_max[0] + radius[0] + 1, - center_min[1] - radius[1]:center_max[1] + radius[1] + 1, - center_min[2] - radius[2]:center_max[2] + radius[2] + 1] - roi_data = roi_data[center_min[0] - radius[0]:center_max[0] + radius[0] + 1, - center_min[1] - radius[1]:center_max[1] + radius[1] + 1, - center_min[2] - radius[2]:center_max[2] + radius[2] + 1] - - return image_data, roi_data - -def compute_box(vol: np.ndarray, - roi: np.ndarray, - spatial_ref: imref3d, - box_string: str) -> Tuple[np.ndarray, - np.ndarray, - imref3d]: - """Computes a new box around the ROI (Region of interest) from the original box - and updates the volume and the ``spatial_ref``. - - Args: - vol (ndarray): ROI mask with values of 0 and 1. - roi (ndarray): ROI mask with values of 0 and 1. - spatial_ref (imref3d): imref3d object (same functionality of MATLAB imref3d class). - box_string (str): Specifies the new box to be computed - - * 'full': full imaging data as output. - * 'box': computes the smallest bounding box. - * Ex: 'box10' means 10 voxels in all three dimensions are added to the smallest bounding box. The number \ - after 'box' defines the number of voxels to add. - * Ex: '2box' computes the smallest box and outputs double its \ - size. The number before 'box' defines the multiplication in size. - - Returns: - 3-element tuple containing - - - ndarray: 3D array of imaging data defining the smallest box containing the ROI. - - ndarray: 3D array of 1's and 0's defining the ROI in ROIbox. - - imref3d: The associated imref3d object imaging data. - - Todo: - * I would not recommend parsing different settings into a string. \ - Provide two or more parameters instead, and use None if one or more \ - are not used. - * There is no else statement, so "new_spatial_ref" might be unset - """ - - if "box" in box_string: - comp = box_string == "box" - box_bound = compute_bounding_box(mask=roi) - if not comp: - # Always returns the first appearance - ind_box = box_string.find("box") - # Addition of a certain number of voxels in all dimensions - if ind_box == 0: - n_v = float(box_string[(ind_box+3):]) - n_v = np.array([n_v, n_v, n_v]).astype(int) - else: # Multiplication of the size of the box - factor = float(box_string[0:ind_box]) - size_box = np.diff(box_bound, axis=1) + 1 - new_box = size_box * factor - n_v = np.round((new_box - size_box)/2.0).astype(int) - - o_k = False - - while not o_k: - border = np.zeros([3, 2]) - border[0, 0] = box_bound[0, 0] - n_v[0] - border[0, 1] = box_bound[0, 1] + n_v[0] - border[1, 0] = box_bound[1, 0] - n_v[1] - border[1, 1] = box_bound[1, 1] + n_v[1] - border[2, 0] = box_bound[2, 0] - n_v[2] - border[2, 1] = box_bound[2, 1] + n_v[2] - border = border + 1 - check1 = np.sum(border[:, 0] > 0) - check2 = border[0, 1] <= vol.shape[0] - check3 = border[1, 1] <= vol.shape[1] - check4 = border[2, 1] <= vol.shape[2] - - check = check1 + check2 + check3 + check4 - - if check == 6: - o_k = True - else: - n_v = np.floor(n_v / 2.0) - if np.sum(n_v) == 0.0: - o_k = True - n_v = [0.0, 0.0, 0.0] - else: - # Will compute the smallest bounding box possible - n_v = [0.0, 0.0, 0.0] - - box_bound[0, 0] -= n_v[0] - box_bound[0, 1] += n_v[0] - box_bound[1, 0] -= n_v[1] - box_bound[1, 1] += n_v[1] - box_bound[2, 0] -= n_v[2] - box_bound[2, 1] += n_v[2] - - box_bound = box_bound.astype(int) - - vol = vol[box_bound[0, 0]:box_bound[0, 1] + 1, - box_bound[1, 0]:box_bound[1, 1] + 1, - box_bound[2, 0]:box_bound[2, 1] + 1] - roi = roi[box_bound[0, 0]:box_bound[0, 1] + 1, - box_bound[1, 0]:box_bound[1, 1] + 1, - box_bound[2, 0]:box_bound[2, 1] + 1] - - # Resolution in mm, nothing has changed here in terms of resolution; - # XYZ format here. - res = np.array([spatial_ref.PixelExtentInWorldX, - spatial_ref.PixelExtentInWorldY, - spatial_ref.PixelExtentInWorldZ]) - - # IJK, as required by imref3d - size_box = (np.diff(box_bound, axis=1) + 1).tolist() - size_box[0] = size_box[0][0] - size_box[1] = size_box[1][0] - size_box[2] = size_box[2][0] - x_limit, y_limit, z_limit = intrinsicToWorld(spatial_ref, - box_bound[0, 0], - box_bound[1, 0], - box_bound[2, 0]) - new_spatial_ref = imref3d(size_box, res[0], res[1], res[2]) - - # The limit is defined as the border of the first pixel - new_spatial_ref.XWorldLimits = new_spatial_ref.XWorldLimits - ( - new_spatial_ref.XWorldLimits[0] - (x_limit - res[0]/2)) - new_spatial_ref.YWorldLimits = new_spatial_ref.YWorldLimits - ( - new_spatial_ref.YWorldLimits[0] - (y_limit - res[1]/2)) - new_spatial_ref.ZWorldLimits = new_spatial_ref.ZWorldLimits - ( - new_spatial_ref.ZWorldLimits[0] - (z_limit - res[2]/2)) - - elif "full" in box_string: - new_spatial_ref = spatial_ref - - return vol, roi, new_spatial_ref - -def compute_bounding_box(mask:np.ndarray) -> np.ndarray: - """Computes the indexes of the ROI (Region of interest) enclosing box - in all dimensions. - - Args: - mask (ndarray): ROI mask with values of 0 and 1. - - Returns: - ndarray: An array containing the indexes of the bounding box. - """ - - indices = np.where(np.reshape(mask, np.size(mask), order='F') == 1) - iv, jv, kv = np.unravel_index(indices, np.shape(mask), order='F') - box_bound = np.zeros((3, 2)) - box_bound[0, 0] = np.min(iv) - box_bound[0, 1] = np.max(iv) - box_bound[1, 0] = np.min(jv) - box_bound[1, 1] = np.max(jv) - box_bound[2, 0] = np.min(kv) - box_bound[2, 1] = np.max(kv) - - return box_bound.astype(int) - -def get_roi(medscan: MEDscan, - name_roi: str, - box_string: str, - interp=False) -> Union[image_volume_obj, - image_volume_obj]: - """Computes the ROI box (box containing the region of interest) - and associated mask from MEDscan object. - - Args: - medscan (MEDscan): The MEDscan class object. - name_roi (str): name of the ROI since the a volume can have multuiple ROIs. - box_string (str): Specifies the size if the box containing the ROI - - - 'full': full imaging data as output. - - 'box': computes the smallest bounding box. - - Ex: 'box10': 10 voxels in all three dimensions are added to \ - the smallest bounding box. The number after 'box' defines the \ - number of voxels to add. - - Ex: '2box': Computes the smallest box and outputs double its \ - size. The number before 'box' defines the multiplication in size. - - interp (bool): True if we need to use an interpolation for box computation. - - Returns: - 2-element tuple containing - - - image_volume_obj: 3D array of imaging data defining box containing the ROI. \ - vol.data is the 3D array, vol.spatialRef is its associated imref3d object. - - image_volume_obj: 3D array of 1's and 0's defining the ROI. \ - roi.data is the 3D array, roi.spatialRef is its associated imref3d object. - """ - # PARSING OF ARGUMENTS - try: - name_structure_set = [] - delimiters = ["\+", "\-"] - n_contour_data = len(medscan.data.ROI.indexes) - - name_roi, vect_plus_minus = get_sep_roi_names(name_roi, delimiters) - contour_number = np.zeros(len(name_roi)) - - if name_structure_set is None: - name_structure_set = [] - - if name_structure_set: - name_structure_set, _ = get_sep_roi_names(name_structure_set, delimiters) - if len(name_roi) != len(name_structure_set): - raise ValueError( - "The numbers of defined ROI names and Structure Set names are not the same") - - for i in range(0, len(name_roi)): - for j in range(0, n_contour_data): - name_temp = medscan.data.ROI.get_roi_name(key=j) - if name_temp == name_roi[i]: - if name_structure_set: - # FOR DICOM + RTSTRUCT - name_set_temp = medscan.data.ROI.get_name_set(key=j) - if name_set_temp == name_structure_set[i]: - contour_number[i] = j - break - else: - contour_number[i] = j - break - - n_roi = np.size(contour_number) - # contour_string IS FOR EXAMPLE '3' or '1-3+2' - contour_string = str(contour_number[0].astype(int)) - - for i in range(1, n_roi): - if vect_plus_minus[i-1] == 1: - sign = '+' - elif vect_plus_minus[i-1] == -1: - sign = '-' - contour_string = contour_string + sign + \ - str(contour_number[i].astype(int)) - - if not (box_string == "full" or "box" in box_string): - raise ValueError( - "The third argument must either be \"full\" or contain the word \"box\".") - - if type(interp) != bool: - raise ValueError( - "If present (i.e. it is optional), the fourth argument must be bool") - - contour_number, operations = parse_contour_string(contour_string) - - # INTIALIZATIONS - if type(contour_number) is int: - n_contour = 1 - contour_number = [contour_number] - else: - n_contour = len(contour_number) - - roi_mask_list = [] - if medscan.type not in ["PTscan", "CTscan", "MRscan", "ADCscan"]: - raise ValueError("Unknown scan type.") - - spatial_ref = medscan.data.volume.spatialRef - vol = medscan.data.volume.array.astype(np.float32) - - # COMPUTING ALL MASKS - for c in np.arange(start=0, stop=n_contour): - contour = contour_number[c] - # GETTING THE XYZ POINTS FROM medscan - roi_xyz = medscan.data.ROI.get_indexes(key=contour).copy() - - # APPLYING ROTATION TO XYZ POINTS (if necessary --> MRscan) - if hasattr(medscan.data.volume, 'scan_rot') and medscan.data.volume.scan_rot is not None: - roi_xyz[:, [0, 1, 2]] = np.transpose( - medscan.data.volume.scan_rot @ np.transpose(roi_xyz[:, [0, 1, 2]])) - - # APPLYING TRANSLATION IF SIMULATION STRUCTURE AS INPUT - # (software STAMP utility) - if hasattr(medscan.data.volume, 'transScanToModel'): - translation = medscan.data.volume.transScanToModel - roi_xyz[:, 0] += translation[0] - roi_xyz[:, 1] += translation[1] - roi_xyz[:, 2] += translation[2] - - # COMPUTING THE ROI MASK - # Problem here in compute_roi.m: If the volume is a full-body CT and the - # slice interpolation process occurs, a lot of RAM will be used. - # One solution could be to a priori compute the bounding box before - # computing the ROI (using XYZ points). But we still want the user to - # be able to fully use the "box" argument, so we are fourré...TO SOLVE! - roi_mask_list += [compute_roi(roi_xyz=roi_xyz, - spatial_ref=spatial_ref, - orientation=medscan.data.orientation, - scan_type=medscan.type, - interp=interp).astype(np.float32)] - - # APPLYING OPERATIONS ON ALL MASKS - roi = roi_mask_list[0] - for c in np.arange(start=1, stop=n_contour): - if operations[c-1] == "+": - roi += roi_mask_list[c] - elif operations[c-1] == "-": - roi -= roi_mask_list[c] - else: - raise ValueError("Unknown operation on ROI.") - - roi[roi >= 1.0] = 1.0 - roi[roi < 1.0] = 0.0 - - # COMPUTING THE BOUNDING BOX - vol, roi, new_spatial_ref = compute_box(vol=vol, - roi=roi, - spatial_ref=spatial_ref, - box_string=box_string) - - # ARRANGE OUTPUT - vol_obj = image_volume_obj(data=vol, spatial_ref=new_spatial_ref) - roi_obj = image_volume_obj(data=roi, spatial_ref=new_spatial_ref) - - except Exception as e: - message = f"\n PROBLEM WITH PRE-PROCESSING OF FEATURES IN get_roi(): \n {e}" - _logger.error(message) - print(message) - - if medscan: - medscan.radiomics.image.update( - {('scale'+(str(medscan.params.process.scale_non_text[0])).replace('.', 'dot')): 'ERROR_PROCESSING'}) - - return vol_obj, roi_obj - -def compute_roi(roi_xyz: np.ndarray, - spatial_ref: imref3d, - orientation: str, - scan_type: str, - interp=False) -> np.ndarray: - """Computes the ROI (Region of interest) mask using the XYZ coordinates. - - Args: - roi_xyz (ndarray): array of (x,y,z) triplets defining a contour in the Patient-Based - Coordinate System extracted from DICOM RTstruct. - spatial_ref (imref3d): imref3d object (same functionality of MATLAB imref3d class). - orientation (str): Imaging data ``orientation`` (axial, sagittal or coronal). - scan_type (str): Imaging modality (MRscan, CTscan...). - interp (bool): Specifies if we need to use an interpolation \ - process prior to :func:`get_polygon_mask()` in the slice axis direction. - - - True: Interpolation is performed in the slice axis dimensions. To be further \ - tested, thus please use with caution (True is safer). - - False (default): No interpolation. This can definitely be safe \ - when the RTstruct has been saved specifically for the volume of \ - interest. - - Returns: - ndarray: 3D array of 1's and 0's defining the ROI mask. - - Todo: - - Using interpolation: this part needs to be further tested. - - Consider changing to 'if statement'. Changing ``interp`` variable here will change the ``interp`` variable everywhere - """ - - while interp: - # Initialization - if orientation == "Axial": - dim_ijk = 2 - dim_xyz = 2 - direction = "Z" - # Only the resolution in 'Z' will be changed - res_xyz = np.array([spatial_ref.PixelExtentInWorldX, - spatial_ref.PixelExtentInWorldY, 0.0]) - elif orientation == "Sagittal": - dim_ijk = 0 - dim_xyz = 1 - direction = "Y" - # Only the resolution in 'Y' will be changed - res_xyz = np.array([spatial_ref.PixelExtentInWorldX, 0.0, - spatial_ref.PixelExtentInWorldZ]) - elif orientation == "Coronal": - dim_ijk = 1 - dim_xyz = 0 - direction = "X" - # Only the resolution in 'X' will be changed - res_xyz = np.array([0.0, spatial_ref.PixelExtentInWorldY, - spatial_ref.PixelExtentInWorldZ]) - else: - raise ValueError( - "Provided orientation is not one of \"Axial\", \"Sagittal\", \"Coronal\".") - - # Creating new imref3d object for sample points (with slice dimension - # similar to original volume - # where RTstruct was created) - # Slice spacing in mm - slice_spacing = find_spacing( - roi_xyz[:, dim_ijk], scan_type).astype(np.float32) - - # Only one slice found in the function "find_spacing" on the above line. - # We thus must set "slice_spacing" to the slice spacing of the queried - # volume, and no interpolation will be performed. - if slice_spacing is None: - slice_spacing = spatial_ref.PixelExtendInWorld(axis=direction) - - new_size = round(spatial_ref.ImageExtentInWorld( - axis=direction) / slice_spacing) - res_xyz[dim_xyz] = slice_spacing - s_z = spatial_ref.ImageSize.copy() - s_z[dim_ijk] = new_size - - xWorldLimits = spatial_ref.XWorldLimits.copy() - yWorldLimits = spatial_ref.YWorldLimits.copy() - zWorldLimits = spatial_ref.ZWorldLimits.copy() - - new_spatial_ref = imref3d(imageSize=s_z, - pixelExtentInWorldX=res_xyz[0], - pixelExtentInWorldY=res_xyz[1], - pixelExtentInWorldZ=res_xyz[2], - xWorldLimits=xWorldLimits, - yWorldLimits=yWorldLimits, - zWorldLimits=zWorldLimits) - - diff = (new_spatial_ref.ImageExtentInWorld(axis=direction) - - spatial_ref.ImageExtentInWorld(axis=direction)) - - if np.abs(diff) >= 0.01: - # Sampled and queried volume are considered "different". - new_limit = spatial_ref.WorldLimits(axis=direction)[0] - diff / 2.0 - - # Sampled volume is now centered on queried volume. - new_spatial_ref.WorldLimits(axis=direction, newValue=(new_spatial_ref.WorldLimits(axis=direction) - - (new_spatial_ref.WorldLimits(axis=direction)[0] - - new_limit))) - else: - # Less than a 0.01 mm, sampled and queried volume are considered - # to be the same. At this point, - # spatial_ref and new_spatial_ref may have differed due to data - # manipulation, so we simply compute - # the ROI mask with spatial_ref (i.e. simply using "poly2mask.m"), - # without performing interpolation. - interp = False - break # Getting out of the "while" statement - - V = get_polygon_mask(roi_xyz, new_spatial_ref) - - # Getting query points (x_q,y_q,z_q) of output roi_mask - sz_q = spatial_ref.ImageSize - x_qi = np.arange(sz_q[0]) - y_qi = np.arange(sz_q[1]) - z_qi = np.arange(sz_q[2]) - x_qi, y_qi, z_qi = np.meshgrid(x_qi, y_qi, z_qi, indexing='ij') - - # Getting queried mask - v_q = interp3(V=V, x_q=x_qi, y_q=y_qi, z_q=z_qi, method="cubic") - roi_mask = v_q - roi_mask[v_q < 0.5] = 0 - roi_mask[v_q >= 0.5] = 1 - - # Getting out of the "while" statement - interp = False - - # SIMPLY USING "poly2mask.m" or "inpolygon.m". "inpolygon.m" is slower, but - # apparently more accurate. - if not interp: - # Using the inpolygon.m function. To be further tested. - roi_mask = get_polygon_mask(roi_xyz, spatial_ref) - - return roi_mask - -def find_spacing(points: np.ndarray, - scan_type: str) -> float: - """Finds the slice spacing in mm. - - Note: - This function works for points from at least 2 slices. If only - one slice is present, the function returns a None. - - Args: - points (ndarray): Array of (x,y,z) triplets defining a contour in the - Patient-Based Coordinate System extracted from DICOM RTstruct. - scan_type (str): Imaging modality (MRscan, CTscan...) - - Returns: - float: Slice spacing in mm. - """ - decim_keep = 4 # We keep at most 4 decimals to find the slice spacing. - - # Rounding to the nearest 0.1 mm, MRI is more problematic due to arbitrary - # orientations allowed for imaging volumes. - if scan_type == "MRscan": - slices = np.unique(np.around(points, 1)) - else: - slices = np.unique(np.around(points, 2)) - - n_slices = len(slices) - if n_slices == 1: - return None - - diff = np.abs(np.diff(slices)) - diff = np.round(diff, decim_keep) - slice_spacing, nOcc = mode(x=diff, return_counts=True) - if np.max(nOcc) == 1: - slice_spacing = np.mean(diff) - - return slice_spacing diff --git a/MEDimage/utils/__init__.py b/MEDimage/utils/__init__.py deleted file mode 100644 index 825f367..0000000 --- a/MEDimage/utils/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -from . import * -from .batch_patients import * -from .create_radiomics_table import * -from .data_frame_export import * -from .find_process_names import * -from .get_file_paths import * -from .get_full_rad_names import * -from .get_institutions_from_ids import * -from .get_patient_id_from_scan_name import * -from .get_patient_names import * -from .get_radiomic_names import * -from .get_scan_name_from_rad_name import * -from .image_reader_SITK import * -from .image_volume_obj import * -from .imref import * -from .initialize_features_names import * -from .inpolygon import * -from .interp3 import * -from .json_utils import * -from .mode import * -from .parse_contour_string import * -from .save_MEDscan import * -from .strfind import * -from .textureTools import * -from .write_radiomics_csv import * diff --git a/MEDimage/utils/batch_patients.py b/MEDimage/utils/batch_patients.py deleted file mode 100644 index 57ae128..0000000 --- a/MEDimage/utils/batch_patients.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import numpy as np - - -def batch_patients(n_patient: int, - n_batch: int) -> np.ndarray: - """Replaces volume intensities outside the ROI with NaN. - - Args: - n_patient (int): Number of patient. - n_batch (int): Number of batch, usually less or equal to the cores number on your machine. - - Returns: - ndarray: List of indexes with size n_batch and max value n_patient. - """ - - # FIND THE NUMBER OF PATIENTS IN EACH BATCH - patients = [0] * n_batch # np.zeros(n_batch, dtype=int) - patient_vect = np.random.permutation(n_patient) # To randomize stuff a bit. - if n_batch: - n_p = n_patient / n_batch - n_sup = np.ceil(n_p).astype(int) - n_inf = np.floor(n_p).astype(int) - if n_sup != n_inf: - n_sub_inf = n_batch - 1 - n_sub_sup = 1 - total = n_sub_inf*n_inf + n_sub_sup*n_sup - while total != n_patient: - n_sub_inf = n_sub_inf - 1 - n_sub_sup = n_sub_sup + 1 - total = n_sub_inf*n_inf + n_sub_sup*n_sup - - n_p = np.hstack((np.tile(n_inf, (1, n_sub_inf))[ - 0], np.tile(n_sup, (1, n_sub_sup))[0])) - else: # The number of patients in all batches will be the same - n_p = np.tile(n_sup, (1, n_batch))[0] - - start = 0 - for i in range(0, n_batch): - patients[i] = patient_vect[start:(start+n_p[i])].tolist() - start += n_p[i] - - return patients diff --git a/MEDimage/utils/create_radiomics_table.py b/MEDimage/utils/create_radiomics_table.py deleted file mode 100644 index 1323e5f..0000000 --- a/MEDimage/utils/create_radiomics_table.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import logging -import random -from json import load -from pathlib import Path -from typing import Dict, List, Union - -import numpy as np -import pandas as pd - -from ..utils.get_patient_id_from_scan_name import get_patient_id_from_scan_name -from ..utils.initialize_features_names import initialize_features_names - - -def create_radiomics_table(radiomics_files_paths: List, image_space: str, log_file: Union[str, Path]) -> Dict: - """ - Creates a dictionary with a csv and other information - - Args: - radiomics_files_paths(List): List of paths to the radiomics JSON files. - image_space(str): String of the image space that contains the extracted features - log_file(Union[str, Path]): Path to logging file. - - Returns: - Dict: Dictionary containing the extracted radiomics and other info (patientID, feature names...) - """ - if log_file: - # Setting up logging settings - logging.basicConfig(filename=log_file, level=logging.DEBUG) - - # INITIALIZATIONS OF RADIOMICS STRUCTURES - n_files = len(radiomics_files_paths) - patientID = [0] * n_files - rad_structs = [0] * n_files - file_open = [False] * n_files - - for f in range(n_files): - with open(radiomics_files_paths[f], "r") as fp: - radStruct = load(fp) - rad_structs[f] = radStruct - file_open[f] = True - patientID[f] = get_patient_id_from_scan_name(radiomics_files_paths[f].stem) - - # INITIALIZE FEATURE NAMES - logging.info(f"\nnFiles: {n_files}") - non_text_cell = [] - text_cell = [] - while len(non_text_cell) == 0 and len(text_cell) == 0: - try: - rand_patient = np.floor(n_files * random.uniform(0, 1)).astype(int) - with open(radiomics_files_paths[rand_patient], "r") as fp: - radiomics_struct = load(fp) - - # IMAGE SPACE STRUCTURE --> .morph, .locInt, ..., .texture - image_space_struct = radiomics_struct[image_space] - non_text_cell, text_cell = initialize_features_names(image_space_struct) - except: - pass - - # CREATE TABLE DATA - features_name_dict = {} - str_table = '' - str_names = '||' - count_var = 0 - - # Non-texture features - for im_type in range(len(non_text_cell[0])): - for param in range(len(non_text_cell[2][im_type])): - for feat in range(len(non_text_cell[1][im_type])): - count_var = count_var + 1 - feature_name = 'radVar' + str(count_var) - features_name_dict.update({feature_name: [0] * n_files}) - real_name_feature = non_text_cell[0][im_type] + '__' + \ - non_text_cell[1][im_type][feat] + '__' + \ - non_text_cell[2][im_type][param] - str_table = str_table + feature_name + ',' - str_names = str_names + feature_name + ':' + real_name_feature + '||' - - for f in range(n_files): - if file_open[f]: - try: - val = rad_structs[f][image_space][ - non_text_cell[0][im_type]][ - non_text_cell[2][im_type][param]][ - non_text_cell[1][im_type][feat]] - except: - val = np.NaN - if type(val) in [str, list]: - val = np.NaN - else: - val = np.NaN - features_name_dict[feature_name][f] = val - - # Texture features - for im_type in range(len(text_cell[0])): - for param in range(len(text_cell[2][im_type])): - for feat in range(len(text_cell[1][im_type])): - count_var = count_var + 1 - feature_name = 'radVar' + str(count_var) - features_name_dict.update({feature_name: [0] * n_files}) - real_name_feature = text_cell[0][im_type] + '__' + \ - text_cell[1][im_type][feat] + '__' + \ - text_cell[2][im_type][param] - str_table = str_table + feature_name + ',' - str_names = str_names + feature_name + ':' + real_name_feature + '||' - for f in range(n_files): - if file_open[f]: - try: - val = rad_structs[f][image_space]['texture'][ - text_cell[0][im_type]][ - text_cell[2][im_type][param]][ - text_cell[1][im_type][feat]] - except: - val = np.NaN - if type(val) in [str, list]: - val = np.NaN - else: - val = np.NaN - features_name_dict[feature_name][f] = val - - radiomics_table_dict = { - 'Table': pd.DataFrame(features_name_dict, index=patientID), - 'Properties': {'UserData': str_names, - 'RowNames': patientID, - 'DimensionNames': ['PatientID', 'Variables'], - 'VariableNames': [key for key in features_name_dict.keys()] - }} - - return radiomics_table_dict diff --git a/MEDimage/utils/data_frame_export.py b/MEDimage/utils/data_frame_export.py deleted file mode 100644 index 0cd2b01..0000000 --- a/MEDimage/utils/data_frame_export.py +++ /dev/null @@ -1,42 +0,0 @@ -import os.path -from isort import file -import pandas as pd - -def export_table(file_name: file, - data: object): - """Export table - - Args: - file_name (file): name of the file - data (object): the data - - Returns: - None - """ - - if not isinstance(data, (pd.DataFrame, pd.Series)): - raise TypeError(f"The exported data should be a pandas DataFrame or Series. Found: {type(data)}") - - # Find the extension - ext = os.path.splitext(file_name)[1] - - # Set an index switch based on type of input - if isinstance(data, pd.DataFrame): - write_index = False - else: - write_index = True - - if ext == ".csv": - data.to_csv(path_or_buf=file_name, sep=";", index=write_index) - elif ext in [".xls", ".xlsx"]: - data.to_excel(excel_writer=file_name, index=write_index) - elif ext in [".tex"]: - with open(file=file_name, mode="w") as f: - data.to_latex(buf=f, index=write_index) - elif ext in [".html"]: - with open(file=file_name, mode="w") as f: - data.to_html(buf=f, index=write_index) - elif ext in [".json"]: - data.to_json(path_or_buf=file_name) - else: - raise ValueError(f"File extension not supported for export of table data. Recognised extensions are: \".csv\", \".xls\", \".xlsx\", \".tex\", \".html\" and \".json\". Found: {ext}") diff --git a/MEDimage/utils/find_process_names.py b/MEDimage/utils/find_process_names.py deleted file mode 100644 index 0349601..0000000 --- a/MEDimage/utils/find_process_names.py +++ /dev/null @@ -1,16 +0,0 @@ -from inspect import stack, getmodule -from typing import List - -def get_process_names() -> List: - """Get process names - - Returns: - List: process names - """ - module_names = ["none"] - for stack_entry in stack(): - current_module = getmodule(stack_entry[0]) - if current_module is not None: - module_names += [current_module.__name__] - - return module_names \ No newline at end of file diff --git a/MEDimage/utils/get_file_paths.py b/MEDimage/utils/get_file_paths.py deleted file mode 100644 index dfd6c90..0000000 --- a/MEDimage/utils/get_file_paths.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -from importlib.resources import path -from pathlib import Path -from typing import List, Union - - -def get_file_paths(path_to_parent_folder: Union[str, Path], wildcard: str=None) -> List[Path]: - """Finds all files in the given path that matches the pattern/wildcard. - - Note: - The search is done recursively in all subdirectories. - - Args: - path_to_parent_folder (Union[str, Path]): Full path to where the files are located. - wildcard (str, optional): String specifying which type of files - to locate in the parent folder. - - Ex : '*.dcm*', to look for dicom files. - - Returns: - List: List of full paths to files with the specific wildcard located \ - in the given path to parent folder. - """ - if wildcard is None: - wildcard = '*' - - # Getting the list of all files full path in file_paths - path_to_parent_folder = Path(path_to_parent_folder) - file_paths_list = list(path_to_parent_folder.rglob(wildcard)) - # for the name only put file.name - file_paths = [file for file in file_paths_list if file.is_file()] - - return file_paths diff --git a/MEDimage/utils/get_full_rad_names.py b/MEDimage/utils/get_full_rad_names.py deleted file mode 100644 index 6d94634..0000000 --- a/MEDimage/utils/get_full_rad_names.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import List - -import numpy as np - - -def get_full_rad_names(str_user_data: str, rad_var_ids: List): - """ - Returns the full real names of the radiomics variables (sequential names are not very informative) - Args: - str_user_data: string containing the full rad names - rad_var_ids: can get it by doing table.column.values - - Returns: - List: List of full radiomic names. - """ - full_rad_names = np.array([]) - for rad_var in rad_var_ids: - ind_var = int(rad_var[6:]) - full_rad_names = np.append(full_rad_names, str_user_data.split('||')[ind_var].split(':')[1]) - - return full_rad_names diff --git a/MEDimage/utils/get_institutions_from_ids.py b/MEDimage/utils/get_institutions_from_ids.py deleted file mode 100644 index d3213d0..0000000 --- a/MEDimage/utils/get_institutions_from_ids.py +++ /dev/null @@ -1,16 +0,0 @@ -import pandas as pd - - -def get_institutions_from_ids(patient_ids): - """ - Extracts the institution strings from a cell of patient IDs. - - Args: - patient_ids (Any): Patient ID (string, list of strings or pandas Series). Ex: 'Cervix-CEM-010'. - - Returns: - str: Categorical vector, specifying the institution of each patient_id entry in "patient_ids". Ex: 'CEM'. - """ - if isinstance(patient_ids, list): - patient_ids = pd.Series(patient_ids) - return patient_ids.str.rsplit('-', expand=True)[1] diff --git a/MEDimage/utils/get_patient_id_from_scan_name.py b/MEDimage/utils/get_patient_id_from_scan_name.py deleted file mode 100644 index 8534a5a..0000000 --- a/MEDimage/utils/get_patient_id_from_scan_name.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -def get_patient_id_from_scan_name(rad_name: str) -> str: - """ - Finds the patient id from the given string - - Args: - rad_name(str): Name of a scan or a radiomics structure - - Returns: - str: patient id - - Example: - >>> get_patient_id_from_scan_name('STS-McGill-001__T1(tumourAndEdema).MRscan') - STS-McGill-001 - """ - ind_double_under = rad_name.find('__') - patientID = rad_name[:ind_double_under] - - return patientID diff --git a/MEDimage/utils/get_patient_names.py b/MEDimage/utils/get_patient_names.py deleted file mode 100644 index 68f6ab6..0000000 --- a/MEDimage/utils/get_patient_names.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from typing import List - -import numpy as np - - -def get_patient_names(roi_names: np.ndarray) -> List[str]: - """Generates all file names for scans using CSV data. - - Args: - roi_names (ndarray): Array with CSV data organized as follows - [[patient_id], [imaging_scan_name], [imagning_modality]] - - Returns: - list[str]: List of scans files name. - """ - n_names = np.size(roi_names[0]) - patient_names = [0] * n_names - for n in range(0, n_names): - patient_names[n] = roi_names[0][n]+'__'+roi_names[1][n] + \ - '.'+roi_names[2][n]+'.npy' - - return patient_names diff --git a/MEDimage/utils/get_radiomic_names.py b/MEDimage/utils/get_radiomic_names.py deleted file mode 100644 index 80bdc59..0000000 --- a/MEDimage/utils/get_radiomic_names.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from typing import Dict -import numpy as np - - -def get_radiomic_names(roi_names: np.array, - roi_type: str) -> Dict: - """Generates radiomics names using ``roi_names`` and ``roi_types``. - - Args: - roi_names (np.array): array of the ROI names. - roi_type(str): string of the ROI. - - Returns: - dict: dict with the radiomic names - """ - - n_names = np.size(roi_names)[0] - radiomic_names = [0] * n_names - for n in range(0, n_names): - radiomic_names[n] = roi_names[n, 0]+'__'+roi_names[n, 1] + \ - '('+roi_type+').'+roi_names[n, 2]+'.npy' - - return radiomic_names diff --git a/MEDimage/utils/get_scan_name_from_rad_name.py b/MEDimage/utils/get_scan_name_from_rad_name.py deleted file mode 100644 index 1a6676e..0000000 --- a/MEDimage/utils/get_scan_name_from_rad_name.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -def get_scan_name_from_rad_name(rad_name: str) -> str: - """Finds the imaging scan name from thr radiomics structure name - - Args: - rad_name (str): radiomics structure name. - - Returns: - str: String of the imaging scan name - - Example: - >>> get_scan_name_from_rad_name('STS-McGill-001__T1(tumourAndEdema).MRscan') - 'T1' - """ - ind_double_under = rad_name.find('__') - ind_open_par = rad_name.find('(') - scan_name = rad_name[ind_double_under + 2:ind_open_par] - - return scan_name \ No newline at end of file diff --git a/MEDimage/utils/image_reader_SITK.py b/MEDimage/utils/image_reader_SITK.py deleted file mode 100644 index df8ed51..0000000 --- a/MEDimage/utils/image_reader_SITK.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from pathlib import Path -from typing import Dict, Union -import SimpleITK as sitk -import numpy as np - - -def image_reader_SITK(path: Path, - option: str=None) -> Union[Dict, None]: - """Return the image in a numpy array or a dictionary with the header of the image. - - Args: - path (path): path of the file - option (str): name of the option, either 'image' or 'header' - - Returns: - Union[Dict, None]: dictionary with the header of the image - """ - if option is None or option == 'image': - # return the image in a numpy array - return np.transpose(sitk.GetArrayFromImage(sitk.ReadImage(path))) - elif option == 'header': - # Return a dictionary with the header of the image. - reader = sitk.ImageFileReader() - reader.SetFileName(path) - # reader.LoadPrivateTagsOn() - reader.ReadImageInformation() - dic_im_header = {} - for key in reader.GetMetaDataKeys(): - dic_im_header.update({key: reader.GetMetaData(key)}) - return dic_im_header - else: - print("Argument option should be the string 'image' or 'header'") - return None diff --git a/MEDimage/utils/image_volume_obj.py b/MEDimage/utils/image_volume_obj.py deleted file mode 100644 index d5f8664..0000000 --- a/MEDimage/utils/image_volume_obj.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -class image_volume_obj: - """Used to organize Imaging data and their corresponding imref3d object. - - Args: - data (ndarray, optional): 3D array of imaging data. - spatialRef (imref3d, optional): The corresponding imref3d object - (same functionality of MATLAB imref3d class). - - Attributes: - data (ndarray): 3D array of imaging data. - spatialRef (imref3d): The corresponding imref3d object - (same functionality of MATLAB imref3d class). - - """ - - def __init__(self, data=None, spatial_ref=None) -> None: - self.data = data - self.spatialRef = spatial_ref diff --git a/MEDimage/utils/imref.py b/MEDimage/utils/imref.py deleted file mode 100644 index 1d812eb..0000000 --- a/MEDimage/utils/imref.py +++ /dev/null @@ -1,340 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -from typing import Tuple, Union - -import numpy as np - - -def intrinsicToWorld(R, xIntrinsic: float, yIntrinsic: float, zIntrinsic:float) -> Tuple[float, float, float]: - """Convert from intrinsic to world coordinates. - - Args: - R (imref3d): imref3d object (same functionality of MATLAB imref3d class) - xIntrinsic (float): Coordinates along the x-dimension in the intrinsic coordinate system - yIntrinsic (float): Coordinates along the y-dimension in the intrinsic coordinate system - zIntrinsic (float): Coordinates along the z-dimension in the intrinsic coordinate system - - Returns: - float: world coordinates - """ - return R.intrinsicToWorld(xIntrinsic=xIntrinsic, yIntrinsic=yIntrinsic, zIntrinsic=zIntrinsic) - - -def worldToIntrinsic(R, xWorld: float, yWorld: float, zWorld: float) -> Tuple[float, float, float] : - """Convert from world coordinates to intrinsic. - - Args: - R (imref3d): imref3d object (same functionality of MATLAB imref3d class) - xWorld (float): Coordinates along the x-dimension in the intrinsic coordinate system - yWorld (float): Coordinates along the y-dimension in the intrinsic coordinate system - zWorld (float): Coordinates along the z-dimension in the intrinsic coordinate system - - Returns: - _type_: intrinsic coordinates - """ - return R.worldToIntrinsic(xWorld=xWorld, yWorld=yWorld, zWorld=zWorld) - - -def sizes_match(R, A): - """Compares whether the two imref3d objects have the same size. - - Args: - R (imref3d): First imref3d object. - A (imref3d): Second imref3d object. - - Returns: - bool: True if ``R`` and ``A`` have the same size, and false if not. - - """ - return np.all(R.imageSize == A.imageSize) - - -class imref3d: - """This class mirrors the functionality of the matlab imref3d class - - An `imref3d object `_ - stores the relationship between the intrinsic coordinates - anchored to the columns, rows, and planes of a 3-D image and the spatial - location of the same column, row, and plane locations in a world coordinate system. - - The image is sampled regularly in the planar world-x, world-y, and world-z coordinates - of the coordinate system such that intrinsic-x, -y and -z values align with world-x, -y - and -z values, respectively. The resolution in each dimension can be different. - - Args: - ImageSize (ndarray, optional): Number of elements in each spatial dimension, - specified as a 3-element positive row vector. - PixelExtentInWorldX (float, optional): Size of a single pixel in the x-dimension - measured in the world coordinate system. - PixelExtentInWorldY (float, optional): Size of a single pixel in the y-dimension - measured in the world coordinate system. - PixelExtentInWorldZ (float, optional): Size of a single pixel in the z-dimension - measured in the world coordinate system. - xWorldLimits (ndarray, optional): Limits of image in world x, specified as a 2-element row vector, - [xMin xMax]. - yWorldLimits (ndarray, optional): Limits of image in world y, specified as a 2-element row vector, - [yMin yMax]. - zWorldLimits (ndarray, optional): Limits of image in world z, specified as a 2-element row vector, - [zMin zMax]. - - Attributes: - ImageSize (ndarray): Number of elements in each spatial dimension, - specified as a 3-element positive row vector. - PixelExtentInWorldX (float): Size of a single pixel in the x-dimension - measured in the world coordinate system. - PixelExtentInWorldY (float): Size of a single pixel in the y-dimension - measured in the world coordinate system. - PixelExtentInWorldZ (float): Size of a single pixel in the z-dimension - measured in the world coordinate system. - XIntrinsicLimits (ndarray): Limits of image in intrinsic units in the x-dimension, - specified as a 2-element row vector [xMin xMax]. - YIntrinsicLimits (ndarray): Limits of image in intrinsic units in the y-dimension, - specified as a 2-element row vector [yMin yMax]. - ZIntrinsicLimits (ndarray): Limits of image in intrinsic units in the z-dimension, - specified as a 2-element row vector [zMin zMax]. - ImageExtentInWorldX (float): Span of image in the x-dimension in - the world coordinate system. - ImageExtentInWorldY (float): Span of image in the y-dimension in - the world coordinate system. - ImageExtentInWorldZ (float): Span of image in the z-dimension in - the world coordinate system. - xWorldLimits (ndarray): Limits of image in world x, specified as a 2-element row vector, - [xMin xMax]. - yWorldLimits (ndarray): Limits of image in world y, specified as a 2-element row vector, - [yMin yMax]. - zWorldLimits (ndarray): Limits of image in world z, specified as a 2-element row vector, - [zMin zMax]. - """ - - def __init__(self, - imageSize=None, - pixelExtentInWorldX=1.0, - pixelExtentInWorldY=1.0, - pixelExtentInWorldZ=1.0, - xWorldLimits=None, - yWorldLimits=None, - zWorldLimits=None) -> None: - - # Check if imageSize is an ndarray, and cast to ndarray otherwise - self.ImageSize = self._parse_to_ndarray(x=imageSize, n=3) - - # Size of single voxels along axis in world coordinate system. - # Equivalent to voxel spacing. - self.PixelExtentInWorldX = pixelExtentInWorldX - self.PixelExtentInWorldY = pixelExtentInWorldY - self.PixelExtentInWorldZ = pixelExtentInWorldZ - - # Limits of the image in intrinsic coordinates - # AZ: this differs from DICOM, which assumes that the origin lies - # at the center of the first voxel. - if imageSize is not None: - self.XIntrinsicLimits = np.array([-0.5, imageSize[0]-0.5]) - self.YIntrinsicLimits = np.array([-0.5, imageSize[1]-0.5]) - self.ZIntrinsicLimits = np.array([-0.5, imageSize[2]-0.5]) - else: - self.XIntrinsicLimits = None - self.YIntrinsicLimits = None - self.ZIntrinsicLimits = None - - # Size of the image in world coordinates - if imageSize is not None: - self.ImageExtentInWorldX = imageSize[0] * pixelExtentInWorldX - self.ImageExtentInWorldY = imageSize[1] * pixelExtentInWorldY - self.ImageExtentInWorldZ = imageSize[2] * pixelExtentInWorldZ - else: - self.ImageExtentInWorldX = None - self.ImageExtentInWorldY = None - self.ImageExtentInWorldZ = None - - # Limits of the image in the world coordinates - self.XWorldLimits = self._parse_to_ndarray(x=xWorldLimits, n=2) - self.YWorldLimits = self._parse_to_ndarray(x=yWorldLimits, n=2) - self.ZWorldLimits = self._parse_to_ndarray(x=zWorldLimits, n=2) - - if xWorldLimits is None and imageSize is not None: - self.XWorldLimits = np.array([0.0, self.ImageExtentInWorldX]) - if yWorldLimits is None and imageSize is not None: - self.YWorldLimits = np.array([0.0, self.ImageExtentInWorldY]) - if zWorldLimits is None and imageSize is not None: - self.ZWorldLimits = np.array([0.0, self.ImageExtentInWorldZ]) - - def _parse_to_ndarray(self, - x: np.iterable, - n=None) -> np.ndarray: - """Internal function to cast input to a numpy array. - - Args: - x (iterable): Object that supports __iter__. - n (int, optional): expected length. - - Returns: - ndarray: iterable input as a numpy array. - """ - if x is not None: - # Cast to ndarray - if not isinstance(x, np.ndarray): - x = np.array(x) - - # Check length - if n is not None: - if not len(x) == n: - raise ValueError( - "Length of array does not meet the expected length.", len(x), n) - - return x - - def intrinsicToWorld(self, - xIntrinsic: np.ndarray, - yIntrinsic: np.ndarray, - zIntrinsic: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: - """Convert from intrinsic to world coordinates. - - Args: - xIntrinsic (ndarray): Coordinates along the x-dimension in the intrinsic coordinate system. - yIntrinsic (ndarray): Coordinates along the y-dimension in the intrinsic coordinate system. - zIntrinsic (ndarray): Coordinates along the z-dimension in the intrinsic coordinate system. - - Returns: - Tuple[np.ndarray, np.ndarray, np.ndarray]: [xWorld, yWorld, zWorld] in world coordinate system. - """ - xWorld = (self.XWorldLimits[0] + 0.5*self.PixelExtentInWorldX) + \ - xIntrinsic * self.PixelExtentInWorldX - yWorld = (self.YWorldLimits[0] + 0.5*self.PixelExtentInWorldY) + \ - yIntrinsic * self.PixelExtentInWorldY - zWorld = (self.ZWorldLimits[0] + 0.5*self.PixelExtentInWorldZ) + \ - zIntrinsic * self.PixelExtentInWorldZ - - return xWorld, yWorld, zWorld - - def worldToIntrinsic(self, - xWorld: np.ndarray, - yWorld: np.ndarray, - zWorld: np.ndarray)-> Union[np.ndarray, - np.ndarray, - np.ndarray]: - """Converts from world coordinates to intrinsic coordinates. - - Args: - xWorld (ndarray): Coordinates along the x-dimension in the world coordinate system. - yWorld (ndarray): Coordinates along the y-dimension in the world coordinate system. - zWorld (ndarray): Coordinates along the z-dimension in the world coordinate system. - - Returns: - ndarray: [xIntrinsic,yIntrinsic,zIntrinsic] in intrinsic coordinate system. - """ - - xIntrinsic = ( - xWorld - (self.XWorldLimits[0] + 0.5*self.PixelExtentInWorldX)) / self.PixelExtentInWorldX - yIntrinsic = ( - yWorld - (self.YWorldLimits[0] + 0.5*self.PixelExtentInWorldY)) / self.PixelExtentInWorldY - zIntrinsic = ( - zWorld - (self.ZWorldLimits[0] + 0.5*self.PixelExtentInWorldZ)) / self.PixelExtentInWorldZ - - return xIntrinsic, yIntrinsic, zIntrinsic - - def contains_point(self, - xWorld: np.ndarray, - yWorld: np.ndarray, - zWorld: np.ndarray) -> np.ndarray: - """Determines which points defined by ``xWorld``, ``yWorld`` and ``zWorld``. - - Args: - xWorld (ndarray): Coordinates along the x-dimension in the world coordinate system. - yWorld (ndarray): Coordinates along the y-dimension in the world coordinate system. - zWorld (ndarray): Coordinates along the z-dimension in the world coordinate system. - - Returns: - ndarray: boolean array for coordinate sets that are within the bounds of the image. - """ - xInside = np.logical_and( - xWorld >= self.XWorldLimits[0], xWorld <= self.XWorldLimits[1]) - yInside = np.logical_and( - yWorld >= self.YWorldLimits[0], yWorld <= self.YWorldLimits[1]) - zInside = np.logical_and( - zWorld >= self.ZWorldLimits[0], zWorld <= self.ZWorldLimits[1]) - - return xInside + yInside + zInside == 3 - - def WorldLimits(self, - axis=None, - newValue=None) -> Union[np.ndarray, None]: - """Sets the WorldLimits to the new value for the given ``axis``. - If the newValue is None, the method returns the attribute value. - - Args: - axis (str, optional): Specify the dimension, must be 'X', 'Y' or 'Z'. - newValue (iterable, optional): New value for the WorldLimits attribute. - - Returns: - ndarray: Limits of image in world along the axis-dimension. - """ - if newValue is None: - # Get value - if axis == "X": - return self.XWorldLimits - elif axis == "Y": - return self.YWorldLimits - elif axis == "Z": - return self.ZWorldLimits - else: - # Set value - if axis == "X": - self.XWorldLimits = self._parse_to_ndarray(x=newValue, n=2) - elif axis == "Y": - self.YWorldLimits = self._parse_to_ndarray(x=newValue, n=2) - elif axis == "Z": - self.ZWorldLimits = self._parse_to_ndarray(x=newValue, n=2) - - def PixelExtentInWorld(self, axis=None) -> Union[float, None]: - """Returns the PixelExtentInWorld attribute value for the given ``axis``. - - Args: - axis (str, optional): Specify the dimension, must be 'X', 'Y' or 'Z'. - - Returns: - float: Size of a single pixel in the axis-dimension measured in the world coordinate system. - """ - if axis == "X": - return self.PixelExtentInWorldX - elif axis == "Y": - return self.PixelExtentInWorldY - elif axis == "Z": - return self.PixelExtentInWorldZ - - def IntrinsicLimits(self, - axis=None) -> Union[np.ndarray, - None]: - """Returns the IntrinsicLimits attribute value for the given ``axis``. - - Args: - axis (str, optional): Specify the dimension, must be 'X', 'Y' or 'Z'. - - Returns: - ndarray: Limits of image in intrinsic units in the axis-dimension, specified as a 2-element row vector [xMin xMax]. - """ - if axis == "X": - return self.XIntrinsicLimits - elif axis == "Y": - return self.YIntrinsicLimits - elif axis == "Z": - return self.ZIntrinsicLimits - - def ImageExtentInWorld(self, - axis=None) -> Union[float, - None]: - """Returns the ImageExtentInWorld attribute value for the given ``axis``. - - Args: - axis (str, optional): Specify the dimension, must be 'X', 'Y' or 'Z'. - - Returns: - ndarray: Span of image in the axis-dimension in the world coordinate system. - - """ - if axis == "X": - return self.ImageExtentInWorldX - elif axis == "Y": - return self.ImageExtentInWorldY - elif axis == "Z": - return self.ImageExtentInWorldZ diff --git a/MEDimage/utils/initialize_features_names.py b/MEDimage/utils/initialize_features_names.py deleted file mode 100644 index 098887c..0000000 --- a/MEDimage/utils/initialize_features_names.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from typing import Dict, List, Tuple - - -def initialize_features_names(image_space_struct: Dict) -> Tuple[List, List]: - """Finds all the features names from `image_space_struct` - - Args: - image_space_struct(Dict): Dictionary of the extracted features (Texture & Non-texture) - - Returns: - Tuple[List, List]: Two lists of the texture and non-texture features names found in the `image_space_struct`. - """ - # First entry is the names of feature types. Second entry is the name of - # the features for a given feature type. Third entry is the name of the - # extraction parameters for all features of a given feature type. - non_text_cell = [0] * 3 - # First entry is the names of feature types. Second entry is the name of - # the features for a given feature type. Third entry is the name of the - # extraction parameters for all features of a given feature type. - text_cell = [0] * 3 - - # NON-TEXTURE FEATURES - field_non_text = [key for key in image_space_struct.keys() if key != 'texture'] - n_non_text_type = len(field_non_text) - non_text_cell[0] = field_non_text - non_text_cell[1] = [0] * n_non_text_type - non_text_cell[2] = [0] * n_non_text_type - - for t in range(0, n_non_text_type): - dic_image_space_struct_non_text = image_space_struct[non_text_cell[0][t]] - field_params_non_text = [ - key for key in dic_image_space_struct_non_text.keys()] - dic_image_space_struct_params_non_text = image_space_struct[non_text_cell[0] - [t]][field_params_non_text[0]] - field_feat_non_text = [ - key for key in dic_image_space_struct_params_non_text.keys()] - non_text_cell[1][t] = field_feat_non_text - non_text_cell[2][t] = field_params_non_text - - # TEXTURE FEATURES - dic_image_space_struct_texture = image_space_struct['texture'] - field_text = [key for key in dic_image_space_struct_texture.keys()] - n_text_type = len(field_text) - text_cell[0] = field_text - text_cell[1] = [0] * n_text_type - text_cell[2] = [0] * n_text_type - - for t in range(0, n_text_type): - dic_image_space_struct_text = image_space_struct['texture'][text_cell[0][t]] - field_params_text = [key for key in dic_image_space_struct_text.keys()] - dic_image_space_struct_params_text = image_space_struct['texture'][text_cell[0] - [t]][field_params_text[0]] - field_feat_text = [ - key for key in dic_image_space_struct_params_text.keys()] - text_cell[1][t] = field_feat_text - text_cell[2][t] = field_params_text - - return non_text_cell, text_cell diff --git a/MEDimage/utils/inpolygon.py b/MEDimage/utils/inpolygon.py deleted file mode 100644 index e0e7a68..0000000 --- a/MEDimage/utils/inpolygon.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -import numpy as np - - -def inpolygon(x_q: np.ndarray, - y_q: np.ndarray, - x_v: np.ndarray, - y_v: np.ndarray) -> np.ndarray: - """Implements similar functionality MATLAB inpolygon. - Finds points located inside or on edge of polygonal region. - - Note: - Unlike matlab inpolygon, this function does not determine the - status of single points :math:`(x_q, y_q)`. Instead, it determines the - status for an entire grid by ray-casting. - - Args: - x_q (ndarray): x-coordinates of query points, in intrinsic reference system. - y_q (ndarray): y-coordinates of query points, in intrinsic reference system. - x_q (ndarray): x-coordinates of polygon vertices, in intrinsic reference system. - y_q (ndarray): y-coordinates of polygon vertices, in intrinsic reference system. - - Returns: - ndarray: boolean array indicating if the query points are on the edge of the polygon area. - - """ - def ray_line_intersection(ray_orig, ray_dir, vert_1, vert_2): - """ - - """ - epsilon = 0.000001 - - # Define edge - edge_line = vert_1 - vert_2 - - # Define ray vertices - r_vert_1 = ray_orig - r_vert_2 = ray_orig + ray_dir - edge_ray = - ray_dir - - # Calculate determinant - if close to 0, lines are parallel and will - # not intersect - det = np.cross(edge_ray, edge_line) - if (det > -epsilon) and (det < epsilon): - return np.nan - - # Calculate inverse of the determinant - inv_det = 1.0 / det - - # Calculate determinant - a11 = np.cross(r_vert_1, r_vert_2) - a21 = np.cross(vert_1, vert_2) - - # Solve for x - a12 = edge_ray[0] - a22 = edge_line[0] - x = np.linalg.det(np.array([[a11, a12], [a21, a22]])) * inv_det - - # Solve for y - b12 = edge_ray[1] - b22 = edge_line[1] - y = np.linalg.det(np.array([[a11, b12], [a21, b22]])) * inv_det - - t = np.array([x, y]) - - # Check whether the solution falls within the line segment - u1 = np.around(np.dot(edge_line, edge_line), 5) - u2 = np.around(np.dot(edge_line, vert_1-t), 5) - if (u2 / u1) < 0.0 or (u2 / u1) > 1.0: - return np.nan - - # Return scalar length from ray origin - t_scal = np.linalg.norm(ray_orig - t) - - return t_scal - - # These are hacks to actually make this function work - spacing = np.array([1.0, 1.0]) - origin = np.array([0.0, 0.0]) - shape = np.array([np.max(x_q) + 1, np.max(y_q) + 1]) - # shape = np.array([np.max(x_q), np.max(y_q)]) Original from Alex - vertices = np.vstack((x_v, y_v)).transpose() - lines = np.vstack( - ([np.arange(0, len(x_v))], [np.arange(-1, len(x_v) - 1)])).transpose() - - # Set up line vertices - vertex_a = vertices[lines[:, 0], :] - vertex_b = vertices[lines[:, 1], :] - - # Remove lines with length 0 and center on the origin - line_mask = np.sum(np.abs(vertex_a - vertex_b), axis=1) > 0.0 - vertex_a = vertex_a[line_mask] - origin - vertex_b = vertex_b[line_mask] - origin - - # Find extent of contours in x - x_min_ind = int( - np.max([np.floor(np.min(vertices[:, 0]) / spacing[0]), 0.0])) - x_max_ind = int( - np.min([np.ceil(np.max(vertices[:, 0]) / spacing[0]), shape[0] * 1.0])) - - # Set up voxel grid and y-span - vox_grid = np.zeros(shape, dtype=int) - vox_span = origin[1] + np.arange(0, shape[1]) * spacing[1] - - # Set ray origin and direction (starts at negative y, and travels towards - # positive y - ray_origin = np.array([0.0, -1.0]) - ray_dir = np.array([0.0, 1.0]) - - for x_ind in np.arange(x_min_ind, x_max_ind): - # Update ray origin - ray_origin[0] = origin[0] + x_ind * spacing[0] - - # Scan both forward and backward to resolve points located on - # the polygon - vox_col_frwd = np.zeros(np.shape(vox_span), dtype=int) - vox_col_bkwd = np.zeros(np.shape(vox_span), dtype=int) - - # Find lines that are intersected by the ray - ray_hit = np.sum( - np.sign(np.vstack((vertex_a[:, 0], vertex_b[:, 0])) - ray_origin[0]), axis=0) - - # If the ray crosses a vertex, the sum of the sign is 0 when the ray - # does not hit an vertex point, and -1 or 1 when it does. - # In the latter case, we only keep of the vertices for each hit. - simplex_mask = np.logical_or(ray_hit == 0, ray_hit == 1) - - # Go to next iterator if mask is empty - if np.sum(simplex_mask) == 0: - continue - - # Determine the selected vertices - selected_verts = np.squeeze(np.where(simplex_mask)) - - # Find intercept of rays with lines - t_scal = np.array([ray_line_intersection(ray_orig=ray_origin, ray_dir=ray_dir, - vert_1=vertex_a[ii, :], vert_2=vertex_b[ii, :]) for ii in selected_verts]) - - # Remove invalid results - t_scal = t_scal[np.isfinite(t_scal)] - if t_scal.size == 0: - continue - - # Update vox_col based on t_scal. This basically adds a 1 for all - # voxels that lie behind the line intersections - # of the ray. - for t_curr in t_scal: - vox_col_frwd[vox_span > t_curr + ray_origin[1]] += 1 - for t_curr in t_scal: - vox_col_bkwd[vox_span < t_curr + ray_origin[1]] += 1 - - # Voxels in the roi cross an uneven number of meshes from the origin - vox_grid[x_ind, - :] += np.logical_and(vox_col_frwd % 2, vox_col_bkwd % 2) - - return vox_grid.astype(dtype=bool) diff --git a/MEDimage/utils/interp3.py b/MEDimage/utils/interp3.py deleted file mode 100644 index 1291c85..0000000 --- a/MEDimage/utils/interp3.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import numpy as np -from scipy.ndimage import map_coordinates - - -def interp3(v, x_q, y_q, z_q, method) -> np.ndarray: - """`Interpolation for 3-D gridded data `_\ - in meshgrid format, implements similar functionality MATLAB interp3. - - Args: - X, Y, Z (ndarray) : Query points, should be intrinsic coordinates. - method (str): {nearest, linear, spline, cubic}, Interpolation ``method``. - - Returns: - ndarray: Array of interpolated values. - - Raises: - ValueError: If ``method`` is not 'nearest', 'linear', 'spline' or 'cubic'. - - """ - - # Parse method - if method == "nearest": - spline_order = 0 - elif method == "linear": - spline_order = 1 - elif method in ["spline", "cubic"]: - spline_order = 3 - else: - raise ValueError("Interpolator not implemented.") - - size = np.size(x_q) - coord_X = np.reshape(x_q, size, order='F') - coord_Y = np.reshape(y_q, size, order='F') - coord_Z = np.reshape(z_q, size, order='F') - coordinates = np.array([coord_X, coord_Y, coord_Z]).astype(np.float32) - v_q = map_coordinates(input=v.astype( - np.float32), coordinates=coordinates, order=spline_order, mode='nearest') - v_q = np.reshape(v_q, np.shape(x_q), order='F') - - return v_q diff --git a/MEDimage/utils/json_utils.py b/MEDimage/utils/json_utils.py deleted file mode 100644 index 355a015..0000000 --- a/MEDimage/utils/json_utils.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -import json -import pathlib -from typing import Dict - - -def _is_jsonable(data: any, cls: object) -> bool: - """Checks if the given ``data`` is JSON serializable. - - Args: - data (Any): ``Data`` that will be checked. - cls(object, optional): Costum JSONDecoder subclass. If not specified JSONDecoder is used. - - Returns: - bool: True if the given ``data`` is serializable, False if not. - """ - try: - json.dumps(data, cls=cls) - return True - except (TypeError, OverflowError): - return False - - -def posix_to_string(dictionnary: Dict) -> Dict: - """Converts all Pathlib.Path to str [Pathlib is not serializable]. - - Args: - dictionnary (Dict): dict with Pathlib.Path values to convert. - - Returns: - Dict: ``dictionnary`` with all Pathlib.Path converted to str. - """ - for key, value in dictionnary.items(): - if type(value) is dict: - value = posix_to_string(value) - else: - if issubclass(type(value), (pathlib.WindowsPath, pathlib.PosixPath, pathlib.Path)): - dictionnary[key] = str(value) - - return dictionnary - -def load_json(file_path: pathlib.Path) -> Dict: - """Wrapper to json.load function. - - Args: - file_path (Path): Path of the json file to load. - - Returns: - Dict: The loaded json file. - - """ - with open(file_path, 'r') as fp: - return json.load(fp) - - -def save_json(file_path: pathlib.Path, data: any, cls=None) -> None: - """Wrapper to json.dump function. - - Args: - file_path (Path): Path to write the json file to. - data (Any): Data to write to the given path. Must be serializable by JSON. - cls(object, optional): Costum JSONDecoder subclass. If not specified JSONDecoder is used. - - Returns: - None: saves the ``data`` in JSON file to the ``file_path``. - - Raises: - TypeError: If ``data`` is not JSON serializable. - """ - if _is_jsonable(data, cls): - with open(file_path, 'w') as fp: - json.dump(data, fp, indent=4, cls=cls) - else: - raise TypeError("The given data is not JSON serializable. \ - We rocommend using a costum encoder.") diff --git a/MEDimage/utils/mode.py b/MEDimage/utils/mode.py deleted file mode 100644 index 35aac31..0000000 --- a/MEDimage/utils/mode.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from typing import Tuple, Union - -import numpy as np - - -def mode(x: np.ndarray, - return_counts=False) -> Union[Tuple[np.ndarray, np.ndarray], - np.ndarray]: - """Implementation of mode that also returns counts, unlike the standard statistics.mode. - - Args: - x (ndarray): n-dimensional array of which to find mode. - return_counts (bool): If True, also return the number of times each unique item appears in ``x``. - - Returns: - 2-element tuple containing - - - ndarray: Array of the modal (most common) value in the given array. - - ndarray: Array of the counts if ``return_counts`` is True. - """ - - unique_values, counts = np.unique(x, return_counts=True) - - if return_counts: - return unique_values[np.argmax(counts)], np.max(counts) - - return unique_values[np.argmax(counts)] diff --git a/MEDimage/utils/parse_contour_string.py b/MEDimage/utils/parse_contour_string.py deleted file mode 100644 index 675d465..0000000 --- a/MEDimage/utils/parse_contour_string.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -from typing import List, Tuple, Union - -import numpy as np - -from ..utils.strfind import strfind - - -def parse_contour_string(contour_string) -> Union[Tuple[float, List[str]], - Tuple[int, List[str]], - Tuple[List[int], List[str]]]: - """Finds the delimeters (:math:`'+'` and :math:`'-'`) and the contour indexe(s) from the given string. - - Args: - contour_string (str, float or int): Index or string of indexes with - delimeters. For example: :math:`'3'` or :math:`'1-3+2'`. - - Returns: - float, int: If ``contour_string`` is a an int or float we return ``contour_string``. - List[str]: List of the delimeters. - List[int]: List of the contour indexes. - - Example: - >>> ``contour_string`` = '1-3+2' - >>> :function: parse_contour_string(contour_string) - [1, 2, 3], ['+', '-'] - >>> ``contour_string`` = 1 - >>> :function: parse_contour_string(contour_string) - 1, [] - """ - - if isinstance(contour_string, (int, float)): - return contour_string, [] - - ind_plus = strfind(string=contour_string, pattern='\+') - ind_minus = strfind(string=contour_string, pattern='\-') - ind_operations = np.sort(np.hstack((ind_plus, ind_minus))).astype(int) - - # Parsing operations and contour numbers - # AZ: I assume that contour_number is an integer - if ind_operations.size == 0: - operations = [] - contour_number = [int(contour_string)] - else: - n_op = len(ind_operations) - operations = [contour_string[ind_operations[i]] for i in np.arange(n_op)] - - contour_number = np.zeros(n_op + 1, dtype=int) - contour_number[0] = int(contour_string[0:ind_operations[0]]) - for c in np.arange(start=1, stop=n_op): - contour_number[c] = int(contour_string[(ind_operations[c-1]+1) : ind_operations[c]]) - - contour_number[-1] = int(contour_string[(ind_operations[-1]+1):]) - contour_number.tolist() - - return contour_number, operations diff --git a/MEDimage/utils/save_MEDscan.py b/MEDimage/utils/save_MEDscan.py deleted file mode 100644 index 2a71cfe..0000000 --- a/MEDimage/utils/save_MEDscan.py +++ /dev/null @@ -1,30 +0,0 @@ -import pickle -from pathlib import Path - -from ..MEDscan import MEDscan - - -def save_MEDscan(medscan: MEDscan, - path_save: Path) -> str: - """Saves MEDscan class instance in a pickle object - - Args: - medscan (MEDscan): MEDscan instance - path_save (Path): MEDscan instance saving paths - - Returns: - None. - """ - - series_description = medscan.series_description.translate({ord(ch): '-' for ch in '/\\ ()&:*'}) - name_id = medscan.patientID - name_id = name_id.translate({ord(ch): '-' for ch in '/\\ ()&:*'}) - - # final saving name - name_complete = name_id + '__' + series_description + '.' + medscan.type + '.npy' - - # save - with open(path_save / name_complete,'wb') as f: - pickle.dump(medscan, f) - - return name_complete diff --git a/MEDimage/utils/strfind.py b/MEDimage/utils/strfind.py deleted file mode 100644 index bf3ec15..0000000 --- a/MEDimage/utils/strfind.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -from re import finditer -from typing import List - - -def strfind(pattern: str, - string: str) -> List[int]: - """Finds indices of ``pattern`` in ``string``. Based on regex. - - Note: - Be careful with + and - symbols. Use :math:`\+` and :math:`\-` instead. - - Args: - pattern (str): Substring to be searched in the ``string``. - string (str): String used to find matches. - - Returns: - List[int]: List of indexes of every occurence of ``pattern`` in the passed ``string``. - - Raises: - ValueError: If the ``pattern`` does not use backslash with special regex symbols - """ - - if pattern in ('+', '-'): - raise ValueError( - "Please use a backslash with special regex symbols in findall.") - - ind = [m.start() for m in finditer(pattern, string)] - - return ind diff --git a/MEDimage/utils/textureTools.py b/MEDimage/utils/textureTools.py deleted file mode 100644 index 7d5ff52..0000000 --- a/MEDimage/utils/textureTools.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from typing import List, Union - -import numpy as np - - -def get_neighbour_direction(d=1.8, - distance="euclidian", - centre=False, - complete=False, - dim3=True) -> np.ndarray: - """Defines transitions to neighbour voxels. - - Note: - This code was adapted from the in-house radiomics software created at - OncoRay, Dresden, Germany. - - Args: - d (float, optional): Max ``distance`` between voxels. - distance (str, optional): Distance norm used to compute distances. must be - "manhattan", "l1", "l_1", "euclidian", "l2", "l_2", "chebyshev", "linf" or "l_inf". - centre (bool, optional): Flags whether the [0,0,0] direction should be included - complete(bool, optional): Flags whether all directions should be computed (True) - or just the primary ones (False). For example, including [0,0,1] and [0,0,-1] - directions may lead to redundant texture matrices. - dim3(bool, optional): flags whether full 3D (True) or only in-slice (2D; False) - directions should be considered. - - Returns: - ndarray: set of k neighbour direction vectors. - """ - - # Base transition vector - trans = np.arange(start=-np.ceil(d), stop=np.ceil(d)+1) - n = np.size(trans) - - # Build transition array [x,y,z] - nbrs = np.array([rep(x=trans, each=n * n, times=1), - rep(x=trans, each=n, times=n), - rep(x=trans, each=1, times=n * n)], dtype=np.int32) - - # Initiate maintenance index - index = np.zeros(np.shape(nbrs)[1], dtype=bool) - - # Remove neighbours more than distance d from the center ---------------- - - # Manhattan distance - if distance.lower() in ["manhattan", "l1", "l_1"]: - index = np.logical_or(index, np.sum(np.abs(nbrs), axis=0) <= d) - # Eucldian distance - if distance.lower() in ["euclidian", "l2", "l_2"]: - index = np.logical_or(index, np.sqrt( - np.sum(np.multiply(nbrs, nbrs), axis=0)) <= d) - # Chebyshev distance - if distance.lower() in ["chebyshev", "linf", "l_inf"]: - index = np.logical_or(index, np.max(np.abs(nbrs), axis=0) <= d) - - # Check if centre voxel [0,0,0] should be maintained; False indicates removal - if centre is False: - index = np.logical_and(index, (np.sum(np.abs(nbrs), axis=0)) > 0) - - # Check if a complete neighbourhood should be returned - # False indicates that only half of the vectors are returned - if complete is False: - index[np.arange(start=0, stop=len(index)//2 + 1)] = False - - # Check if neighbourhood should be 3D or 2D - if dim3 is False: - index[nbrs[2, :] != 0] = False - - return nbrs[:, index] - - -def rep(x: np.ndarray, - each=1, - times=1) -> np.ndarray: - """Replicates the values in ``x``. - Replicates the :func:`"rep"` function found in R for tiling and repeating vectors. - - Note: - Code was adapted from the in-house radiomics software created at OncoRay, - Dresden, Germany. - - Args: - x (ndarray): Array to replicate. - each (int): Integer (non-negative) giving the number of times to repeat - each element of the passed array. - times (int): Integer (non-negative). Each element of ``x`` is repeated each times. - - Returns: - ndarray: Array with same values but replicated. - """ - - each = int(each) - times = int(times) - - if each > 1: - x = np.repeat(x, repeats=each) - - if times > 1: - x = np.tile(x, reps=times) - - return x - -def get_value(x: np.ndarray, - index: int, - replace_invalid=True) -> np.ndarray: - """Retrieves intensity values from an image intensity table used for computing - texture features. - - Note: - Code was adapted from the in-house radiomics software created at OncoRay, - Dresden, Germany. - - Args: - x (ndarray): set of intensity values. - index (int): Index to the provided set of intensity values. - replace_invalid (bool, optional): If True, invalid indices will be replaced - by a placeholder "NaN" value. - - Returns: - ndarray: Array of the intensity values found at the requested indices. - - """ - - # Initialise placeholder - read_x = np.zeros(np.shape(x)) - - # Read variables for valid indices - read_x[index >= 0] = x[index[index >= 0]] - - if replace_invalid: - # Set variables for invalid indices to nan - read_x[index < 0] = np.nan - - # Set variables for invalid initial indices to nan - read_x[np.isnan(x)] = np.nan - - return read_x - - -def coord2index(x: np.ndarray, - y: np.ndarray, - z: np.ndarray, - dims: Union[List, np.ndarray]) -> Union[np.ndarray, - List]: - """Translate requested coordinates to row indices in image intensity tables. - - Note: - Code was adapted from the in-house radiomics software created at OncoRay, - Dresden, Germany. - - Args: - x (ndarray): set of discrete x-coordinates. - y (ndarray): set of discrete y-coordinates. - z (ndarray): set of discrete z-coordinates. - dims (ndarray or List): dimensions of the image. - - Returns: - ndarray or List: Array or List of indexes corresponding the requested coordinates - - """ - - # Translate coordinates to indices - index = z + y * dims[2] + x * dims[2] * dims[1] - - # Mark invalid transitions - index[np.logical_or(x < 0, x >= dims[0])] = -99999 - index[np.logical_or(y < 0, y >= dims[1])] = -99999 - index[np.logical_or(z < 0, z >= dims[2])] = -99999 - - return index - - -def is_list_all_none(x: List) -> bool: - """Determines if all list elements are None. - - Args: - x (List): List of elements to check. - - Returns: - bool: True if all elemets in `x` are None. - - """ - return all(y is None for y in x) diff --git a/MEDimage/utils/texture_features_names.py b/MEDimage/utils/texture_features_names.py deleted file mode 100644 index a286c51..0000000 --- a/MEDimage/utils/texture_features_names.py +++ /dev/null @@ -1,115 +0,0 @@ -glcm_features_names = [ - "Fcm_joint_max", - "Fcm_joint_avg", - "Fcm_joint_var", - "Fcm_joint_entr", - "Fcm_diff_avg", - "Fcm_diff_var", - "Fcm_diff_entr", - "Fcm_sum_avg", - "Fcm_sum_var", - "Fcm_sum_entr", - "Fcm_energy", - "Fcm_contrast", - "Fcm_dissimilarity", - "Fcm_inv_diff", - "Fcm_inv_diff_norm", - "Fcm_inv_diff_mom", - "Fcm_inv_diff_mom_norm", - "Fcm_inv_var", - "Fcm_corr", - "Fcm_auto_corr", - "Fcm_info_corr1", - "Fcm_info_corr2", - "Fcm_clust_tend", - "Fcm_clust_shade", - "Fcm_clust_prom" -] -glrlm_features_names = [ - "Frlm_sre", - "Frlm_lre", - "Frlm_lgre", - "Frlm_hgre", - "Frlm_srlge", - "Frlm_srhge", - "Frlm_lrlge", - "Frlm_lrhge", - "Frlm_glnu", - "Frlm_glnu_norm", - "Frlm_rlnu", - "Frlm_rlnu_norm", - "Frlm_r_perc", - "Frlm_gl_var", - "Frlm_rl_var", - "Frlm_rl_entr" -] -glszm_features_names = [ - "Fszm_sze", - "Fszm_lze", - "Fszm_lgze", - "Fszm_hgze", - "Fszm_szlge", - "Fszm_szhge", - "Fszm_lzlge", - "Fszm_lzhge", - "Fszm_glnu", - "Fszm_glnu_norm", - "Fszm_zsnu", - "Fszm_zsnu_norm", - "Fszm_z_perc", - "Fszm_gl_var", - "Fszm_zs_var", - "Fszm_zs_entr", -] -gldzm_features_names = [ - "Fdzm_sde", - "Fdzm_lde", - "Fdzm_lgze", - "Fdzm_hgze", - "Fdzm_sdlge", - "Fdzm_sdhge", - "Fdzm_ldlge", - "Fdzm_ldhge", - "Fdzm_glnu", - "Fdzm_glnu_norm", - "Fdzm_zdnu", - "Fdzm_zdnu_norm", - "Fdzm_z_perc", - "Fdzm_gl_var", - "Fdzm_zd_var", - "Fdzm_zd_entr" -] -ngtdm_features_names = [ - "Fngt_coarseness" - "Fngt_contrast" - "Fngt_busyness" - "Fngt_complexity" - "Fngt_strength" -] -ngldm_features_names = [ - "Fngl_lde", - "Fngl_hde", - "Fngl_lgce", - "Fngl_hgce", - "Fngl_ldlge", - "Fngl_ldhge", - "Fngl_hdlge", - "Fngl_hdhge", - "Fngl_glnu", - "Fngl_glnu_norm", - "Fngl_dcnu", - "Fngl_dcnu_norm", - "Fngl_gl_var", - "Fngl_dc_var", - "Fngl_dc_entr", - "Fngl_dc_energy" -] -# PS: DO NOT CHANGE THE ORDER OF THE LISTS BELOW, CHANGING THE ORDER WILL BREAK THE CODE (RESULTS CLASS) -texture_features_all = [ - glcm_features_names, - ngtdm_features_names, - ngldm_features_names, - glrlm_features_names, - gldzm_features_names, - glszm_features_names -] \ No newline at end of file diff --git a/MEDimage/utils/write_radiomics_csv.py b/MEDimage/utils/write_radiomics_csv.py deleted file mode 100644 index c4e66ff..0000000 --- a/MEDimage/utils/write_radiomics_csv.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -from pathlib import Path -from typing import Union - -import numpy as np - - -def write_radiomics_csv(path_radiomics_table: Union[Path, str]) -> None: - """ - Loads a radiomics structure (dict with radiomics features) to convert it to a CSV file and save it. - - Args: - path_radiomics_table(Union[Path, str]): path to the radiomics dict. - - Returns: - None. - """ - - # INITIALIZATION - path_radiomics_table = Path(path_radiomics_table) - path_to_table = path_radiomics_table.parent - name_table = path_radiomics_table.stem - - # LOAD RADIOMICS TABLE - radiomics_table_dict = np.load(path_radiomics_table, allow_pickle=True)[0] - - # WRITE RADIOMICS TABLE IN CSV FORMAT - csv_name = name_table + '.csv' - csv_path = path_to_table / csv_name - radiomics_table_dict['Table'] = radiomics_table_dict['Table'].fillna(value='NaN') - radiomics_table_dict['Table'] = radiomics_table_dict['Table'].sort_index() - radiomics_table_dict['Table'].to_csv(csv_path, - sep=',', - encoding='utf-8', - index=True, - index_label=radiomics_table_dict['Properties']['DimensionNames'][0]) - - # WRITE DEFINITIONS.TXT - txt_name = name_table + '.txt' - txt_Path = path_to_table / txt_name - - # WRITE THE CSV - fid = open(txt_Path, 'w') - fid.write(radiomics_table_dict['Properties']['UserData']) - fid.close() diff --git a/MEDimage/wrangling/DataManager.py b/MEDimage/wrangling/DataManager.py deleted file mode 100644 index 6105012..0000000 --- a/MEDimage/wrangling/DataManager.py +++ /dev/null @@ -1,1724 +0,0 @@ -import json -import logging -import os -import pickle -import re -from dataclasses import dataclass -from pathlib import Path -from time import time -from typing import List, Union - -import matplotlib.pyplot as plt -import nibabel as nib -import numpy as np -import pandas as pd -import pydicom -import pydicom.errors -import pydicom.misc -import ray -from nilearn import image -from numpyencoder import NumpyEncoder -from tqdm import tqdm, trange - -from ..MEDscan import MEDscan -from ..processing.compute_suv_map import compute_suv_map -from ..processing.segmentation import get_roi_from_indexes -from ..utils.get_file_paths import get_file_paths -from ..utils.get_patient_names import get_patient_names -from ..utils.imref import imref3d -from ..utils.json_utils import load_json, save_json -from ..utils.save_MEDscan import save_MEDscan -from .ProcessDICOM import ProcessDICOM - - -class DataManager(object): - """Reads all the raw data (DICOM, NIfTI) content and organizes it in instances of the MEDscan class.""" - - - @dataclass - class DICOM(object): - """DICOM data management class that will organize data during the conversion to MEDscan class process""" - stack_series_rs: List - stack_path_rs: List - stack_frame_rs: List - cell_series_id: List - cell_path_rs: List - cell_path_images: List - cell_frame_rs: List - cell_frame_id: List - - - @dataclass - class NIfTI(object): - """NIfTI data management class that will organize data during the conversion to MEDscan class process""" - stack_path_images: List - stack_path_roi: List - stack_path_all: List - - - @dataclass - class Paths(object): - """Paths management class that will organize the paths used in the processing""" - _path_to_dicoms: List - _path_to_niftis: List - _path_csv: Union[Path, str] - _path_save: Union[Path, str] - _path_save_checks: Union[Path, str] - _path_pre_checks_settings: Union[Path, str] - - def __init__( - self, - path_to_dicoms: List = [], - path_to_niftis: List = [], - path_csv: Union[Path, str] = None, - path_save: Union[Path, str] = None, - path_save_checks: Union[Path, str] = None, - path_pre_checks_settings: Union[Path, str] = None, - save: bool = True, - n_batch: int = 2 - ) -> None: - """Constructor of the class DataManager. - - Args: - path_to_dicoms (Union[Path, str], optional): Full path to the starting directory - where the DICOM data is located. - path_to_niftis (Union[Path, str], optional): Full path to the starting directory - where the NIfTI is located. - path_csv (Union[Path, str], optional): Full path to the CSV file containing the scans info list. - path_save (Union[Path, str], optional): Full path to the directory where to save all the MEDscan classes. - path_save_checks(Union[Path, str], optional): Full path to the directory where to save all - the pre-radiomics checks analysis results. - path_pre_checks_settings(Union[Path, str], optional): Full path to the JSON file of the pre-checks analysis - parameters. - save (bool, optional): True to save the MEDscan classes in `path_save`. - n_batch (int, optional): Numerical value specifying the number of batch to use in the - parallel computations (use 0 for serial computation). - - Returns: - None - """ - # Convert all paths to Pathlib.Path - if path_to_dicoms: - path_to_dicoms = Path(path_to_dicoms) - if path_to_niftis: - path_to_niftis = Path(path_to_niftis) - if path_csv: - path_csv = Path(path_csv) - if path_save: - path_save = Path(path_save) - if path_save_checks: - path_save_checks = Path(path_save_checks) - if path_pre_checks_settings: - path_pre_checks_settings = Path(path_pre_checks_settings) - - self.paths = self.Paths( - path_to_dicoms, - path_to_niftis, - path_csv, - path_save, - path_save_checks, - path_pre_checks_settings, - ) - self.save = save - self.n_batch = n_batch - self.__dicom = self.DICOM( - stack_series_rs=[], - stack_path_rs=[], - stack_frame_rs=[], - cell_series_id=[], - cell_path_rs=[], - cell_path_images=[], - cell_frame_rs=[], - cell_frame_id=[] - ) - self.__nifti = self.NIfTI( - stack_path_images=[], - stack_path_roi=[], - stack_path_all=[] - ) - self.path_to_objects = [] - self.summary = {} - self.csv_data = None - self.__studies = [] - self.__institutions = [] - self.__scans = [] - - def __find_uid_cell_index(self, uid: Union[str, List[str]], cell: List[str]) -> List: - """Finds the cell with the same `uid`. If not is present in `cell`, creates a new position - in the `cell` for the new `uid`. - - Args: - uid (Union[str, List[str]]): Unique identifier of the Series to find. - cell (List[str]): List of Unique identifiers of the Series. - - Returns: - Union[List[str], str]: List or string of the uid - """ - return [len(cell)] if uid not in cell else[i for i, e in enumerate(cell) if e == uid] - - def __get_list_of_files(self, dir_name: str) -> List: - """Gets all files in the given directory - - Args: - dir_name (str): directory name - - Returns: - List: List of all files in the directory - """ - list_of_file = os.listdir(dir_name) - all_files = list() - for entry in list_of_file: - full_path = os.path.join(dir_name, entry) - if os.path.isdir(full_path): - all_files = all_files + self.__get_list_of_files(full_path) - else: - all_files.append(full_path) - - return all_files - - def __get_MEDscan_name_save(self, medscan: MEDscan) -> str: - """Returns the name that will be used to save the MEDscan instance, based on the values of the attributes. - - Args: - medscan(MEDscan): A MEDscan class instance. - - Returns: - str: String of the name save. - """ - series_description = medscan.series_description.translate({ord(ch): '-' for ch in '/\\ ()&:*'}) - name_id = medscan.patientID.translate({ord(ch): '-' for ch in '/\\ ()&:*'}) - # final saving name - name_complete = name_id + '__' + series_description + '.' + medscan.type + '.npy' - return name_complete - - def __associate_rt_stuct(self) -> None: - """Associates the imaging volumes to their mask using UIDs - - Returns: - None - """ - print('--> Associating all RT objects to imaging volumes') - n_rs = len(self.__dicom.stack_path_rs) - self.__dicom.stack_series_rs = list(dict.fromkeys(self.__dicom.stack_series_rs)) - if n_rs: - for i in trange(0, n_rs): - try: - # PUT ALL THE DICOM PATHS WITH THE SAME UID IN THE SAME PATH LIST - ind_series_id = self.__find_uid_cell_index( - self.__dicom.stack_series_rs[i], - self.__dicom.cell_series_id) - for n in range(len(ind_series_id)): - if ind_series_id[n] < len(self.__dicom.cell_path_rs): - self.__dicom.cell_path_rs[ind_series_id[n]] += [self.__dicom.stack_path_rs[i]] - except: - ind_series_id = self.__find_uid_cell_index( - self.__dicom.stack_frame_rs[i], - self.__dicom.cell_frame_id) - for n in range(len(ind_series_id)): - if ind_series_id[n] < len(self.__dicom.cell_path_rs): - self.__dicom.cell_path_rs[ind_series_id[n]] += [self.__dicom.stack_path_rs[i]] - print('DONE') - - def __read_all_dicoms(self) -> None: - """Reads all the dicom files in the all the paths of the attribute `_path_to_dicoms` - - Returns: - None - """ - # SCANNING ALL FOLDERS IN INITIAL DIRECTORY - print('\n--> Scanning all folders in initial directory...', end='') - p = Path(self.paths._path_to_dicoms) - e_rglob = '*.dcm' - - # EXTRACT ALL FILES IN THE PATH TO DICOMS - if self.paths._path_to_dicoms.is_dir(): - stack_folder_temp = list(p.rglob(e_rglob)) - stack_folder = [x for x in stack_folder_temp if not x.is_dir()] - elif str(self.paths._path_to_dicoms).find('json') != -1: - with open(self.paths._path_to_dicoms) as f: - data = json.load(f) - for value in data.values(): - stack_folder_temp = value - directory_name = str(stack_folder_temp).replace("'", '').replace('[', '').replace(']', '') - stack_folder = self.__get_list_of_files(directory_name) - else: - raise ValueError("The given dicom folder path either doesn't exist or not a folder.") - # READ ALL DICOM FILES AND UPDATE ATTRIBUTES FOR FURTHER PROCESSING - for file in tqdm(stack_folder): - if pydicom.misc.is_dicom(file): - try: - info = pydicom.dcmread(str(file)) - if info.Modality in ['MR', 'PT', 'CT']: - ind_series_id = self.__find_uid_cell_index( - info.SeriesInstanceUID, - self.__dicom.cell_series_id)[0] - if ind_series_id == len(self.__dicom.cell_series_id): # New volume - self.__dicom.cell_series_id = self.__dicom.cell_series_id + [info.SeriesInstanceUID] - self.__dicom.cell_frame_id += [info.FrameOfReferenceUID] - self.__dicom.cell_path_images += [[]] - self.__dicom.cell_path_rs = self.__dicom.cell_path_rs + [[]] - self.__dicom.cell_path_images[ind_series_id] += [file] - elif info.Modality == 'RTSTRUCT': - self.__dicom.stack_path_rs += [file] - try: - series_uid = info.ReferencedFrameOfReferenceSequence[ - 0].RTReferencedStudySequence[ - 0].RTReferencedSeriesSequence[ - 0].SeriesInstanceUID - except: - series_uid = 'NotFound' - self.__dicom.stack_series_rs += [series_uid] - try: - frame_uid = info.ReferencedFrameOfReferenceSequence[0].FrameOfReferenceUID - except: - frame_uid = info.FrameOfReferenceUID - self.__dicom.stack_frame_rs += [frame_uid] - else: - print("Modality not supported: ", info.Modality) - - except Exception as e: - print(f'Error while reading: {file}, error: {e}\n') - continue - print('DONE') - - # ASSOCIATE ALL VOLUMES TO THEIR MASK - self.__associate_rt_stuct() - - def process_all_dicoms(self) -> Union[List[MEDscan], None]: - """This function reads the DICOM content of all the sub-folder tree of a starting directory defined by - `path_to_dicoms`. It then organizes the data (files throughout the starting directory are associated by - 'SeriesInstanceUID') in the MEDscan class including the region of interest (ROI) defined by an - associated RTstruct. All MEDscan classes hereby created are saved in `path_save` with a name - varying with every scan. - - Returns: - List[MEDscan]: List of MEDscan instances. - """ - ray.init(local_mode=True, include_dashboard=True) - - print('--> Reading all DICOM objects to create MEDscan classes') - self.__read_all_dicoms() - - print('--> Processing DICOMs and creating MEDscan objects') - n_scans = len(self.__dicom.cell_path_images) - if self.n_batch is None: - n_batch = 1 - elif n_scans < self.n_batch: - n_batch = n_scans - else: - n_batch = self.n_batch - - # Distribute the first tasks to all workers - pds = [ProcessDICOM( - self.__dicom.cell_path_images[i], - self.__dicom.cell_path_rs[i], - self.paths._path_save, - self.save) - for i in range(n_batch)] - - ids = [pd.process_files() for pd in pds] - - # Update the path to the created instances - for name_save in ray.get(ids): - if self.paths._path_save: - self.path_to_objects.append(str(self.paths._path_save / name_save)) - # Update processing summary - if name_save.split('_')[0].count('-') >= 2: - scan_type = name_save[name_save.find('__')+2 : name_save.find('.')] - if name_save.split('-')[0] not in self.__studies: - self.__studies.append(name_save.split('-')[0]) # add new study - if name_save.split('-')[1] not in self.__institutions: - self.__institutions.append(name_save.split('-')[1]) # add new study - if name_save.split('-')[0] not in self.summary: - self.summary[name_save.split('-')[0]] = {} - if name_save.split('-')[1] not in self.summary[name_save.split('-')[0]]: - self.summary[name_save.split('-')[0]][name_save.split('-')[1]] = {} # add new institution - if scan_type not in self.__scans: - self.__scans.append(scan_type) - if scan_type not in self.summary[name_save.split('-')[0]][name_save.split('-')[1]]: - self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type] = [] - if name_save not in self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type]: - self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type].append(name_save) - else: - if self.save: - logging.warning(f"The patient ID of the following file: {name_save} does not respect the MEDimage "\ - "naming convention 'study-institution-id' (Ex: Glioma-TCGA-001)") - - nb_job_left = n_scans - n_batch - - # Distribute the remaining tasks - for _ in trange(n_scans): - _, ids = ray.wait(ids, num_returns=1) - if nb_job_left > 0: - idx = n_scans - nb_job_left - pd = ProcessDICOM( - self.__dicom.cell_path_images[idx], - self.__dicom.cell_path_rs[idx], - self.paths._path_save, - self.save) - ids.extend([pd.process_files()]) - nb_job_left -= 1 - - # Update the path to the created instances - for name_save in ray.get(ids): - if self.paths._path_save: - self.path_to_objects.extend(str(self.paths._path_save / name_save)) - # Update processing summary - if name_save.split('_')[0].count('-') >= 2: - scan_type = name_save[name_save.find('__')+2 : name_save.find('.')] - if name_save.split('-')[0] not in self.__studies: - self.__studies.append(name_save.split('-')[0]) # add new study - if name_save.split('-')[1] not in self.__institutions: - self.__institutions.append(name_save.split('-')[1]) # add new study - if name_save.split('-')[0] not in self.summary: - self.summary[name_save.split('-')[0]] = {} - if name_save.split('-')[1] not in self.summary[name_save.split('-')[0]]: - self.summary[name_save.split('-')[0]][name_save.split('-')[1]] = {} # add new institution - if scan_type not in self.__scans: - self.__scans.append(scan_type) - if scan_type not in self.summary[name_save.split('-')[0]][name_save.split('-')[1]]: - self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type] = [] - if name_save not in self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type]: - self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type].append(name_save) - else: - if self.save: - logging.warning(f"The patient ID of the following file: {name_save} does not respect the MEDimage "\ - "naming convention 'study-institution-id' (Ex: Glioma-TCGA-001)") - print('DONE') - - def __read_all_niftis(self) -> None: - """Reads all files in the initial path and organizes other path to images and roi - in the class attributes. - - Returns: - None. - """ - print('\n--> Scanning all folders in initial directory') - if not self.paths._path_to_niftis: - raise ValueError("The path to the niftis is not defined") - p = Path(self.paths._path_to_niftis) - e_rglob1 = '*.nii' - e_rglob2 = '*.nii.gz' - - # EXTRACT ALL FILES IN THE PATH TO DICOMS - if p.is_dir(): - self.__nifti.stack_path_all = list(p.rglob(e_rglob1)) - self.__nifti.stack_path_all.extend(list(p.rglob(e_rglob2))) - else: - raise TypeError(f"{p} must be a path to a directory") - - all_niftis = list(self.__nifti.stack_path_all) - for i in trange(0, len(all_niftis)): - if 'ROI' in all_niftis[i].name.split("."): - self.__nifti.stack_path_roi.append(all_niftis[i]) - else: - self.__nifti.stack_path_images.append(all_niftis[i]) - print('DONE') - - def __associate_roi_to_image( - self, - image_file: Union[Path, str], - medscan: MEDscan, - nifti: nib.Nifti1Image, - path_roi_data: Path = None - ) -> MEDscan: - """Extracts all ROI data from the given path for the given patient ID and updates all class attributes with - the new extracted data. - - Args: - image_file(Union[Path, str]): Path to the ROI data. - medscan (MEDscan): MEDscan class instance that will hold the data. - - Returns: - MEDscan: Returns a MEDscan instance with updated roi attributes. - """ - image_file = Path(image_file) - roi_index = 0 - - if not path_roi_data: - if not self.paths._path_to_niftis: - raise ValueError("The path to the niftis is not defined") - else: - path_roi_data = self.paths._path_to_niftis - - for file in path_roi_data.glob('*.nii.gz'): - _id = image_file.name.split("(")[0] # id is PatientID__ImagingScanName - # Load the patient's ROI nifti files: - if file.name.startswith(_id) and 'ROI' in file.name.split("."): - roi = nib.load(file) - roi = image.resample_to_img(roi, nifti, interpolation='nearest') - roi_data = roi.get_fdata() - roi_name = file.name[file.name.find("(") + 1 : file.name.find(")")] - name_set = file.name[file.name.find("_") + 2 : file.name.find("(")] - medscan.data.ROI.update_indexes(key=roi_index, indexes=np.nonzero(roi_data.flatten())) - medscan.data.ROI.update_name_set(key=roi_index, name_set=name_set) - medscan.data.ROI.update_roi_name(key=roi_index, roi_name=roi_name) - roi_index += 1 - return medscan - - def __associate_spatialRef(self, nifti_file: Union[Path, str], medscan: MEDscan) -> MEDscan: - """Computes the imref3d spatialRef using a NIFTI file and updates the spatialRef attribute. - - Args: - nifti_file(Union[Path, str]): Path to the nifti data. - medscan (MEDscan): MEDscan class instance that will hold the data. - - Returns: - MEDscan: Returns a MEDscan instance with updated spatialRef attribute. - """ - # Loading the nifti file : - nifti = nib.load(nifti_file) - nifti_data = medscan.data.volume.array - - # spatialRef Creation - pixel_x = abs(nifti.affine[0, 0]) - pixel_y = abs(nifti.affine[1, 1]) - slices = abs(nifti.affine[2, 2]) - min_grid = nifti.affine[:3, 3] * [-1.0, -1.0, 1.0] # x and y are flipped - min_x_grid = min_grid[0] - min_y_grid = min_grid[1] - min_z_grid = min_grid[2] - size_image = np.shape(nifti_data) - spatialRef = imref3d(size_image, abs(pixel_x), abs(pixel_y), abs(slices)) - spatialRef.XWorldLimits = (np.array(spatialRef.XWorldLimits) - - (spatialRef.XWorldLimits[0] - - (min_x_grid-pixel_x/2)) - ).tolist() - spatialRef.YWorldLimits = (np.array(spatialRef.YWorldLimits) - - (spatialRef.YWorldLimits[0] - - (min_y_grid-pixel_y/2)) - ).tolist() - spatialRef.ZWorldLimits = (np.array(spatialRef.ZWorldLimits) - - (spatialRef.ZWorldLimits[0] - - (min_z_grid-slices/2)) - ).tolist() - - # Converting the results into lists - spatialRef.ImageSize = spatialRef.ImageSize.tolist() - spatialRef.XIntrinsicLimits = spatialRef.XIntrinsicLimits.tolist() - spatialRef.YIntrinsicLimits = spatialRef.YIntrinsicLimits.tolist() - spatialRef.ZIntrinsicLimits = spatialRef.ZIntrinsicLimits.tolist() - - # update spatialRef in the volume sub-class - medscan.data.volume.update_spatialRef(spatialRef) - - return medscan - - def __process_one_nifti(self, nifti_file: Union[Path, str], path_data) -> MEDscan: - """ - Processes one NIfTI file to create a MEDscan class instance. - - Args: - nifti_file (Union[Path, str]): Path to the NIfTI file. - path_data (Union[Path, str]): Path to the data. - - Returns: - MEDscan: MEDscan class instance. - """ - medscan = MEDscan() - medscan.patientID = os.path.basename(nifti_file).split("_")[0] - medscan.type = os.path.basename(nifti_file).split(".")[-3] - medscan.series_description = nifti_file.name[nifti_file.name.find('__') + 2: nifti_file.name.find('(')] - medscan.format = "nifti" - medscan.data.set_orientation(orientation="Axial") - medscan.data.set_patient_position(patient_position="HFS") - medscan.data.volume.array = nib.load(nifti_file).get_fdata() - medscan.data.volume.scan_rot = None - - # Update spatialRef - self.__associate_spatialRef(nifti_file, medscan) - - # Assiocate ROI - medscan = self.__associate_roi_to_image(nifti_file, medscan, nib.load(nifti_file), path_data) - - return medscan - - def process_all(self) -> None: - """Processes both DICOM & NIfTI content to create MEDscan classes - """ - self.process_all_dicoms() - self.process_all_niftis() - - def process_all_niftis(self) -> List[MEDscan]: - """This function reads the NIfTI content of all the sub-folder tree of a starting directory. - It then organizes the data in the MEDscan class including the region of interest (ROI) - defined by an associated mask file. All MEDscan classes hereby created are saved in a specific path - with a name specific name varying with every scan. - - Args: - None. - - Returns: - List[MEDscan]: List of MEDscan instances. - """ - - # Reading all NIfTI files - self.__read_all_niftis() - - # Create the MEDscan instances - print('--> Reading all NIfTI objects (imaging volumes & masks) to create MEDscan classes') - list_instances = [] - for file in tqdm(self.__nifti.stack_path_images): - # Assert the list of instances does not exceed the a size of 10 - if len(list_instances) >= 10: - print('The number of MEDscan instances exceeds 10, please consider saving the instances') - break - # INITIALIZE MEDscan INSTANCE AND UPDATE ATTRIBUTES - medscan = MEDscan() - medscan.patientID = os.path.basename(file).split("_")[0] - medscan.type = os.path.basename(file).split(".")[-3] - medscan.series_description = file.name[file.name.find('__') + 2: file.name.find('(')] - medscan.format = "nifti" - medscan.data.set_orientation(orientation="Axial") - medscan.data.set_patient_position(patient_position="HFS") - medscan.data.volume.array = nib.load(file).get_fdata() - - # RAS to LPS - #medscan.data.volume.convert_to_LPS() - medscan.data.volume.scan_rot = None - - # Update spatialRef - medscan = self.__associate_spatialRef(file, medscan) - - # Get ROI - medscan = self.__associate_roi_to_image(file, medscan, nib.load(file)) - - # SAVE MEDscan INSTANCE - if self.save and self.paths._path_save: - save_MEDscan(medscan, self.paths._path_save) - else: - list_instances.append(medscan) - - # Update the path to the created instances - name_save = self.__get_MEDscan_name_save(medscan) - - # Clear memory - del medscan - - # Update the path to the created instances - if self.paths._path_save: - self.path_to_objects.append(str(self.paths._path_save / name_save)) - - # Update processing summary - if name_save.split('_')[0].count('-') >= 2: - scan_type = name_save[name_save.find('__')+2 : name_save.find('.')] - if name_save.split('-')[0] not in self.__studies: - self.__studies.append(name_save.split('-')[0]) # add new study - if name_save.split('-')[1] not in self.__institutions: - self.__institutions.append(name_save.split('-')[1]) # add new institution - if name_save.split('-')[0] not in self.summary: - self.summary[name_save.split('-')[0]] = {} # add new study to summary - if name_save.split('-')[1] not in self.summary[name_save.split('-')[0]]: - self.summary[name_save.split('-')[0]][name_save.split('-')[1]] = {} # add new institution - if scan_type not in self.__scans: - self.__scans.append(scan_type) - if scan_type not in self.summary[name_save.split('-')[0]][name_save.split('-')[1]]: - self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type] = [] - if name_save not in self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type]: - self.summary[name_save.split('-')[0]][name_save.split('-')[1]][scan_type].append(name_save) - else: - if self.save: - logging.warning(f"The patient ID of the following file: {name_save} does not respect the MEDimage "\ - "naming convention 'study-institution-id' (Ex: Glioma-TCGA-001)") - print('DONE') - - if list_instances: - return list_instances - - def update_from_csv(self, path_csv: Union[str, Path] = None) -> None: - """Updates the class from a given CSV and summarizes the processed scans again according to it. - - Args: - path_csv(optional, Union[str, Path]): Path to a csv file, if not given, will check - for csv info in the class attributes. - - Returns: - None - """ - if not (path_csv or self.paths._path_csv): - print('No csv provided, no updates will be made') - else: - if path_csv: - self.paths._path_csv = path_csv - # Extract roi type label from csv file name - name_csv = self.paths._path_csv.name - roi_type_label = name_csv[name_csv.find('_')+1 : name_csv.find('.')] - - # Create a dictionary - csv_data = {} - csv_data[roi_type_label] = pd.read_csv(self.paths._path_csv) - self.csv_data = csv_data - self.summarize() - - def summarize(self, retrun_summary: bool = False) -> None: - """Creates and shows a summary of processed scans organized by study, institution, scan type and roi type - - Args: - retrun_summary (bool, optional): If True, will return the summary as a dictionary. - - Returns: - None - """ - def count_scans(summary): - count = 0 - if type(summary) == dict: - for study in summary: - if type(summary[study]) == dict: - for institution in summary[study]: - if type(summary[study][institution]) == dict: - for scan in self.summary[study][institution]: - count += len(summary[study][institution][scan]) - else: - count += len(summary[study][institution]) - else: - count += len(summary[study]) - elif type(summary) == list: - count = len(summary) - return count - - summary_df = pd.DataFrame(columns=['study', 'institution', 'scan_type', 'roi_type', 'count']) - - for study in self.summary: - summary_df = summary_df.append({ - 'study': study, - 'institution': "", - 'scan_type': "", - 'roi_type': "", - 'count' : count_scans(self.summary) - }, ignore_index=True) - for institution in self.summary[study]: - summary_df = summary_df.append({ - 'study': study, - 'institution': institution, - 'scan_type': "", - 'roi_type': "", - 'count' : count_scans(self.summary[study][institution]) - }, ignore_index=True) - for scan in self.summary[study][institution]: - summary_df = summary_df.append({ - 'study': study, - 'institution': institution, - 'scan_type': scan, - 'roi_type': "", - 'count' : count_scans(self.summary[study][institution][scan]) - }, ignore_index=True) - if self.csv_data: - roi_count = 0 - for roi_type in self.csv_data: - csv_table = pd.DataFrame(self.csv_data[roi_type]) - csv_table['under'] = '_' - csv_table['dot'] = '.' - csv_table['npy'] = '.npy' - name_patients = (pd.Series( - csv_table[['PatientID', 'under', 'under', - 'ImagingScanName', - 'dot', - 'ImagingModality', - 'npy']].fillna('').values.tolist()).str.join('')).tolist() - for patient_id in self.summary[study][institution][scan]: - if patient_id in name_patients: - roi_count += 1 - summary_df = summary_df.append({ - 'study': study, - 'institution': institution, - 'scan_type': scan, - 'roi_type': roi_type, - 'count' : roi_count - }, ignore_index=True) - print(summary_df.to_markdown(index=False)) - - if retrun_summary: - return summary_df - - def __pre_radiomics_checks_dimensions( - self, - path_data: Union[Path, str] = None, - wildcards_dimensions: List[str] = [], - min_percentile: float = 0.05, - max_percentile: float = 0.95, - save: bool = False - ) -> None: - """Finds proper voxels dimension options for radiomics analyses for a group of scans - - Args: - path_data (Path, optional): Path to the MEDscan objects, if not specified will use ``path_save`` from the - inner-class ``Paths`` in the current instance. - wildcards_dimensions(List[str], optional): List of wildcards that determines the scans - that will be analyzed. You can learn more about wildcards in - :ref:`this link `. - min_percentile (float, optional): Minimum percentile to use for the histograms. Defaults to 0.05. - max_percentile (float, optional): Maximum percentile to use for the histograms. Defaults to 0.95. - save (bool, optional): If True, will save the results in a json file. Defaults to False. - - Returns: - None. - """ - xy_dim = { - "data": [], - "mean": [], - "median": [], - "std": [], - "min": [], - "max": [], - f"p{min_percentile}": [], - f"p{max_percentile}": [] - } - z_dim = { - "data": [], - "mean": [], - "median": [], - "std": [], - "min": [], - "max": [], - f"p{min_percentile}": [], - f"p{max_percentile}": [] - } - if type(wildcards_dimensions) is str: - wildcards_dimensions = [wildcards_dimensions] - - if len(wildcards_dimensions) == 0: - print("Wildcard is empty, the pre-checks will be aborted") - return - - # Updating plotting params - plt.rcParams["figure.figsize"] = (20,20) - plt.rcParams.update({'font.size': 22}) - - # TODO: seperate by studies and scan type (MRscan, CTscan...) - # TODO: Two summaries (df, list of names saves) -> - # name_save = name_save(ROI) : Glioma-Huashan-001__T1.MRscan.npy({GTV}) - file_paths = list() - for w in range(len(wildcards_dimensions)): - wildcard = wildcards_dimensions[w] - if path_data: - file_paths = get_file_paths(path_data, wildcard) - elif self.paths._path_save: - file_paths = get_file_paths(self.paths._path_save, wildcard) - else: - raise ValueError("Path data is invalid.") - n_files = len(file_paths) - xy_dim["data"] = np.zeros((n_files, 1)) - xy_dim["data"] = np.multiply(xy_dim["data"], np.nan) - z_dim["data"] = np.zeros((n_files, 1)) - z_dim["data"] = np.multiply(z_dim["data"], np.nan) - for f in tqdm(range(len(file_paths))): - try: - if file_paths[f].name.endswith("nii.gz") or file_paths[f].name.endswith("nii"): - medscan = nib.load(file_paths[f]) - xy_dim["data"][f] = medscan.header.get_zooms()[0] - z_dim["data"][f] = medscan.header.get_zooms()[2] - else: - medscan = np.load(file_paths[f], allow_pickle=True) - xy_dim["data"][f] = medscan.data.volume.spatialRef.PixelExtentInWorldX - z_dim["data"][f] = medscan.data.volume.spatialRef.PixelExtentInWorldZ - except Exception as e: - print(e) - - # Running analysis - xy_dim["data"] = np.concatenate(xy_dim["data"]) - xy_dim["mean"] = np.mean(xy_dim["data"][~np.isnan(xy_dim["data"])]) - xy_dim["median"] = np.median(xy_dim["data"][~np.isnan(xy_dim["data"])]) - xy_dim["std"] = np.std(xy_dim["data"][~np.isnan(xy_dim["data"])]) - xy_dim["min"] = np.min(xy_dim["data"][~np.isnan(xy_dim["data"])]) - xy_dim["max"] = np.max(xy_dim["data"][~np.isnan(xy_dim["data"])]) - xy_dim[f"p{min_percentile}"] = np.percentile(xy_dim["data"][~np.isnan(xy_dim["data"])], - min_percentile) - xy_dim[f"p{max_percentile}"] = np.percentile(xy_dim["data"][~np.isnan(xy_dim["data"])], - max_percentile) - z_dim["mean"] = np.mean(z_dim["data"][~np.isnan(z_dim["data"])]) - z_dim["median"] = np.median(z_dim["data"][~np.isnan(z_dim["data"])]) - z_dim["std"] = np.std(z_dim["data"][~np.isnan(z_dim["data"])]) - z_dim["min"] = np.min(z_dim["data"][~np.isnan(z_dim["data"])]) - z_dim["max"] = np.max(z_dim["data"][~np.isnan(z_dim["data"])]) - z_dim[f"p{min_percentile}"] = np.percentile(z_dim["data"][~np.isnan(z_dim["data"])], - min_percentile) - z_dim[f"p{max_percentile}"] = np.percentile(z_dim["data"][~np.isnan(z_dim["data"])], max_percentile) - xy_dim["data"] = xy_dim["data"].tolist() - z_dim["data"] = z_dim["data"].tolist() - - # Plotting xy-spacing data histogram - df_xy = pd.DataFrame(xy_dim["data"], columns=['data']) - del xy_dim["data"] # no interest in keeping data (we only need statistics) - ax = df_xy.hist(column='data') - min_quant, max_quant, median = df_xy.quantile(min_percentile), df_xy.quantile(max_percentile), df_xy.median() - for x in ax[0]: - x.axvline(min_quant.data, linestyle=':', color='r', label=f"Min Percentile: {float(min_quant):.3f}") - x.axvline(max_quant.data, linestyle=':', color='g', label=f"Max Percentile: {float(max_quant):.3f}") - x.axvline(median.data, linestyle='solid', color='gold', label=f"Median: {float(median.data):.3f}") - x.grid(False) - plt.title(f"Voxels xy-spacing checks for {wildcard}") - plt.legend() - # Save the plot - if save: - plt.savefig(self.paths._path_save_checks / ('Voxels_xy_check.png')) - else: - plt.show() - - # Plotting z-spacing data histogram - df_z = pd.DataFrame(z_dim["data"], columns=['data']) - del z_dim["data"] # no interest in keeping data (we only need statistics) - ax = df_z.hist(column='data') - min_quant, max_quant, median = df_z.quantile(min_percentile), df_z.quantile(max_percentile), df_z.median() - for x in ax[0]: - x.axvline(min_quant.data, linestyle=':', color='r', label=f"Min Percentile: {float(min_quant):.3f}") - x.axvline(max_quant.data, linestyle=':', color='g', label=f"Max Percentile: {float(max_quant):.3f}") - x.axvline(median.data, linestyle='solid', color='gold', label=f"Median: {float(median.data):.3f}") - x.grid(False) - plt.title(f"Voxels z-spacing checks for {wildcard}") - plt.legend() - # Save the plot - if save: - plt.savefig(self.paths._path_save_checks / ('Voxels_z_check.png')) - else: - plt.show() - - # Saving files using wildcard for name - if save: - wildcard = str(wildcard).replace('*', '').replace('.npy', '.json') - save_json(self.paths._path_save_checks / ('xyDim_' + wildcard), xy_dim, cls=NumpyEncoder) - save_json(self.paths._path_save_checks / ('zDim_' + wildcard), z_dim, cls=NumpyEncoder) - - def __pre_radiomics_checks_window( - self, - path_data: Union[str, Path] = None, - wildcards_window: List = [], - path_csv: Union[str, Path] = None, - min_percentile: float = 0.05, - max_percentile: float = 0.95, - bin_width: int = 0, - hist_range: list = [], - nifti: bool = True, - save: bool = False - ) -> None: - """Finds proper re-segmentation ranges options for radiomics analyses for a group of scans - - Args: - path_data (Path, optional): Path to the MEDscan objects, if not specified will use ``path_save`` from the - inner-class ``Paths`` in the current instance. - wildcards_window(List[str], optional): List of wildcards that determines the scans - that will be analyzed. You can learn more about wildcards in - :ref:`this link `. - path_csv(Union[str, Path], optional): Path to a csv file containing a list of the scans that will be - analyzed (a CSV file for a single ROI type). - min_percentile (float, optional): Minimum percentile to use for the histograms. Defaults to 0.05. - max_percentile (float, optional): Maximum percentile to use for the histograms. Defaults to 0.95. - bin_width(int, optional): Width of the bins for the histograms. If not provided, will use the - default number of bins in the method - :ref:`pandas.DataFrame.hist `: 10 bins. - hist_range(list, optional): Range of the histograms. If empty, will use the minimum and maximum values. - nifti(bool, optional): If True, will use the NIfTI files, otherwise will use the numpy files. - save (bool, optional): If True, will save the results in a json file. Defaults to False. - - Returns: - None. - """ - # Updating plotting params - plt.rcParams["figure.figsize"] = (20,20) - plt.rcParams.update({'font.size': 22}) - - if type(wildcards_window) is str: - wildcards_window = [wildcards_window] - - if len(wildcards_window) == 0: - print("Wilcards is empty") - return - if path_csv: - self.paths._path_csv = Path(path_csv) - roi_table = pd.read_csv(self.paths._path_csv) - if nifti: - roi_table['under'] = '_' - roi_table['dot'] = '.' - roi_table['roi_label'] = 'GTV' - roi_table['oparenthesis'] = '(' - roi_table['cparenthesis'] = ')' - roi_table['ext'] = '.nii.gz' - patient_names = (pd.Series( - roi_table[['PatientID', 'under', 'under', - 'ImagingScanName', - 'oparenthesis', - 'roi_label', - 'cparenthesis', - 'dot', - 'ImagingModality', - 'ext']].fillna('').values.tolist()).str.join('')).tolist() - else: - roi_names = [[], [], []] - roi_names[0] = roi_table['PatientID'] - roi_names[1] = roi_table['ImagingScanName'] - roi_names[2] = roi_table['ImagingModality'] - patient_names = get_patient_names(roi_names) - for w in range(len(wildcards_window)): - temp_val = [] - temp = [] - file_paths = [] - roi_data= { - "data": [], - "mean": [], - "median": [], - "std": [], - "min": [], - "max": [], - f"p{min_percentile}": [], - f"p{max_percentile}": [] - } - wildcard = wildcards_window[w] - if path_data: - file_paths = get_file_paths(path_data, wildcard) - elif self.paths._path_save: - path_data = self.paths._path_save - file_paths = get_file_paths(self.paths._path_save, wildcard) - else: - raise ValueError("Path data is invalid.") - n_files = len(file_paths) - i = 0 - for f in tqdm(range(n_files)): - file = file_paths[f] - _, filename = os.path.split(file) - filename, ext = os.path.splitext(filename) - patient_name = filename + ext - try: - if file.name.endswith('nii.gz') or file.name.endswith('nii'): - medscan = self.__process_one_nifti(file, path_data) - else: - medscan = np.load(file, allow_pickle=True) - if re.search('PTscan', wildcard) and medscan.format != 'nifti': - medscan.data.volume.array = compute_suv_map( - np.double(medscan.data.volume.array), - medscan.dicomH[2]) - patient_names = pd.Index(patient_names) - ind_roi = patient_names.get_loc(patient_name) - name_roi = roi_table.loc[ind_roi][3] - vol_obj_init, roi_obj_init = get_roi_from_indexes(medscan, name_roi, 'box') - temp = vol_obj_init.data[roi_obj_init.data == 1] - temp_val.append(len(temp)) - roi_data["data"].append(np.zeros(shape=(n_files, temp_val[i]))) - roi_data["data"][i] = temp - i+=1 - del medscan - del vol_obj_init - del roi_obj_init - except Exception as e: - print(f"Problem with patient {patient_name}, error: {e}") - - roi_data["data"] = np.concatenate(roi_data["data"]) - roi_data["mean"] = np.mean(roi_data["data"][~np.isnan(roi_data["data"])]) - roi_data["median"] = np.median(roi_data["data"][~np.isnan(roi_data["data"])]) - roi_data["std"] = np.std(roi_data["data"][~np.isnan(roi_data["data"])]) - roi_data["min"] = np.min(roi_data["data"][~np.isnan(roi_data["data"])]) - roi_data["max"] = np.max(roi_data["data"][~np.isnan(roi_data["data"])]) - roi_data[f"p{min_percentile}"] = np.percentile(roi_data["data"][~np.isnan(roi_data["data"])], - min_percentile) - roi_data[f"p{max_percentile}"] = np.percentile(roi_data["data"][~np.isnan(roi_data["data"])], - max_percentile) - - # Set bin width if not provided - if bin_width != 0: - if hist_range: - nb_bins = (round(hist_range[1]) - round(hist_range[0])) // bin_width - else: - nb_bins = (round(roi_data["max"]) - round(roi_data["min"])) // bin_width - else: - nb_bins = 10 - if hist_range: - bin_width = int((round(hist_range[1]) - round(hist_range[0])) // nb_bins) - else: - bin_width = int((round(roi_data["max"]) - round(roi_data["min"])) // nb_bins) - nb_bins = int(nb_bins) - - # Set histogram range if not provided - if not hist_range: - hist_range = (roi_data["min"], roi_data["max"]) - - # re-segment data according to histogram range - roi_data["data"] = roi_data["data"][(roi_data["data"] > hist_range[0]) & (roi_data["data"] < hist_range[1])] - df_data = pd.DataFrame(roi_data["data"], columns=['data']) - del roi_data["data"] # no interest in keeping data (we only need statistics) - - # Plot histogram - ax = df_data.hist(column='data', bins=nb_bins, range=(hist_range[0], hist_range[1]), edgecolor='black') - min_quant, max_quant= df_data.quantile(min_percentile), df_data.quantile(max_percentile) - for x in ax[0]: - x.axvline(min_quant.data, linestyle=':', color='r', label=f"{min_percentile*100}% Percentile: {float(min_quant):.3f}") - x.axvline(max_quant.data, linestyle=':', color='g', label=f"{max_percentile*100}% Percentile: {float(max_quant):.3f}") - x.grid(False) - x.xaxis.set_ticks(np.arange(hist_range[0], hist_range[1], bin_width, dtype=int)) - x.set_xticklabels(x.get_xticks(), rotation=45) - x.xaxis.set_tick_params(pad=15) - plt.title(f"Intensity range checks for {wildcard}, bw={bin_width}") - plt.legend() - # Save the plot - if save: - plt.savefig(self.paths._path_save_checks / ('Intensity_range_check_' + f'bw_{bin_width}.png')) - else: - plt.show() - - # save final checks - if save: - wildcard = str(wildcard).replace('*', '').replace('.npy', '.json') - save_json(self.paths._path_save_checks / ('roi_data_' + wildcard), roi_data, cls=NumpyEncoder) - - def pre_radiomics_checks(self, - path_data: Union[str, Path] = None, - wildcards_dimensions: List = [], - wildcards_window: List = [], - path_csv: Union[str, Path] = None, - min_percentile: float = 0.05, - max_percentile: float = 0.95, - bin_width: int = 0, - hist_range: list = [], - nifti: bool = False, - save: bool = False) -> None: - """Finds proper dimension and re-segmentation ranges options for radiomics analyses. - - The resulting files from this method can then be analyzed and used to set up radiomics - parameters options in computation methods. - - Args: - path_data (Path, optional): Path to the MEDscan objects, if not specified will use ``path_save`` from the - inner-class ``Paths`` in the current instance. - wildcards_dimensions(List[str], optional): List of wildcards that determines the scans - that will be analyzed. You can learn more about wildcards in - `this link `_. - wildcards_window(List[str], optional): List of wildcards that determines the scans - that will be analyzed. You can learn more about wildcards in - `this link `_. - path_csv(Union[str, Path], optional): Path to a csv file containing a list of the scans that will be - analyzed (a CSV file for a single ROI type). - min_percentile (float, optional): Minimum percentile to use for the histograms. Defaults to 0.05. - max_percentile (float, optional): Maximum percentile to use for the histograms. Defaults to 0.95. - bin_width(int, optional): Width of the bins for the histograms. If not provided, will use the - default number of bins in the method - :ref:`pandas.DataFrame.hist `: 10 bins. - hist_range(list, optional): Range of the histograms. If empty, will use the minimum and maximum values. - nifti (bool, optional): Set to True if the scans are nifti files. Defaults to False. - save (bool, optional): If True, will save the results in a json file. Defaults to False. - - Returns: - None - """ - # Initialization - path_study = Path.cwd() - - # Load params - if not self.paths._path_pre_checks_settings: - if not wildcards_dimensions or not wildcards_window: - raise ValueError("path to pre-checks settings is None.\ - wildcards_dimensions and wildcards_window need to be specified") - else: - settings = self.paths._path_pre_checks_settings - settings = load_json(settings) - settings = settings['pre_radiomics_checks'] - - # Setting up paths - if 'path_save_checks' in settings and settings['path_save_checks']: - self.paths._path_save_checks = Path(settings['path_save_checks']) - if 'path_csv' in settings and settings['path_csv']: - self.paths._path_csv = Path(settings['path_csv']) - - # Wildcards of groups of files to analyze for dimensions in path_data. - # See for example: https://www.linuxtechtips.com/2013/11/how-wildcards-work-in-linux-and-unix.html - # Keep the cell empty if no dimension checks are to be performed. - if not wildcards_dimensions: - wildcards_dimensions = [] - for i in range(len(settings['wildcards_dimensions'])): - wildcards_dimensions.append(settings['wildcards_dimensions'][i]) - - # ROI intensity window checks params - if not wildcards_window: - wildcards_window = [] - for i in range(len(settings['wildcards_window'])): - wildcards_window.append(settings['wildcards_window'][i]) - - # PRE-RADIOMICS CHECKS - if not self.paths._path_save_checks: - if (path_study / 'checks').exists(): - self.paths._path_save_checks = Path(path_study / 'checks') - else: - os.mkdir(path_study / 'checks') - self.paths._path_save_checks = Path(path_study / 'checks') - else: - if self.paths._path_save_checks.name != 'checks': - if (self.paths._path_save_checks / 'checks').exists(): - self.paths._path_save_checks /= 'checks' - else: - os.mkdir(self.paths._path_save_checks / 'checks') - self.paths._path_save_checks = Path(self.paths._path_save_checks / 'checks') - - # Initializing plotting params - plt.rcParams["figure.figsize"] = (20,20) - plt.rcParams.update({'font.size': 22}) - - start = time() - print('\n\n************************* PRE-RADIOMICS CHECKS *************************', end='') - - # 1. PRE-RADIOMICS CHECKS -- DIMENSIONS - start1 = time() - print('\n--> PRE-RADIOMICS CHECKS -- DIMENSIONS ... ', end='') - self.__pre_radiomics_checks_dimensions( - path_data, - wildcards_dimensions, - min_percentile, - max_percentile, - save) - print('DONE', end='') - time1 = f"{time() - start1:.2f}" - print(f'\nElapsed time: {time1} sec', end='') - - # 2. PRE-RADIOMICS CHECKS - WINDOW - start2 = time() - print('\n\n--> PRE-RADIOMICS CHECKS -- WINDOW ... \n', end='') - self.__pre_radiomics_checks_window( - path_data, - wildcards_window, - path_csv, - min_percentile, - max_percentile, - bin_width, - hist_range, - nifti, - save) - print('DONE', end='') - time2 = f"{time() - start2:.2f}" - print(f'\nElapsed time: {time2} sec', end='') - - time_elapsed = f"{time() - start:.2f}" - print(f'\n\n--> TOTAL TIME FOR PRE-RADIOMICS CHECKS: {time_elapsed} seconds') - print('-------------------------------------------------------------------------------------') - - def perform_mr_imaging_summary(self, - wildcards_scans: List[str], - path_data: Path = None, - path_save_checks: Path = None, - min_percentile: float = 0.05, - max_percentile: float = 0.95 - ) -> None: - """ - Summarizes MRI imaging acquisition parameters. Plots summary histograms - for different dimensions and saves all acquisition parameters locally in JSON files. - - Args: - wildcards_scans (List[str]): List of wildcards that determines the scans - that will be analyzed (Only MRI scans will be analyzed). You can learn more about wildcards in - `this link `_. - For example: ``[\"STS*.MRscan.npy\"]``. - path_data (Path, optional): Path to the MEDscan objects, if not specified will use ``path_save`` from the - inner-class ``Paths`` in the current instance. - path_save_checks (Path, optional): Path where to save the checks, if not specified will use the one - in the current instance. - min_percentile (float, optional): Minimum percentile to use for the histograms. Defaults to 0.05. - max_percentile (float, optional): Maximum percentile to use for the histograms. Defaults to 0.95. - - Returns: - None. - """ - # Initializing data structures - class param: - dates = [] - manufacturer = [] - scanning_sequence = [] - class years: - data = [] - - class fieldStrength: - data = [] - - class repetitionTime: - data = [] - - class echoTime: - data = [] - - class inversionTime: - data = [] - - class echoTrainLength: - data = [] - - class flipAngle: - data = [] - - class numberAverages: - data = [] - - class xyDim: - data = [] - - class zDim: - data = [] - - if len(wildcards_scans) == 0: - print('wildcards_scans is empty') - - # wildcards checks: - no_mr_scan = True - for wildcard in wildcards_scans: - if 'MRscan' in wildcard: - no_mr_scan = False - if no_mr_scan: - raise ValueError(f"wildcards: {wildcards_scans} does not include MR scans. (Only MR scans are supported)") - - # Initialization - if path_data is None: - if self.paths._path_save: - path_data = Path(self.paths._path_save) - else: - print("No path to data was given and path save is None.") - return 0 - - if not path_save_checks: - if self.paths._path_save_checks: - path_save_checks = Path(self.paths._path_save_checks) - else: - if (Path(os.getcwd()) / "checks").exists(): - path_save_checks = Path(os.getcwd()) / "checks" - else: - path_save_checks = (Path(os.getcwd()) / "checks").mkdir() - # Looping through all the different wildcards - for i in tqdm(range(len(wildcards_scans))): - wildcard = wildcards_scans[i] - file_paths = get_file_paths(path_data, wildcard) - n_files = len(file_paths) - param.dates = np.zeros(n_files) - param.years.data = np.zeros((n_files, 1)) - param.years.data = np.multiply(param.years.data, np.NaN) - param.manufacturer = [None] * n_files - param.scanning_sequence = [None] * n_files - param.fieldStrength.data = np.zeros((n_files, 1)) - param.fieldStrength.data = np.multiply(param.fieldStrength.data, np.NaN) - param.repetitionTime.data = np.zeros((n_files, 1)) - param.repetitionTime.data = np.multiply(param.repetitionTime.data, np.NaN) - param.echoTime.data = np.zeros((n_files, 1)) - param.echoTime.data = np.multiply(param.echoTime.data, np.NaN) - param.inversionTime.data = np.zeros((n_files, 1)) - param.inversionTime.data = np.multiply(param.inversionTime.data, np.NaN) - param.echoTrainLength.data = np.zeros((n_files, 1)) - param.echoTrainLength.data = np.multiply(param.echoTrainLength.data, np.NaN) - param.flipAngle.data = np.zeros((n_files, 1)) - param.flipAngle.data = np.multiply(param.flipAngle.data, np.NaN) - param.numberAverages.data = np.zeros((n_files, 1)) - param.numberAverages.data = np.multiply(param.numberAverages.data, np.NaN) - param.xyDim.data = np.zeros((n_files, 1)) - param.xyDim.data = np.multiply(param.xyDim.data, np.NaN) - param.zDim.data = np.zeros((n_files, 1)) - param.zDim.data = np.multiply(param.zDim.data, np.NaN) - - # Loading and recording data - for f in tqdm(range(n_files)): - file = file_paths[f] - - #Open file for warning - try: - warn_file = open(path_save_checks / 'imaging_summary_mr_warnings.txt', 'a') - except IOError: - print("Could not open warning file") - - # Loading Data - try: - print(f'\nCurrently working on: {file}', file = warn_file) - with open(path_data / file, 'rb') as fe: medscan = pickle.load(fe) - - # Example of DICOM header - info = medscan.dicomH[1] - # Recording dates (info.AcquistionDates) - try: - param.dates[f] = info.AcquisitionDate - except AttributeError: - param.dates[f] = info.StudyDate - # Recording years - try: - y = str(param.dates[f]) # Only the first four characters represent the years - param.years.data[f] = y[0:4] - except Exception as e: - print(f'Cannot read years of: {file}. Error: {e}', file = warn_file) - # Recording manufacturers - try: - param.manufacturer[f] = info.Manufacturer - except Exception as e: - print(f'Cannot read manufacturer of: {file}. Error: {e}', file = warn_file) - # Recording scanning sequence - try: - param.scanning_sequence[f] = info.scanning_sequence - except Exception as e: - print(f'Cannot read scanning sequence of: {file}. Error: {e}', file = warn_file) - # Recording field strength - try: - param.fieldStrength.data[f] = info.MagneticFieldStrength - except Exception as e: - print(f'Cannot read field strength of: {file}. Error: {e}', file = warn_file) - # Recording repetition time - try: - param.repetitionTime.data[f] = info.RepetitionTime - except Exception as e: - print(f'Cannot read repetition time of: {file}. Error: {e}', file = warn_file) - # Recording echo time - try: - param.echoTime.data[f] = info.EchoTime - except Exception as e: - print(f'Cannot read echo time of: {file}. Error: {e}', file = warn_file) - # Recording inversion time - try: - param.inversionTime.data[f] = info.InversionTime - except Exception as e: - print(f'Cannot read inversion time of: {file}. Error: {e}', file = warn_file) - # Recording echo train length - try: - param.echoTrainLength.data[f] = info.EchoTrainLength - except Exception as e: - print(f'Cannot read echo train length of: {file}. Error: {e}', file = warn_file) - # Recording flip angle - try: - param.flipAngle.data[f] = info.FlipAngle - except Exception as e: - print(f'Cannot read flip angle of: {file}. Error: {e}', file = warn_file) - # Recording number of averages - try: - param.numberAverages.data[f] = info.NumberOfAverages - except Exception as e: - print(f'Cannot read number averages of: {file}. Error: {e}', file = warn_file) - # Recording xy spacing - try: - param.xyDim.data[f] = medscan.data.volume.spatialRef.PixelExtentInWorldX - except Exception as e: - print(f'Cannot read x spacing of: {file}. Error: {e}', file = warn_file) - # Recording z spacing - try: - param.zDim.data[f] = medscan.data.volume.spatialRef.PixelExtentInWorldZ - except Exception as e: - print(f'Cannot read z spacing of: {file}', file = warn_file) - except Exception as e: - print(f'Cannot read file: {file}. Error: {e}', file = warn_file) - - warn_file.close() - - # Summarize data - # Summarizing years - df_years = pd.DataFrame(param.years.data, - columns=['years']).describe(percentiles=[min_percentile, max_percentile], - include='all') - # Summarizing field strength - df_fs = pd.DataFrame(param.fieldStrength.data, - columns=['fieldStrength']).describe(percentiles=[min_percentile, max_percentile], - include='all') - # Summarizing repetition time - df_rt = pd.DataFrame(param.repetitionTime.data, - columns=['repetitionTime']).describe(percentiles=[min_percentile, max_percentile], - include='all') - # Summarizing echo time - df_et = pd.DataFrame(param.echoTime.data, - columns=['echoTime']).describe(percentiles=[min_percentile, max_percentile], - include='all') - # Summarizing inversion time - df_it = pd.DataFrame(param.inversionTime.data, - columns=['inversionTime']).describe(percentiles=[min_percentile, max_percentile], - include='all') - # Summarizing echo train length - df_etl = pd.DataFrame(param.echoTrainLength.data, - columns=['echoTrainLength']).describe(percentiles=[min_percentile, max_percentile], - include='all') - # Summarizing flip angle - df_fa = pd.DataFrame(param.flipAngle.data, - columns=['flipAngle']).describe(percentiles=[min_percentile, max_percentile], - include='all') - # Summarizing number of averages - df_na = pd.DataFrame(param.numberAverages.data, - columns=['numberAverages']).describe(percentiles=[min_percentile, max_percentile], - include='all') - # Summarizing xy-spacing - df_xy = pd.DataFrame(param.xyDim.data, - columns=['xyDim']) - # Summarizing z-spacing - df_z = pd.DataFrame(param.zDim.data, - columns=['zDim']) - - # Plotting xy-spacing histogram - ax = df_xy.hist(column='xyDim') - min_quant, max_quant, average = df_xy.quantile(min_percentile), df_xy.quantile(max_percentile), param.xyDim.data.mean() - for x in ax[0]: - x.axvline(min_quant.xyDim, linestyle=':', color='r', label=f"Min Percentile: {float(min_quant):.3f}") - x.axvline(max_quant.xyDim, linestyle=':', color='g', label=f"Max Percentile: {float(max_quant):.3f}") - x.axvline(average, linestyle='solid', color='gold', label=f"Average: {float(average):.3f}") - x.grid(False) - plt.title(f"MR xy-spacing imaging summary for {wildcard}") - plt.legend() - plt.show() - - # Plotting z-spacing histogram - ax = df_z.hist(column='zDim') - min_quant, max_quant, average = df_z.quantile(min_percentile), df_z.quantile(max_percentile), param.zDim.data.mean() - for x in ax[0]: - x.axvline(min_quant.zDim, linestyle=':', color='r', label=f"Min Percentile: {float(min_quant):.3f}") - x.axvline(max_quant.zDim, linestyle=':', color='g', label=f"Max Percentile: {float(max_quant):.3f}") - x.axvline(average, linestyle='solid', color='gold', label=f"Average: {float(average):.3f}") - x.grid(False) - plt.title(f"MR z-spacing imaging summary for {wildcard}") - plt.legend() - plt.show() - - # Summarizing xy-spacing - df_xy = df_xy.describe(percentiles=[min_percentile, max_percentile], include='all') - # Summarizing z-spacing - df_z = df_z.describe(percentiles=[min_percentile, max_percentile], include='all') - - # Saving data - name_save = wildcard.replace('*', '').replace('.npy', '') - save_name = 'imagingSummary__' + name_save + ".json" - df_all = [df_years, df_fs, df_rt, df_et, df_it, df_etl, df_fa, df_na, df_xy, df_z] - df_all = df_all[0].join(df_all[1:]) - df_all.to_json(path_save_checks / save_name, orient='columns', indent=4) - - def perform_ct_imaging_summary(self, - wildcards_scans: List[str], - path_data: Path = None, - path_save_checks: Path = None, - min_percentile: float = 0.05, - max_percentile: float = 0.95 - ) -> None: - """ - Summarizes CT imaging acquisition parameters. Plots summary histograms - for different dimensions and saves all acquisition parameters locally in JSON files. - - Args: - wildcards_scans (List[str]): List of wildcards that determines the scans - that will be analyzed (Only MRI scans will be analyzed). You can learn more about wildcards in - `this link `_. - For example: ``[\"STS*.CTscan.npy\"]``. - path_data (Path, optional): Path to the MEDscan objects, if not specified will use ``path_save`` from the - inner-class ``Paths`` in the current instance. - path_save_checks (Path, optional): Path where to save the checks, if not specified will use the one - in the current instance. - min_percentile (float, optional): Minimum percentile to use for the histograms. Defaults to 0.05. - max_percentile (float, optional): Maximum percentile to use for the histograms. Defaults to 0.95. - - Returns: - None. - """ - - class param: - manufacturer = [] - dates = [] - kernel = [] - - class years: - data = [] - class voltage: - data = [] - class exposure: - data = [] - class xyDim: - data = [] - class zDim: - data = [] - - if len(wildcards_scans) == 0: - print('wildcards_scans is empty') - - # wildcards checks: - no_mr_scan = True - for wildcard in wildcards_scans: - if 'CTscan' in wildcard: - no_mr_scan = False - if no_mr_scan: - raise ValueError(f"wildcards: {wildcards_scans} does not include CT scans. (Only CT scans are supported)") - - # Initialization - if path_data is None: - if self.paths._path_save: - path_data = Path(self.paths._path_save) - else: - print("No path to data was given and path save is None.") - return 0 - - if not path_save_checks: - if self.paths._path_save_checks: - path_save_checks = Path(self.paths._path_save_checks) - else: - if (Path(os.getcwd()) / "checks").exists(): - path_save_checks = Path(os.getcwd()) / "checks" - else: - path_save_checks = (Path(os.getcwd()) / "checks").mkdir() - - # Looping through all the different wildcards - for i in tqdm(range(len(wildcards_scans))): - wildcard = wildcards_scans[i] - file_paths = get_file_paths(path_data, wildcard) - n_files = len(file_paths) - param.dates = np.zeros(n_files) - param.years.data = np.zeros(n_files) - param.years.data = np.multiply(param.years.data, np.NaN) - param.manufacturer = [None] * n_files - param.voltage.data = np.zeros(n_files) - param.voltage.data = np.multiply(param.voltage.data, np.NaN) - param.exposure.data = np.zeros(n_files) - param.exposure.data = np.multiply(param.exposure.data, np.NaN) - param.kernel = [None] * n_files - param.xyDim.data = np.zeros(n_files) - param.xyDim.data = np.multiply(param.xyDim.data, np.NaN) - param.zDim.data = np.zeros(n_files) - param.zDim.data = np.multiply(param.zDim.data, np.NaN) - - # Loading and recording data - for f in tqdm(range(n_files)): - file = file_paths[f] - - # Open file for warning - try: - warn_file = open(path_save_checks / 'imaging_summary_ct_warnings.txt', 'a') - except IOError: - print("Could not open file") - - # Loading Data - try: - with open(path_data / file, 'rb') as fe: medscan = pickle.load(fe) - print(f'Currently working on: {file}', file=warn_file) - - # DICOM header - info = medscan.dicomH[1] - - # Recording dates - try: - param.dates[f] = info.AcquisitionDate - except AttributeError: - param.dates[f] = info.StudyDate - # Recording years - try: - years = str(param.dates[f]) # Only the first four characters represent the years - param.years.data[f] = years[0:4] - except Exception as e: - print(f'Cannot read dates of : {file}. Error: {e}', file=warn_file) - # Recording manufacturers - try: - param.manufacturer[f] = info.Manufacturer - except Exception as e: - print(f'Cannot read Manufacturer of: {file}. Error: {e}', file=warn_file) - # Recording voltage - try: - param.voltage.data[f] = info.KVP - except Exception as e: - print(f'Cannot read voltage of: {file}. Error: {e}', file=warn_file) - # Recording exposure - try: - param.exposure.data[f] = info.Exposure - except Exception as e: - print(f'Cannot read exposure of: {file}. Error: {e}', file=warn_file) - # Recording reconstruction kernel - try: - param.kernel[f] = info.ConvolutionKernel - except Exception as e: - print(f'Cannot read Kernel of: {file}. Error: {e}', file=warn_file) - # Recording xy spacing - try: - param.xyDim.data[f] = medscan.data.volume.spatialRef.PixelExtentInWorldX - except Exception as e: - print(f'Cannot read x spacing of: {file}. Error: {e}', file=warn_file) - # Recording z spacing - try: - param.zDim.data[f] = medscan.data.volume.spatialRef.PixelExtentInWorldZ - except Exception as e: - print(f'Cannot read z spacing of: {file}. Error: {e}', file=warn_file) - except Exception as e: - print(f'Cannot load file: {file}', file=warn_file) - - warn_file.close() - - # Summarize data - # Summarizing years - df_years = pd.DataFrame(param.years.data, columns=['years']).describe(percentiles=[min_percentile, max_percentile], include='all') - # Summarizing voltage - df_voltage = pd.DataFrame(param.voltage.data, columns=['voltage']).describe(percentiles=[min_percentile, max_percentile], include='all') - # Summarizing exposure - df_exposure = pd.DataFrame(param.exposure.data, columns=['exposure']).describe(percentiles=[min_percentile, max_percentile], include='all') - # Summarizing kernel - df_kernel = pd.DataFrame(param.kernel, columns=['kernel']).describe(percentiles=[min_percentile, max_percentile], include='all') - # Summarize xy spacing - df_xy = pd.DataFrame(param.xyDim.data, columns=['xyDim']).describe(percentiles=[min_percentile, max_percentile], include='all') - # Summarize z spacing - df_z = pd.DataFrame(param.zDim.data, columns=['zDim']).describe(percentiles=[min_percentile, max_percentile], include='all') - # Summarizing xy-spacing - df_xy = pd.DataFrame(param.xyDim.data, columns=['xyDim']) - # Summarizing z-spacing - df_z = pd.DataFrame(param.zDim.data, columns=['zDim']) - - # Plotting xy-spacing histogram - ax = df_xy.hist(column='xyDim') - min_quant, max_quant, average = df_xy.quantile(min_percentile), df_xy.quantile(max_percentile), param.xyDim.data.mean() - for x in ax[0]: - x.axvline(min_quant.xyDim, linestyle=':', color='r', label=f"Min Percentile: {float(min_quant):.3f}") - x.axvline(max_quant.xyDim, linestyle=':', color='g', label=f"Max Percentile: {float(max_quant):.3f}") - x.axvline(average, linestyle='solid', color='gold', label=f"Average: {float(average):.3f}") - x.grid(False) - plt.title(f"CT xy-spacing imaging summary for {wildcard}") - plt.legend() - plt.show() - - # Plotting z-spacing histogram - ax = df_z.hist(column='zDim') - min_quant, max_quant, average = df_z.quantile(min_percentile), df_z.quantile(max_percentile), param.zDim.data.mean() - for x in ax[0]: - x.axvline(min_quant.zDim, linestyle=':', color='r', label=f"Min Percentile: {float(min_quant):.3f}") - x.axvline(max_quant.zDim, linestyle=':', color='g', label=f"Max Percentile: {float(max_quant):.3f}") - x.axvline(average, linestyle='solid', color='gold', label=f"Average: {float(average):.3f}") - x.grid(False) - plt.title(f"CT z-spacing imaging summary for {wildcard}") - plt.legend() - plt.show() - - # Summarizing xy-spacing - df_xy = df_xy.describe(percentiles=[min_percentile, max_percentile], include='all') - # Summarizing z-spacing - df_z = df_z.describe(percentiles=[min_percentile, max_percentile], include='all') - - # Saving data - name_save = wildcard.replace('*', '').replace('.npy', '') - save_name = 'imagingSummary__' + name_save + ".json" - df_all = [df_years, df_voltage, df_exposure, df_kernel, df_xy, df_z] - df_all = df_all[0].join(df_all[1:]) - df_all.to_json(path_save_checks / save_name, orient='columns', indent=4) - - def perform_imaging_summary(self, - wildcards_scans: List[str], - path_data: Path = None, - path_save_checks: Path = None, - min_percentile: float = 0.05, - max_percentile: float = 0.95 - ) -> None: - """ - Summarizes CT and MR imaging acquisition parameters. Plots summary histograms - for different dimensions and saves all acquisition parameters locally in JSON files. - - Args: - wildcards_scans (List[str]): List of wildcards that determines the scans - that will be analyzed (CT and MRI scans will be analyzed). You can learn more about wildcards in - `this link `_. - For example: ``[\"STS*.CTscan.npy\", \"STS*.MRscan.npy\"]``. - path_data (Path, optional): Path to the MEDscan objects, if not specified will use ``path_save`` from the - inner-class ``Paths`` in the current instance. - path_save_checks (Path, optional): Path where to save the checks, if not specified will use the one - in the current instance. - min_percentile (float, optional): Minimum percentile to use for the histograms. Defaults to 0.05. - max_percentile (float, optional): Maximum percentile to use for the histograms. Defaults to 0.95. - - Returns: - None. - """ - # MR imaging summary - wildcards_scans_mr = [wildcard for wildcard in wildcards_scans if 'MRscan' in wildcard] - if len(wildcards_scans_mr) == 0: - print("Cannot perform imaging summary for MR, no MR scan wildcard was given! ") - else: - self.perform_mr_imaging_summary( - wildcards_scans_mr, - path_data, - path_save_checks, - min_percentile, - max_percentile) - # CT imaging summary - wildcards_scans_ct = [wildcard for wildcard in wildcards_scans if 'CTscan' in wildcard] - if len(wildcards_scans_ct) == 0: - print("Cannot perform imaging summary for CT, no CT scan wildcard was given! ") - else: - self.perform_ct_imaging_summary( - wildcards_scans_ct, - path_data, - path_save_checks, - min_percentile, - max_percentile) diff --git a/MEDimage/wrangling/ProcessDICOM.py b/MEDimage/wrangling/ProcessDICOM.py deleted file mode 100644 index afd7269..0000000 --- a/MEDimage/wrangling/ProcessDICOM.py +++ /dev/null @@ -1,512 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import sys -import warnings -from typing import List, Union - -import numpy as np -import pydicom -import ray - -from ..utils.imref import imref3d - -warnings.simplefilter("ignore") - -from pathlib import Path - -from ..MEDscan import MEDscan -from ..processing.segmentation import get_roi -from ..utils.save_MEDscan import save_MEDscan - - -class ProcessDICOM(): - """ - Class to process dicom files and extract imaging volume and 3D masks from it - in order to oganize the data in a MEDscan class object. - """ - - def __init__( - self, - path_images: List[Path], - path_rs: List[Path], - path_save: Union[str, Path], - save: bool) -> None: - """ - Args: - path_images (List[Path]): List of paths to the dicom files of a single scan. - path_rs (List[Path]): List of paths to the RT struct dicom files for the same scan. - path_save (Union[str, Path]): Path to the folder where the MEDscan object will be saved. - save (bool): Whether to save the MEDscan object or not. - - Returns: - None. - """ - self.path_images = path_images - self.path_rs = path_rs - self.path_save = Path(path_save) if path_save is str else path_save - self.save = save - - def __get_dicom_scan_orientation(self, dicom_header: List[pydicom.dataset.FileDataset]) -> str: - """ - Get the orientation of the scan. - - Args: - dicom_header (List[pydicom.dataset.FileDataset]): List of dicom headers. - - Returns: - str: Orientation of the scan. - """ - n_slices = len(dicom_header) - image_patient_positions_x = [dicom_header[i].ImagePositionPatient[0] for i in range(n_slices)] - image_patient_positions_y = [dicom_header[i].ImagePositionPatient[1] for i in range(n_slices)] - image_patient_positions_z = [dicom_header[i].ImagePositionPatient[2] for i in range(n_slices)] - dist = [ - np.median(np.abs(np.diff(image_patient_positions_x))), - np.median(np.abs(np.diff(image_patient_positions_y))), - np.median(np.abs(np.diff(image_patient_positions_z))) - ] - index = dist.index(max(dist)) - if index == 0: - orientation = 'Sagittal' - elif index == 1: - orientation = 'Coronal' - else: - orientation = 'Axial' - - return orientation - - def __merge_slice_pixel_arrays(self, slice_datasets): - first_dataset = slice_datasets[0] - num_rows = first_dataset.Rows - num_columns = first_dataset.Columns - num_slices = len(slice_datasets) - - sorted_slice_datasets = self.__sort_by_slice_spacing(slice_datasets) - - if any(self.__requires_rescaling(d) for d in sorted_slice_datasets): - voxels = np.empty( - (num_columns, num_rows, num_slices), dtype=np.float32) - for k, dataset in enumerate(sorted_slice_datasets): - slope = float(getattr(dataset, 'RescaleSlope', 1)) - intercept = float(getattr(dataset, 'RescaleIntercept', 0)) - voxels[:, :, k] = dataset.pixel_array.T.astype( - np.float32)*slope + intercept - else: - dtype = first_dataset.pixel_array.dtype - voxels = np.empty((num_columns, num_rows, num_slices), dtype=dtype) - for k, dataset in enumerate(sorted_slice_datasets): - voxels[:, :, k] = dataset.pixel_array.T - - return voxels - - def __requires_rescaling(self, dataset): - return hasattr(dataset, 'RescaleSlope') or hasattr(dataset, 'RescaleIntercept') - - def __ijk_to_patient_xyz_transform_matrix(self, slice_datasets): - first_dataset = self.__sort_by_slice_spacing(slice_datasets)[0] - image_orientation = first_dataset.ImageOrientationPatient - row_cosine, column_cosine, slice_cosine = self.__extract_cosines( - image_orientation) - - row_spacing, column_spacing = first_dataset.PixelSpacing - slice_spacing = self.__slice_spacing(slice_datasets) - - transform = np.identity(4, dtype=np.float32) - rotation = np.identity(3, dtype=np.float32) - scaling = np.identity(3, dtype=np.float32) - - transform[:3, 0] = row_cosine*column_spacing - transform[:3, 1] = column_cosine*row_spacing - transform[:3, 2] = slice_cosine*slice_spacing - - transform[:3, 3] = first_dataset.ImagePositionPatient - - rotation[:3, 0] = row_cosine - rotation[:3, 1] = column_cosine - rotation[:3, 2] = slice_cosine - - rotation = np.transpose(rotation) - - scaling[0, 0] = column_spacing - scaling[1, 1] = row_spacing - scaling[2, 2] = slice_spacing - - return transform, rotation, scaling - - def __validate_slices_form_uniform_grid(self, slice_datasets): - """ - Perform various data checks to ensure that the list of slices form a - evenly-spaced grid of data. - Some of these checks are probably not required if the data follows the - DICOM specification, however it seems pertinent to check anyway. - """ - invariant_properties = [ - 'Modality', - 'SOPClassUID', - 'SeriesInstanceUID', - 'Rows', - 'Columns', - 'ImageOrientationPatient', - 'PixelSpacing', - 'PixelRepresentation', - 'BitsAllocated', - 'BitsStored', - 'HighBit', - ] - - for property_name in invariant_properties: - self.__slice_attribute_equal(slice_datasets, property_name) - - self.__validate_image_orientation(slice_datasets[0].ImageOrientationPatient) - - slice_positions = self.__slice_positions(slice_datasets) - self.__check_for_missing_slices(slice_positions) - - def __validate_image_orientation(self, image_orientation): - """ - Ensure that the image orientation is supported - - The direction cosines have magnitudes of 1 (just in case) - - The direction cosines are perpendicular - """ - - row_cosine, column_cosine, slice_cosine = self.__extract_cosines( - image_orientation) - - if not self.__almost_zero(np.dot(row_cosine, column_cosine), 1e-4): - raise ValueError( - "Non-orthogonal direction cosines: {}, {}".format(row_cosine, column_cosine)) - elif not self.__almost_zero(np.dot(row_cosine, column_cosine), 1e-8): - warnings.warn("Direction cosines aren't quite orthogonal: {}, {}".format( - row_cosine, column_cosine)) - - if not self.__almost_one(np.linalg.norm(row_cosine), 1e-4): - raise ValueError( - "The row direction cosine's magnitude is not 1: {}".format(row_cosine)) - elif not self.__almost_one(np.linalg.norm(row_cosine), 1e-8): - warnings.warn( - "The row direction cosine's magnitude is not quite 1: {}".format(row_cosine)) - - if not self.__almost_one(np.linalg.norm(column_cosine), 1e-4): - raise ValueError( - "The column direction cosine's magnitude is not 1: {}".format(column_cosine)) - elif not self.__almost_one(np.linalg.norm(column_cosine), 1e-8): - warnings.warn( - "The column direction cosine's magnitude is not quite 1: {}".format(column_cosine)) - sys.stderr.flush() - - def __is_close(self, a, b, rel_tol=1e-9, abs_tol=0.0): - return abs(a-b) <= max(rel_tol*max(abs(a), abs(b)), abs_tol) - - def __almost_zero(self, value, abs_tol): - return self.__is_close(value, 0.0, abs_tol=abs_tol) - - def __almost_one(self, value, abs_tol): - return self.__is_close(value, 1.0, abs_tol=abs_tol) - - def __extract_cosines(self, image_orientation): - row_cosine = np.array(image_orientation[:3]) - column_cosine = np.array(image_orientation[3:]) - slice_cosine = np.cross(row_cosine, column_cosine) - return row_cosine, column_cosine, slice_cosine - - def __slice_attribute_equal(self, slice_datasets, property_name): - initial_value = getattr(slice_datasets[0], property_name, None) - for slice_idx, dataset in enumerate(slice_datasets[1:]): - value = getattr(dataset, property_name, None) - if value != initial_value: - msg = f'Slice {slice_idx+1} have different value for {property_name}: {value} != {initial_value}' - warnings.warn(msg) - - def __slice_positions(self, slice_datasets): - image_orientation = slice_datasets[0].ImageOrientationPatient - row_cosine, column_cosine, slice_cosine = self.__extract_cosines( - image_orientation) - return [np.dot(slice_cosine, d.ImagePositionPatient) for d in slice_datasets] - - def __check_for_missing_slices(self, slice_positions): - slice_positions_diffs = np.diff(sorted(slice_positions)) - if not np.allclose(slice_positions_diffs, slice_positions_diffs[0], atol=0, rtol=1e-5): - msg = "The slice spacing is non-uniform. Slice spacings:\n{}" - warnings.warn(msg.format(slice_positions_diffs)) - sys.stderr.flush() - if not np.allclose(slice_positions_diffs, slice_positions_diffs[0], atol=0, rtol=1e-1): - raise ValueError('The slice spacing is non-uniform. It appears there are extra slices from another scan') - - def __slice_spacing(self, slice_datasets): - if len(slice_datasets) > 1: - slice_positions = self.__slice_positions(slice_datasets) - slice_positions_diffs = np.diff(sorted(slice_positions)) - return np.mean(slice_positions_diffs) - - return 0.0 - - def __sort_by_slice_spacing(self, slice_datasets): - slice_spacing = self.__slice_positions(slice_datasets) - return [d for (s, d) in sorted(zip(slice_spacing, slice_datasets))] - - def combine_slices(self, slice_datasets: List[pydicom.dataset.FileDataset]) -> List[np.ndarray]: - """ - Given a list of pydicom datasets for an image series, stitch them together into a - three-dimensional numpy array of iamging data. Also calculate a 4x4 affine transformation - matrix that converts the ijk-pixel-indices into the xyz-coordinates in the - DICOM patient's coordinate system and 4x4 rotation and scaling matrix. - If any of the DICOM images contain either the - `Rescale Slope `__ or the - `Rescale Intercept `__ - attributes they will be applied to each slice individually. - This function requires that the datasets: - - - Be in same series (have the same - `Series Instance UID `__, - `Modality `__, - and `SOP Class UID `__). - - The binary storage of each slice must be the same (have the same - `Bits Allocated `__, - `Bits Stored `__, - `High Bit `__, and - `Pixel Representation `__). - - The image slice must approximately form a grid. This means there can not - be any missing internal slices (missing slices on the ends of the dataset - are not detected). It also means that each slice must have the same - `Rows `__, - `Columns `__, - `Pixel Spacing `__, and - `Image Orientation (Patient) `__ - attribute values. - - The direction cosines derived from the - `Image Orientation (Patient) `__ - attribute must, within 1e-4, have a magnitude of 1. The cosines must - also be approximately perpendicular (their dot-product must be within - 1e-4 of 0). Warnings are displayed if any of theseapproximations are - below 1e-8, however, since we have seen real datasets with values up to - 1e-4, we let them pass. - - The `Image Position (Patient) `__ - values must approximately form a line. - - If any of these conditions are not met, a `dicom_numpy.DicomImportException` is raised. - - Args: - slice_datasets (List[pydicom.dataset.FileDataset]): List of dicom headers. - Returns: - List[numpy.ndarray]: List of numpy arrays containing the data extracted the dicom files - (voxels, translation, rotation and scaling matrix). - """ - - if not slice_datasets: - raise ValueError("Must provide at least one DICOM dataset") - - self.__validate_slices_form_uniform_grid(slice_datasets) - - voxels = self.__merge_slice_pixel_arrays(slice_datasets) - transform, rotation, scaling = self.__ijk_to_patient_xyz_transform_matrix( - slice_datasets) - - return voxels, transform, rotation, scaling - - def process_files(self): - """ - Reads DICOM files (imaging volume + ROIs) in the instance data path - and then organizes it in the MEDscan class. - - Args: - None. - - Returns: - medscan (MEDscan): Instance of a MEDscan class. - """ - - return self.process_files_wrapper.remote(self) - - @ray.remote - def process_files_wrapper(self) -> MEDscan: - """ - Wrapper function to process the files. - """ - - # PARTIAL PARSING OF ARGUMENTS - if self.path_images is None: - raise ValueError('At least two arguments must be provided') - - # INITIALIZATION - medscan = MEDscan() - - # IMAGING DATA AND ROI DEFINITION (if applicable) - # Reading DICOM images and headers - dicom_hi = [pydicom.dcmread(str(dicom_file), force=True) - for dicom_file in self.path_images] - - try: - # Determination of the scan orientation - medscan.data.orientation = self.__get_dicom_scan_orientation(dicom_hi) - - # IMPORTANT NOTE: extract_voxel_data using combine_slices from dicom_numpy - # missing slices and oblique restrictions apply see the reference: - # https://dicom-numpy.readthedocs.io/en/latest/index.html#dicom_numpy.combine_slices - try: - voxel_ndarray, ijk_to_xyz, rotation_m, scaling_m = self.combine_slices(dicom_hi) - except ValueError as e: - raise ValueError(f'Invalid DICOM data for combine_slices(). Error: {e}') - - # Alignment of scan coordinates for MR scans - # (inverse of ImageOrientationPatient rotation matrix) - if not np.allclose(rotation_m, np.eye(rotation_m.shape[0])): - medscan.data.volume.scan_rot = rotation_m - - medscan.data.volume.array = voxel_ndarray - medscan.type = dicom_hi[0].Modality + 'scan' - - # 7. Creation of imref3d object - pixel_x = scaling_m[0, 0] - pixel_y = scaling_m[1, 1] - slice_s = scaling_m[2, 2] - min_grid = rotation_m@ijk_to_xyz[:3, 3] - min_x_grid = min_grid[0] - min_y_grid = min_grid[1] - min_z_grid = min_grid[2] - size_image = np.shape(voxel_ndarray) - spatial_ref = imref3d(size_image, pixel_x, pixel_y, slice_s) - spatial_ref.XWorldLimits = (np.array(spatial_ref.XWorldLimits) - - (spatial_ref.XWorldLimits[0] - - (min_x_grid-pixel_x/2))).tolist() - spatial_ref.YWorldLimits = (np.array(spatial_ref.YWorldLimits) - - (spatial_ref.YWorldLimits[0] - - (min_y_grid-pixel_y/2))).tolist() - spatial_ref.ZWorldLimits = (np.array(spatial_ref.ZWorldLimits) - - (spatial_ref.ZWorldLimits[0] - - (min_z_grid-slice_s/2))).tolist() - - # Converting the results into lists - spatial_ref.ImageSize = spatial_ref.ImageSize.tolist() - spatial_ref.XIntrinsicLimits = spatial_ref.XIntrinsicLimits.tolist() - spatial_ref.YIntrinsicLimits = spatial_ref.YIntrinsicLimits.tolist() - spatial_ref.ZIntrinsicLimits = spatial_ref.ZIntrinsicLimits.tolist() - - # Update the spatial reference in the MEDscan class - medscan.data.volume.spatialRef = spatial_ref - - # DICOM HEADERS OF IMAGING DATA - dicom_h = [ - pydicom.dcmread(str(dicom_file),stop_before_pixels=True,force=True) for dicom_file in self.path_images - ] - for i in range(0, len(dicom_h)): - dicom_h[i].remove_private_tags() - medscan.dicomH = dicom_h - - # DICOM RTstruct (if applicable) - if self.path_rs is not None and len(self.path_rs) > 0: - dicom_rs_full = [ - pydicom.dcmread(str(dicom_file), - stop_before_pixels=True, - force=True) - for dicom_file in self.path_rs - ] - for i in range(0, len(dicom_rs_full)): - dicom_rs_full[i].remove_private_tags() - - # GATHER XYZ POINTS OF ROIs USING RTstruct - n_rs = len(dicom_rs_full) if type(dicom_rs_full) is list else dicom_rs_full - contour_num = 0 - for rs in range(n_rs): - n_roi = len(dicom_rs_full[rs].StructureSetROISequence) - for roi in range(n_roi): - if roi!=0: - if dicom_rs_full[rs].StructureSetROISequence[roi].ROIName == \ - dicom_rs_full[rs].StructureSetROISequence[roi-1].ROIName: - continue - points = [] - name_set_strings = ['StructureSetName', 'StructureSetDescription', - 'series_description', 'SeriesInstanceUID'] - for name_field in name_set_strings: - if name_field in dicom_rs_full[rs]: - name_set = getattr(dicom_rs_full[rs], name_field) - name_set_info = name_field - break - - medscan.data.ROI.update_roi_name(key=contour_num, - roi_name=dicom_rs_full[rs].StructureSetROISequence[roi].ROIName) - medscan.data.ROI.update_indexes(key=contour_num, - indexes=None) - medscan.data.ROI.update_name_set(key=contour_num, - name_set=name_set) - medscan.data.ROI.update_name_set_info(key=contour_num, - nameSetInfo=name_set_info) - - try: - n_closed_contour = len(dicom_rs_full[rs].ROIContourSequence[roi].ContourSequence) - ind_closed_contour = [] - for s in range(0, n_closed_contour): - # points stored in the RTstruct file for a given closed - # contour (beware: there can be multiple closed contours - # on a given slice). - pts_temp = dicom_rs_full[rs].ROIContourSequence[roi].ContourSequence[s].ContourData - n_points = int(len(pts_temp) / 3) - if len(pts_temp) > 0: - ind_closed_contour = ind_closed_contour + np.tile(s, n_points).tolist() - if type(points) == list: - points = np.reshape(np.transpose(pts_temp),(n_points, 3)) - else: - points = np.concatenate( - (points, np.reshape(np.transpose(pts_temp), (n_points, 3))), - axis=0 - ) - if n_closed_contour == 0: - print(f'Warning: no contour data found for ROI: \ - {dicom_rs_full[rs].StructureSetROISequence[roi].ROIName}') - else: - # Save the XYZ points in the MEDscan class - medscan.data.ROI.update_indexes( - key=contour_num, - indexes=np.concatenate( - (points, - np.reshape(ind_closed_contour, (len(ind_closed_contour), 1))), - axis=1) - ) - # Compute the ROI box - _, roi_obj = get_roi( - medscan, - name_roi='{' + dicom_rs_full[rs].StructureSetROISequence[roi].ROIName + '}', - box_string='full' - ) - - # Save the ROI box non-zero indexes in the MEDscan class - medscan.data.ROI.update_indexes(key=contour_num, indexes=np.nonzero(roi_obj.data.flatten())) - - except Exception as e: - if 'SeriesDescription' in dicom_h[0]: - print(f'patientID: {dicom_hi[0].PatientID} Modality: {dicom_hi[0].SeriesDescription} error: \ - {str(e)} n_roi: {str(roi)} n_rs: {str(rs)}') - else: - print(f'patientID: {dicom_hi[0].PatientID} Modality: {dicom_hi[0].Modality} error: \ - {str(e)} n_roi: {str(roi)} n_rs: {str(rs)}') - medscan.data.ROI.update_indexes(key=contour_num, indexes=np.NaN) - contour_num += 1 - - # Save additional scan information in the MEDscan class - medscan.data.set_patient_position(patient_position=dicom_h[0].PatientPosition) - medscan.patientID = str(dicom_h[0].PatientID) - medscan.format = "dicom" - if 'SeriesDescription' in dicom_h[0]: - medscan.series_description = dicom_h[0].SeriesDescription - else: - medscan.series_description = dicom_h[0].Modality - - # save MEDscan class instance as a pickle object - if self.save and self.path_save: - name_complete = save_MEDscan(medscan, self.path_save) - del medscan - else: - series_description = medscan.series_description.translate({ord(ch): '-' for ch in '/\\ ()&:*'}) - name_id = medscan.patientID.translate({ord(ch): '-' for ch in '/\\ ()&:*'}) - - # final saving name - name_complete = name_id + '__' + series_description + '.' + medscan.type + '.npy' - - except Exception as e: - if 'SeriesDescription' in dicom_hi[0]: - print(f'patientID: {dicom_hi[0].PatientID} Modality: {dicom_hi[0].SeriesDescription} error: {str(e)}') - else: - print(f'patientID: {dicom_hi[0].PatientID} Modality: {dicom_hi[0].Modality} error: {str(e)}') - return '' - - return name_complete diff --git a/MEDimage/wrangling/__init__.py b/MEDimage/wrangling/__init__.py deleted file mode 100644 index 240b253..0000000 --- a/MEDimage/wrangling/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from . import * -from .DataManager import * -from .ProcessDICOM import * diff --git a/Makefile.mk b/Makefile.mk index 803d5c5..19e2f59 100644 --- a/Makefile.mk +++ b/Makefile.mk @@ -2,7 +2,7 @@ # Configuration variables # -WORKDIR?=./MEDimage +WORKDIR?=./MEDiml REQUIREMENTS_TXT?=environment.yml SETUP_PY?=setup.py python_version := $(wordlist 2,4,$(subst ., ,$(shell python --version 2>&1))) @@ -14,7 +14,7 @@ python_version := $(wordlist 2,4,$(subst ., ,$(shell python --version 2>&1))) .PHONY: create_environment create_environment: conda update --yes --name base --channel defaults conda - conda env create --name medimage --file environment.yml + conda env create --name mediml --file environment.yml .PHONY: clean clean: diff --git a/README.md b/README.md index 74e5d25..1702442 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![PyPI - Python Version](https://img.shields.io/badge/python-3.8%20|%203.9%20|%203.10-blue)](https://www.python.org/downloads/release/python-380/) [![PyPI - version](https://img.shields.io/badge/pypi-v0.9.8-blue)](https://pypi.org/project/medimage-pkg/) -[![Continuous Integration](https://github.com/MahdiAll99/MEDimage/actions/workflows/python-app.yml/badge.svg)](https://github.com/MahdiAll99/MEDimage/actions/workflows/python-app.yml) +[![Continuous Integration](https://github.com/MEDomicsLab/MEDiml/actions/workflows/python-app.yml/badge.svg)](https://github.com/MEDomicsLab/MEDiml/actions/workflows/python-app.yml) [![Documentation Status](https://readthedocs.org/projects/medimage/badge/?version=latest)](https://medimage.readthedocs.io/en/latest/?badge=latest) [![License: GPL-3](https://img.shields.io/badge/license-GPLv3-blue)](LICENSE) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/MahdiAll99/MEDimage/blob/main/notebooks/tutorial/DataManager-Tutorial.ipynb) @@ -25,26 +25,26 @@ * [9. Statement](#9-statement) ## 1. Introduction -MEDimage is an open-source Python package that can be used for processing multi-modal medical images (MRI, CT or PET) and for extracting their radiomic features. This package is meant to facilitate the processing of medical images and the subsequent computation of all types of radiomic features while maintaining the reproducibility of analyses. This package has been standardized with the [IBSI](https://theibsi.github.io/) norms. +MEDiml is an open-source Python package that can be used for processing multi-modal medical images (MRI, CT or PET) and for extracting their radiomic features. This package is meant to facilitate the processing of medical images and the subsequent computation of all types of radiomic features while maintaining the reproducibility of analyses. This package has been standardized with the [IBSI](https://theibsi.github.io/) norms. -![MEDimage overview](https://raw.githubusercontent.com/MahdiAll99/MEDimage/main/docs/figures/pakcage-overview.png) +![MEDiml overview](https://raw.githubusercontent.com/MahdiAll99/MEDimage/main/docs/figures/pakcage-overview.png) ## 2. Installation ### Python installation -The MEDimage package requires *Python 3.8* or more. If you don't have it installed on your machine, follow the instructions [here](https://github.com/MahdiAll99/MEDimage/blob/main/python.md) to install it. +The MEDiml package requires *Python 3.8* or more. If you don't have it installed on your machine, follow the instructions [here](https://github.com/MEDomicsLab/MEDiml/blob/main/python.md) to install it. ### Package installation -You can easily install the ``MEDimage`` package from PyPI using: +You can easily install the ``MEDiml`` package from PyPI using: ``` -pip install medimage-pkg +pip install MEDiml ``` For more installation options (Conda, Poetry...) check out the [installation documentation](https://medimage.readthedocs.io/en/latest/Installation.html). ## 3. Generating the documentation locally -The [documentation](https://medimage.readthedocs.io/en/latest/) of the MEDimage package was created using Sphinx. However, you can generate and host it locally by compiling the documentation source code using : +The [documentation](https://medimage.readthedocs.io/en/latest/) of the MEDiml package was created using Sphinx. However, you can generate and host it locally by compiling the documentation source code using : ``` cd docs @@ -64,22 +64,22 @@ python -m http.server import os import pickle -import MEDimage +import MEDiml -# Load the DataManager -dm = MEDimage.DataManager(path_dicoms=os.getcwd()) +# Load MEDiml DataManager +dm = MEDiml.DataManager(path_dicoms=os.getcwd()) -# Process the DICOM files and retrieve the MEDimage object +# Process the DICOM files and retrieve the MEDiml object med_obj = dm.process_all_dicoms()[0] # Extract ROI mask from the object -vol_obj_init, roi_obj_init = MEDimage.processing.get_roi_from_indexes( +vol_obj_init, roi_obj_init = MEDiml.processing.get_roi_from_indexes( med_obj, name_roi='{ED}+{ET}+{NET}', box_string='full') # Extract features from the imaging data -local_intensity = MEDimage.biomarkers.local_intensity.extract_all( +local_intensity = MEDiml.biomarkers.local_intensity.extract_all( img_obj=vol_obj_init.data, roi_obj=roi_obj_init.data, res=[1, 1, 1] @@ -99,19 +99,19 @@ med_obj.save_radiomics( ## 5. Tutorials -We have created many [tutorial notebooks](https://github.com/MahdiAll99/MEDimage/tree/main/notebooks) to assist you in learning how to use the different parts of the package. More details can be found in the [documentation](https://medimage.readthedocs.io/en/latest/tutorials.html). +We have created many [tutorial notebooks](https://github.com/MEDomicsLab/MEDiml/tree/main/notebooks) to assist you in learning how to use the different parts of the package. More details can be found in the [documentation](https://medimage.readthedocs.io/en/latest/tutorials.html). ## 6. IBSI Standardization The image biomarker standardization initiative ([IBSI](https://theibsi.github.io)) is an independent international collaboration that aims to standardize the extraction of image biomarkers from acquired imaging. The IBSI therefore seeks to provide image biomarker nomenclature and definitions, benchmark datasets, and benchmark values to verify image processing and image biomarker calculations, as well as reporting guidelines, for high-throughput image analysis. We participate in this collaboration with our package to make sure it respects international nomenclatures and definitions. The participation was separated into two chapters: - ### IBSI Chapter 1 - [The IBSI chapter 1](https://theibsi.github.io/ibsi1/) is dedicated to the standardization of commonly used radiomic features. It was initiated in September 2016 and reached completion in March 2020. We have created two [jupyter notebooks](https://github.com/MahdiAll99/MEDimage/tree/main/notebooks/ibsi) for each phase of the chapter and made them available for the users to run the IBSI tests for themselves. The tests can also be explored in interactive Colab notebooks that are directly accessible here: + [The IBSI chapter 1](https://theibsi.github.io/ibsi1/) is dedicated to the standardization of commonly used radiomic features. It was initiated in September 2016 and reached completion in March 2020. We have created two [jupyter notebooks](https://github.com/MEDomicsLab/MEDiml/tree/main/notebooks/ibsi) for each phase of the chapter and made them available for the users to run the IBSI tests for themselves. The tests can also be explored in interactive Colab notebooks that are directly accessible here: - **Phase 1**: [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/MahdiAll99/MEDimage/blob/main/notebooks/ibsi/ibsi1p1.ipynb) - **Phase 2**: [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/MahdiAll99/MEDimage/blob/main/notebooks/ibsi/ibsi1p2.ipynb) - ### IBSI Chapter 2 - [The IBSI chapter 2](https://theibsi.github.io/ibsi2/) was launched in June 2020 and reached completion in February 2024. It is dedicated to the standardization of commonly used imaging filters in radiomic studies. We have created two [jupyter notebooks](https://github.com/MahdiAll99/MEDimage/tree/main/notebooks/ibsi) for each phase of the chapter and made them available for the users to run the IBSI tests for themselves and validate image filtering and image biomarker calculations from filter response maps. The tests can also be explored in interactive Colab notebooks that are directly accessible here: + [The IBSI chapter 2](https://theibsi.github.io/ibsi2/) was launched in June 2020 and reached completion in February 2024. It is dedicated to the standardization of commonly used imaging filters in radiomic studies. We have created two [jupyter notebooks](https://github.com/MEDomicsLab/MEDiml/tree/main/notebooks/ibsi) for each phase of the chapter and made them available for the users to run the IBSI tests for themselves and validate image filtering and image biomarker calculations from filter response maps. The tests can also be explored in interactive Colab notebooks that are directly accessible here: - **Phase 1**: [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/MahdiAll99/MEDimage/blob/main/notebooks/ibsi/ibsi2p1.ipynb) - **Phase 2**: [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/MahdiAll99/MEDimage/blob/main/notebooks/ibsi/ibsi2p2.ipynb) @@ -129,10 +129,10 @@ You can view and run the tests locally by installing the [Jupyter Notebook](http ``` python -m pip install jupyter ``` -Then add the installed `medimage` environment to the Jupyter Notebook kernels using: +Then add the installed `MEDiml` environment to the Jupyter Notebook kernels using: ``` -python -m ipykernel install --user --name=medimage +python -m ipykernel install --user --name=MEDiml ``` Then access the IBSI tests folder using: @@ -148,10 +148,10 @@ jupyter notebook ``` ## 7. Acknowledgement -MEDimage is an open-source package developed at the [MEDomics-Udes](https://www.medomics-udes.org/en/) laboratory with the collaboration of the international consortium [MEDomics](https://www.medomics.ai/). We welcome any contribution and feedback. Furthermore, we wish that this package could serve the growing radiomics research community by providing a flexible as well as [IBSI](https://theibsi.github.io/) standardized tool to reimplement existing methods and develop new ones. +MEDiml is an open-source package developed at the [MEDomicsLab](https://www.medomicslab.com/en/) laboratory with the collaboration of the international consortium [MEDomics](https://www.medomics.ai/). We welcome any contribution and feedback. Furthermore, we wish that this package could serve the growing radiomics research community by providing a flexible as well as [IBSI](https://theibsi.github.io/) standardized tool to reimplement existing methods and develop new ones. ## 8. Authors -* [MEDomics-Udes](https://www.medomics-udes.org/en/): Research laboratory at Université de Sherbrooke. +* [MEDomicsLab](https://www.medomicslab.com/en/): Research laboratory at Université de Sherbrooke & McGill University. * [MEDomics](https://github.com/medomics/): MEDomics consortium. ## 9. Statement @@ -176,4 +176,4 @@ Here's what the license entails: 9. The software author or license can not be held liable for any damages inflicted by the software. ``` -More information on about the [LICENSE can be found here](https://github.com/MEDomics-UdeS/MEDimage/blob/main/LICENSE.md) +More information on about the [LICENSE can be found here](https://github.com/MEDomicsLab/MEDiml/blob/main/LICENSE.md) diff --git a/docs/FAQs.rst b/docs/FAQs.rst index 21cd6d2..47e9044 100644 --- a/docs/FAQs.rst +++ b/docs/FAQs.rst @@ -1,4 +1,4 @@ FAQs ========================== -If your question is not here, feel free to ask it on `GitHub `__ \ No newline at end of file +If your question is not here, feel free to ask it on `GitHub `__ \ No newline at end of file diff --git a/docs/Installation.rst b/docs/Installation.rst index 468a447..bcff1b4 100644 --- a/docs/Installation.rst +++ b/docs/Installation.rst @@ -4,14 +4,13 @@ Installation Python installation ------------------- -The MEDimage package requires python 3.8 or more to be run. If you don't have it installed on your machine, follow \ -the instructions `here `__. +The MEDiml package requires python 3.8 or more to be run. If you don't have it installed on your machine, follow \ +the instructions `here `__. Install via pip --------------- -``MEDimage`` is available on PyPi for installation via ``pip`` which allows you to install the package in one step :: - - pip install medimage-pkg +``MEDiml`` is available on PyPi for installation via ``pip`` which allows you to install the package in one step :: + pip install mediml Install from source ------------------- @@ -28,23 +27,23 @@ following the instructions `here `__ tests require specific parameters for radiomics extraction for each test. You can check a full example of the file here: -`notebooks/ibsi/settings/ `__. - +`notebooks/ibsi/settings/ `__. This section will walk you through the details on how to set up and use the configuration file. It will be separated to four subdivision: - :ref:`Pre-checks` @@ -145,7 +144,7 @@ e.g. { "pre_radiomics_checks" : { - "path_data" : "home/user/medimage/data/npy/sts", + "path_data" : "home/user/mediml/data/npy/sts", } } @@ -164,7 +163,7 @@ e.g. { "pre_radiomics_checks" : { - "path_save_checks" : "home/user/medimage/checks", + "path_save_checks" : "home/user/mediml/checks", } } @@ -183,7 +182,7 @@ e.g. { "pre_radiomics_checks" : { - "path_csv" : "home/user/medimage/data/csv/roiNames_GTV.csv", + "path_csv" : "home/user/mediml/data/csv/roiNames_GTV.csv", } } @@ -335,7 +334,7 @@ e.g. "type": "List" }, "outliers": { - "description": "Outlier resegmentation algorithm. For now ``MEDimage`` only implements ``\"Collewet\"`` algorithms. + "description": "Outlier resegmentation algorithm. For now ``MEDiml`` only implements ``\"Collewet\"`` algorithms. Leave empty for no outlier resegmentation", "type": "string" } @@ -508,7 +507,7 @@ This parameter is only used for PET scans and is set as follows: } .. note:: - This parameter concern PET scans only. ``MEDimage`` only computes suv map for DICOM scans, since the computation relies on + This parameter concern PET scans only. ``MEDiml`` only computes suv map for DICOM scans, since the computation relies on DICOM headers for computation and assumes it's already computed for NIfTI scans. .. jsonschema:: @@ -828,7 +827,7 @@ Filtering parameters ^^^^^^^^^^^^^^^^^^^^ Filtering parameters are organized in a separate dictionary, each dictionary contains -parameters for every filter of the ``MEDimage``: +parameters for every filter of the ``MEDiml``: .. code-block:: JSON diff --git a/docs/filters.rst b/docs/filters.rst index 1368283..bd547c0 100644 --- a/docs/filters.rst +++ b/docs/filters.rst @@ -4,7 +4,7 @@ Filters gabor -------------------------------------------- -.. automodule:: MEDimage.filters.gabor +.. automodule:: MEDiml.filters.gabor :members: :undoc-members: :show-inheritance: @@ -12,7 +12,7 @@ gabor laws ----------------------------------------- -.. automodule:: MEDimage.filters.laws +.. automodule:: MEDiml.filters.laws :members: :undoc-members: :show-inheritance: @@ -20,7 +20,7 @@ laws log ----------------------------------------- -.. automodule:: MEDimage.filters.log +.. automodule:: MEDiml.filters.log :members: :undoc-members: :show-inheritance: @@ -28,7 +28,7 @@ log mean ----------------------------------------- -.. automodule:: MEDimage.filters.mean +.. automodule:: MEDiml.filters.mean :members: :undoc-members: :show-inheritance: @@ -36,7 +36,7 @@ mean wavelet ----------------------------------------- -.. automodule:: MEDimage.filters.wavelet +.. automodule:: MEDiml.filters.wavelet :members: :undoc-members: :show-inheritance: @@ -44,7 +44,7 @@ wavelet apply\_filter ----------------------------------------- -.. automodule:: MEDimage.filters.apply_filter +.. automodule:: MEDiml.filters.apply_filter :members: :undoc-members: :show-inheritance: @@ -52,7 +52,7 @@ apply\_filter utils ----------------------------------------- -.. automodule:: MEDimage.filters.utils +.. automodule:: MEDiml.filters.utils :members: :undoc-members: :show-inheritance: diff --git a/docs/index.rst b/docs/index.rst index 7a46b93..36a9549 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,16 +1,16 @@ -Welcome to the MEDimage documentation! +Welcome to the MEDiml documentation! ======================================= .. image:: /figures/pakcage-overview.png :width: 100% -``MEDimage`` is a comprehensive tool, for processing and extracting features from medical images. It also supports the training, evaluation, +``MEDiml`` is a comprehensive tool, for processing and extracting features from medical images. It also supports the training, evaluation, and optimality analysis, streamlining radiomics approaches. It complies with `international radiomic feature extraction standards \ `_ and `international standards for convolotuional filters \ `_ in the context of radiomics. -``MEDimage`` also uses an interactive, easy-to-install application (see image below) that grants users access to all software modules. Find more details `here \ +``MEDiml`` also uses an interactive, easy-to-install application (see image below) that grants users access to all software modules. Find more details `here \ `_ .. carousel:: diff --git a/docs/input_data.rst b/docs/input_data.rst index 8679c00..9b20a44 100644 --- a/docs/input_data.rst +++ b/docs/input_data.rst @@ -1,7 +1,7 @@ Input Data ========== -``MEDimage`` package accepts two formats of input data: `NIfTI `__ +``MEDiml`` package accepts two formats of input data: `NIfTI `__ and `DICOM `__. Each format has its own conventions that need to be followed. The following sections describe the norms and the conventions for each format and we recommend you process your dataset in a way that respects them. @@ -26,12 +26,12 @@ B. **RTstruct** RTstruct files define the area of significance and hold information about each region of interest (ROI). The RTstruct files are associated with their imaging volume using the ``(0020,000E) Series Instance UID`` or the ``(0020,0052) Frame of Reference UID`` found in the file's header. - ``MEDimage`` package recommends the following: + ``MEDiml`` package recommends the following: - **Patient ID**: Same conventions and recommendations as the DICOM image. - **Series description**: Same conventions and recommendations as the DICOM image. - **ROI name**: Only found in DICOM RTstruct files and referenced in each element (each ROI) of the ``(3006,0020) Structure Set ROI Sequence`` list of - the DICOM header, under the attribute ``(3006,0026) ROI Name`` which is a name given to each region of interest (ROI). ``MEDimage`` has no + the DICOM header, under the attribute ``(3006,0026) ROI Name`` which is a name given to each region of interest (ROI). ``MEDiml`` has no conventions over this field, but we recommend renaming each ROI name in a simple and logic way to differentiate them from each other. It is very important to keep track of all the ROIs in your dataset since they need to be specified in the :doc:`../csv_file` of the dataset under the ``ROIName`` column to be used later in your radiomics analysis. @@ -40,21 +40,21 @@ NIfTI ----- The NIfTI format is a simple format that only contains the image itself. Unlike DICOM, the NIfTI format does contain any -information about the regions of interest (ROI) so it needs to be provided in other separate files. In order for ``MEDimage`` to read a NIfTI scan +information about the regions of interest (ROI) so it needs to be provided in other separate files. In order for ``MEDMEDimlimage`` to read a NIfTI scan files, they need to be put in the same folder with the following names: - ``'PatientID__SeriesDescription(ROILabel).Modality.nii.gz'``: The image itself. For example: ``'STS-McGill-001__T1(GTV).MRscan.nii.gz'``. - ``'PatientID__SeriesDescription(ROIname).ROI.nii.gz'``: The ROI or the mask of the image. This file should contain a binary mask of the ROI. For example: ``'STS-McGill-001__T1(GTV_Mass).ROI.nii.gz'``. -The following figure sums up the ``MEDimage`` logic in reading data for both formats: +The following figure sums up the ``MEDiml`` logic in reading data for both formats: .. image:: /figures/InputDataSummary.png :width: 1000 :align: center If these conventions are followed, the ``DataManager`` class will be able to read the data and create the ``MEDscan`` objects that will be used -in the radiomics analysis with no further intervention from the user. For instance, ``MEDimage`` package is capable of automatically updating +in the radiomics analysis with no further intervention from the user. For instance, ``MEDiml`` package is capable of automatically updating the fields of all the DICOM files as long as the dataset is organized in the following way: :: diff --git a/docs/learning.rst b/docs/learning.rst index 72a0833..ddbcf6d 100644 --- a/docs/learning.rst +++ b/docs/learning.rst @@ -3,42 +3,42 @@ Learning DataCleaner ------------------------------------- -.. automodule:: MEDimage.learning.DataCleaner +.. automodule:: MEDiml.learning.DataCleaner :members: :undoc-members: :show-inheritance: Desgin experiment ------------------------------------- -.. automodule:: MEDimage.learning.DesignExperiment +.. automodule:: MEDiml.learning.DesignExperiment :members: :undoc-members: :show-inheritance: Feature set reduction ------------------------------------- -.. automodule:: MEDimage.learning.FSR +.. automodule:: MEDiml.learning.FSR :members: :undoc-members: :show-inheritance: Normalization ------------------------------------- -.. automodule:: MEDimage.learning.Normalization +.. automodule:: MEDiml.learning.Normalization :members: :undoc-members: :show-inheritance: Radiomics Learner ------------------------------------- -.. automodule:: MEDimage.learning.RadiomicsLearner +.. automodule:: MEDiml.learning.RadiomicsLearner :members: :undoc-members: :show-inheritance: Results ------------------------------------- -.. automodule:: MEDimage.learning.Results +.. automodule:: MEDiml.learning.Results :members: :undoc-members: :show-inheritance: \ No newline at end of file diff --git a/docs/modules.rst b/docs/modules.rst index 667f556..9729fc6 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -1,4 +1,4 @@ -MEDimage +MEDiml ======== .. toctree:: diff --git a/docs/processing.rst b/docs/processing.rst index b8ffe72..6f56feb 100644 --- a/docs/processing.rst +++ b/docs/processing.rst @@ -5,7 +5,7 @@ Processing compute\_suv\_map -------------------------------------------- -.. automodule:: MEDimage.processing.compute_suv_map +.. automodule:: MEDiml.processing.compute_suv_map :members: :undoc-members: :show-inheritance: @@ -13,7 +13,7 @@ compute\_suv\_map discretization ----------------------------------------- -.. automodule:: MEDimage.processing.discretisation +.. automodule:: MEDiml.processing.discretisation :members: :undoc-members: :show-inheritance: @@ -21,7 +21,7 @@ discretization interpolation ----------------------------------------- -.. automodule:: MEDimage.processing.interpolation +.. automodule:: MEDiml.processing.interpolation :members: :undoc-members: :show-inheritance: @@ -29,7 +29,7 @@ interpolation resegmentation ----------------------------------------- -.. automodule:: MEDimage.processing.resegmentation +.. automodule:: MEDiml.processing.resegmentation :members: :undoc-members: :show-inheritance: @@ -37,7 +37,7 @@ resegmentation segmentation ----------------------------------------- -.. automodule:: MEDimage.processing.segmentation +.. automodule:: MEDiml.processing.segmentation :members: :undoc-members: :show-inheritance: diff --git a/docs/tutorials.rst b/docs/tutorials.rst index 703f306..d842680 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -20,24 +20,24 @@ CSV file -------- Most tutorials, such as the :ref:`BatchExtractor tutorial `, utilize multiple scans, each with its CSV file. - ``MEDimage`` requires a CSV file for each dataset; details can be found in the :doc:`../csv_file`. - Examples are available in ``MEDimage/notebooks/tutorial/csv``. + ``MEDiml`` requires a CSV file for each dataset; details can be found in the :doc:`../csv_file`. + Examples are available in ``MEDiml/notebooks/tutorial/csv``. .. note:: - Future versions of ``MEDimage`` aim to automate the creation of these CSV files for each dataset. + Future versions of ``MEDiml`` aim to automate the creation of these CSV files for each dataset. Configuration file ------------------ - To use ``MEDimage``, a configuration file is always required. An example file is available in the GitHub repository - (``MEDimage/notebooks/tutorial/settings/MEDimage-Tutorial.json``), and documentation is provided :doc:`../configurations_file`. + To use ``MEDiml``, a configuration file is always required. An example file is available in the GitHub repository + (``MEDiml/notebooks/tutorial/settings/MEDiml-Tutorial.json``), and documentation is provided :doc:`../configurations_file`. Different JSON configuration files are used for each case; for example, specific JSON configurations for every - `IBSI `__ test are available in ``MEDimage/notebooks/ibsi/settings``. + `IBSI `__ test are available in ``MEDiml/notebooks/ibsi/settings``. DataManager =========== - The ``DataManager`` plays an important role in ``MEDimage``. The class is capable of processing raw `DICOM `__ + The ``DataManager`` plays an important role in ``MEDiml``. The class is capable of processing raw `DICOM `__ and `NIfTI `__ and converting them in into ``MEDscan`` class objects. It includes pre-radiomics analysis, determining the best intensity ranges and voxel dimension rescaling parameters for a given dataset. This analysis is essential, as highlighted in this `article `__ , which investigates how intensity @@ -45,10 +45,10 @@ DataManager The tutorial for DataManager is available here.: |DataManager_image_badge| - You can also find this tutorial on the repository ``MEDimage/notebooks/tutorial/DataManager-Tutorial.ipynb``. + You can also find this tutorial on the repository ``MEDiml/notebooks/tutorial/DataManager-Tutorial.ipynb``. .. |DataManager_image_badge| image:: https://colab.research.google.com/assets/colab-badge.png - :target: https://colab.research.google.com/github/MahdiAll99/MEDimage/blob/main/notebooks/tutorial/DataManager-Tutorial.ipynb + :target: https://colab.research.google.com/github/MEDomicsLab/MEDiml/blob/main/notebooks/tutorial/DataManager-Tutorial.ipynb .. image:: /figures/DataManager-overview.png :width: 800 @@ -57,32 +57,32 @@ DataManager MEDscan Class ============== - In MEDimage, the ``MEDscan`` class is a Python object that maintains data and information about the dataset, particularly related to scans processed + In MEDiml, the ``MEDscan`` class is a Python object that maintains data and information about the dataset, particularly related to scans processed from NIfTI or DICOM data. It can manage parameters used in processing, filtering, and extraction, reading from JSON files and updating all relevant - attributes. Many other useful functionalities are detailed in this tutorial: |MEDimage_image_badge| + attributes. Many other useful functionalities are detailed in this tutorial: |MEDiml_image_badge| - You can also find this tutorial on the repository ``MEDimage/notebooks/tutorial/MEDimage-Tutorial.ipynb``. + You can also find this tutorial on the repository ``MEDiml/notebooks/tutorial/MEDiml-Tutorial.ipynb``. -.. |MEDimage_image_badge| image:: https://colab.research.google.com/assets/colab-badge.png - :target: https://colab.research.google.com/github/MahdiAll99/MEDimage/blob/main/notebooks/tutorial/MEDimage-Tutorial.ipynb +.. |MEDiml_image_badge| image:: https://colab.research.google.com/assets/colab-badge.png + :target: https://colab.research.google.com/github/MEDomicsLab/MEDiml/blob/main/notebooks/tutorial/MEDiml-Tutorial.ipynb Single-scan demo ================ - This demo provides a step-by-step guide to processing and extracting features for a single scan using ``MEDimage``. It covers various use cases, - from initial processing steps to the extraction of features. The demo is perfect for learning how to use MEDimage for single-scan feature extraction. + This demo provides a step-by-step guide to processing and extracting features for a single scan using ``MEDiml``. It covers various use cases, + from initial processing steps to the extraction of features. The demo is perfect for learning how to use MEDiml for single-scan feature extraction. The interactive Colab notebook for the demo is available here: |Glioma_demo_image_badge| - You can also find it on the repository ``MEDimage/notebooks/demo/Glioma-Demo.ipynb``. + You can also find it on the repository ``MEDiml/notebooks/demo/Glioma-Demo.ipynb``. .. |Glioma_demo_image_badge| image:: https://colab.research.google.com/assets/colab-badge.png - :target: https://colab.research.google.com/github/MahdiAll99/MEDimage/blob/main/notebooks/demo/Glioma-Demo.ipynb + :target: https://colab.research.google.com/github/MEDomicsLab/MEDiml/blob/main/notebooks/demo/Glioma-Demo.ipynb BatchExtractor ============== - ``MEDimage`` facilitates batch feature extraction through the ``BatchExtractor`` class, which streamlines the following workflow: + ``MEDiml`` facilitates batch feature extraction through the ``BatchExtractor`` class, which streamlines the following workflow: .. image:: /figures/BatchExtractor-overview.png :width: 800 @@ -90,14 +90,14 @@ BatchExtractor This class creates batches of scans and performs full extraction of all radiomics family features, saving them in tables and JSON files. To run a batch extraction, simply set the path to your dataset and the path to your dataset's :doc:`../csv_file` of regions of interest. - (check example `here `__). + (check example `here `__). Learn more in the interactive Colab notebook here: |BatchExtractor_image_badge| - You can also find it on the repository ``MEDimage/notebooks/tutorial/BatchExtractor-Tutorial.ipynb``. + You can also find it on the repository ``MEDiml/notebooks/tutorial/BatchExtractor-Tutorial.ipynb``. .. |BatchExtractor_image_badge| image:: https://colab.research.google.com/assets/colab-badge.png - :target: https://colab.research.google.com/github/MahdiAll99/MEDimage/blob/main/notebooks/tutorial/BatchExtractor-Tutorial.ipynb + :target: https://colab.research.google.com/github/MEDomicsLab/MEDiml/blob/main/notebooks/tutorial/BatchExtractor-Tutorial.ipynb Learning ======== @@ -105,7 +105,7 @@ Learning Overview -------- - ``MEDimage`` offers a learning module for training a machine learning model on extracted features. The module handles features cleaning, normalization, + ``MEDiml`` offers a learning module for training a machine learning model on extracted features. The module handles features cleaning, normalization, selection, model training, and testing. The workflow is summarized in the following image: .. image:: /figures/LearningWorkflow.png @@ -114,22 +114,22 @@ Overview Similar to the extraction module, the learning module also uses multiple JSON configuration files to set the parameters of the learning process. Details about the configuration files, are available here: :doc:`../configurations_file`. You can also find an example of these files in the - GitHub repository (``MEDimage/tree/learning/notebooks/tutorial/learning/settings``). + GitHub repository (``MEDiml/tree/learning/notebooks/tutorial/learning/settings``). A tutorial is provided in this notebook: |Learning_image_badge| - You can also find it on the repository ``MEDimage/notebooks/tutorial/Learning-Tutorial.ipynb``. + You can also find it on the repository ``MEDiml/notebooks/tutorial/Learning-Tutorial.ipynb``. .. |Learning_image_badge| image:: https://colab.research.google.com/assets/colab-badge.png - :target: https://colab.research.google.com/github/MahdiAll99/MEDimage/blob/learning/notebooks/tutorial/Learning-Tutorial.ipynb + :target: https://colab.research.google.com/github/MEDomicsLab/MEDiml/blob/learning/notebooks/tutorial/Learning-Tutorial.ipynb How to setup your experiment ---------------------------- - To fully use the ``MEDimage`` functionalities, you must follow certain norms and guidelines: + To fully use the ``MEDiml`` functionalities, you must follow certain norms and guidelines: **Experiment Name**: - The experiment name is the label used to identify your machine learning experiment. In ``MEDimage`` we use the following format for the experiment name: + The experiment name is the label used to identify your machine learning experiment. In ``MEDiml`` we use the following format for the experiment name: ``__``. This format is depicted in the following image: .. image:: /figures/ExperimentNameBreakdown.png @@ -139,7 +139,7 @@ How to setup your experiment Results Analysis ---------------- - It is worth noting that to use most functionalities of the results analsys part you must follow the format for the experiment. + It is worth noting that to use most functionalities of the results analysis part you must follow the format for the experiment. Analysis of results involves different key steps: diff --git a/docs/utils.rst b/docs/utils.rst index d523489..7a3a288 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -4,7 +4,7 @@ Utils batch\_patients ------------------------------------- -.. automodule:: MEDimage.utils.batch_patients +.. automodule:: MEDiml.utils.batch_patients :members: :undoc-members: :show-inheritance: @@ -12,7 +12,7 @@ batch\_patients create\_radiomics\_table ---------------------------------------------- -.. automodule:: MEDimage.utils.create_radiomics_table +.. automodule:: MEDiml.utils.create_radiomics_table :members: :undoc-members: :show-inheritance: @@ -20,7 +20,7 @@ create\_radiomics\_table data\_frame\_export ----------------------------------------- -.. automodule:: MEDimage.utils.data_frame_export +.. automodule:: MEDiml.utils.data_frame_export :members: :undoc-members: :show-inheritance: @@ -28,7 +28,7 @@ data\_frame\_export find\_process\_names ------------------------------------------ -.. automodule:: MEDimage.utils.find_process_names +.. automodule:: MEDiml.utils.find_process_names :members: :undoc-members: :show-inheritance: @@ -36,7 +36,7 @@ find\_process\_names get\_file\_paths -------------------------------------- -.. automodule:: MEDimage.utils.get_file_paths +.. automodule:: MEDiml.utils.get_file_paths :members: :undoc-members: :show-inheritance: @@ -44,7 +44,7 @@ get\_file\_paths get\_institutions\_from\_ids -------------------------------------------------- -.. automodule:: MEDimage.utils.get_institutions_from_ids +.. automodule:: MEDiml.utils.get_institutions_from_ids :members: :undoc-members: :show-inheritance: @@ -52,7 +52,7 @@ get\_institutions\_from\_ids get\_patient\_id\_from\_scan\_name -------------------------------------------------------- -.. automodule:: MEDimage.utils.get_patient_id_from_scan_name +.. automodule:: MEDiml.utils.get_patient_id_from_scan_name :members: :undoc-members: :show-inheritance: @@ -60,7 +60,7 @@ get\_patient\_id\_from\_scan\_name get\_patient\_names ----------------------------------------- -.. automodule:: MEDimage.utils.get_patient_names +.. automodule:: MEDiml.utils.get_patient_names :members: :undoc-members: :show-inheritance: @@ -68,7 +68,7 @@ get\_patient\_names get\_radiomic\_names ------------------------------------------ -.. automodule:: MEDimage.utils.get_radiomic_names +.. automodule:: MEDiml.utils.get_radiomic_names :members: :undoc-members: :show-inheritance: @@ -76,7 +76,7 @@ get\_radiomic\_names get\_scan\_name\_from\_rad\_name ------------------------------------------------------ -.. automodule:: MEDimage.utils.get_scan_name_from_rad_name +.. automodule:: MEDiml.utils.get_scan_name_from_rad_name :members: :undoc-members: :show-inheritance: @@ -84,7 +84,7 @@ get\_scan\_name\_from\_rad\_name image\_reader\_SITK ----------------------------------------- -.. automodule:: MEDimage.utils.image_reader_SITK +.. automodule:: MEDiml.utils.image_reader_SITK :members: :undoc-members: :show-inheritance: @@ -92,7 +92,7 @@ image\_reader\_SITK image\_volume\_obj ---------------------------------------- -.. automodule:: MEDimage.utils.image_volume_obj +.. automodule:: MEDiml.utils.image_volume_obj :members: :undoc-members: :show-inheritance: @@ -100,7 +100,7 @@ image\_volume\_obj imref --------------------------- -.. automodule:: MEDimage.utils.imref +.. automodule:: MEDiml.utils.imref :members: :undoc-members: :show-inheritance: @@ -108,7 +108,7 @@ imref initialize\_features\_names ------------------------------------------------- -.. automodule:: MEDimage.utils.initialize_features_names +.. automodule:: MEDiml.utils.initialize_features_names :members: :undoc-members: :show-inheritance: @@ -116,7 +116,7 @@ initialize\_features\_names inpolygon ------------------------------- -.. automodule:: MEDimage.utils.inpolygon +.. automodule:: MEDiml.utils.inpolygon :members: :undoc-members: :show-inheritance: @@ -124,7 +124,7 @@ inpolygon interp3 ----------------------------- -.. automodule:: MEDimage.utils.interp3 +.. automodule:: MEDiml.utils.interp3 :members: :undoc-members: :show-inheritance: @@ -132,7 +132,7 @@ interp3 json\_utils --------------------------------- -.. automodule:: MEDimage.utils.json_utils +.. automodule:: MEDiml.utils.json_utils :members: :undoc-members: :show-inheritance: @@ -140,7 +140,7 @@ json\_utils mode -------------------------- -.. automodule:: MEDimage.utils.mode +.. automodule:: MEDiml.utils.mode :members: :undoc-members: :show-inheritance: @@ -148,7 +148,7 @@ mode parse\_contour\_string -------------------------------------------- -.. automodule:: MEDimage.utils.parse_contour_string +.. automodule:: MEDiml.utils.parse_contour_string :members: :undoc-members: :show-inheritance: @@ -156,7 +156,7 @@ parse\_contour\_string save\_MEDscan ------------------------------------ -.. automodule:: MEDimage.utils.save_MEDscan +.. automodule:: MEDiml.utils.save_MEDscan :members: :undoc-members: :show-inheritance: @@ -164,7 +164,7 @@ save\_MEDscan strfind ----------------------------- -.. automodule:: MEDimage.utils.strfind +.. automodule:: MEDiml.utils.strfind :members: :undoc-members: :show-inheritance: @@ -172,7 +172,7 @@ strfind textureTools ---------------------------------- -.. automodule:: MEDimage.utils.textureTools +.. automodule:: MEDiml.utils.textureTools :members: :undoc-members: :show-inheritance: @@ -180,7 +180,7 @@ textureTools write\_radiomics\_csv ------------------------------------------- -.. automodule:: MEDimage.utils.write_radiomics_csv +.. automodule:: MEDiml.utils.write_radiomics_csv :members: :undoc-members: :show-inheritance: diff --git a/docs/wrangling.rst b/docs/wrangling.rst index 9af4995..d000ddd 100644 --- a/docs/wrangling.rst +++ b/docs/wrangling.rst @@ -4,7 +4,7 @@ Wrangling DataManager ------------------------------------- -.. automodule:: MEDimage.wrangling.DataManager +.. automodule:: MEDiml.wrangling.DataManager :members: :undoc-members: :show-inheritance: @@ -12,7 +12,7 @@ DataManager ProcessDICOM ----------------------------------------- -.. automodule:: MEDimage.wrangling.ProcessDICOM +.. automodule:: MEDiml.wrangling.ProcessDICOM :members: :undoc-members: :show-inheritance: diff --git a/environment.yml b/environment.yml index 02d97a5..b18943b 100644 --- a/environment.yml +++ b/environment.yml @@ -1,4 +1,4 @@ -name: medimage +name: mediml channels: - conda-forge diff --git a/notebooks/README.md b/notebooks/README.md index 9358d47..ccd45b6 100644 --- a/notebooks/README.md +++ b/notebooks/README.md @@ -1,4 +1,4 @@ -# MEDimage demo, ibsi tests and tutorials +# MEDiml demo, ibsi tests and tutorials We have made many notebooks available in this folder to clear the way for the use of the package. In order to run these notebooks you must have the right datasets for each notebook. To easily do so, run the following command from the parent folder: @@ -18,4 +18,4 @@ The *demo* folder contains one notebook that demonstrates in a brief way the dif ## tutorials folder -The *tutorials* folder contains notebooks explaining the use of ``DataManager``, ``MEDimage`` and ``BatchExtractor`` classes, these three classes highly participate in the functioning of the package so we recommend taking the time to follow the tutorials. \ No newline at end of file +The *tutorials* folder contains notebooks explaining the use of ``DataManager``, ``MEDiml`` and ``BatchExtractor`` classes, these three classes highly participate in the functioning of the package so we recommend taking the time to follow the tutorials. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8fcb5f9..4cf50b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,17 @@ [tool.poetry] -name = "medimage-pkg" +name = "mediml" version = "0.9.8" -description = "MEDimage is a Python package for processing and extracting features from medical images" +description = "MEDiml is a Python package for processing and extracting features from medical images" authors = ["MEDomics Consortium "] license = "GPL-3.0" readme = "README.md" homepage = "https://medimage.app/" -repository = "https://github.com/MEDomics-UdeS/MEDimage/" +repository = "https://github.com/MEDomicsLab/MEDiml/" keywords = ["python", "ibsi", "medical-imaging", "cancer-imaging-research", "radiomics", "medical-image-analysis", "features-extraction", "radiomics-extraction", "radiomics-features", "radiomics-analysis"] -packages = [ {include = "MEDimage"} ] +packages = [ {include = "MEDiml"} ] [tool.poetry.dependencies] python = ">=3.8.0,<=3.10" diff --git a/scripts/download_data.py b/scripts/download_data.py index 9f061ff..191ca43 100644 --- a/scripts/download_data.py +++ b/scripts/download_data.py @@ -8,7 +8,7 @@ def main(full_sts: bool, subset: bool) -> None: """ - Downloads MEDimage data for testing, tutorials and demo and organizes it in the right folders. + Downloads MEDiml data for testing, tutorials and demo and organizes it in the right folders. Args: full_sts (bool): if ``True`` will not download the STS data (large size). @@ -24,17 +24,17 @@ def main(full_sts: bool, subset: bool) -> None: "https://sandbox.zenodo.org/records/45640/files/MEDimage-Dataset-No-STS.zip?download=1", out=os.getcwd()) except Exception as e: - print("MEDimage-Dataset-No-STS.zip download failed, error:", e) + print("MEDiml-Dataset-No-STS.zip download failed, error:", e) # unzip data print("\n================ Extracting first part of data ================") try: - with zipfile.ZipFile(os.getcwd() + "/MEDimage-Dataset-No-STS.zip", 'r') as zip_ref: + with zipfile.ZipFile(os.getcwd() + "/MEDiml-Dataset-No-STS.zip", 'r') as zip_ref: zip_ref.extractall(os.getcwd()) # delete zip file after extraction - os.remove(os.getcwd() + "/MEDimage-Dataset-No-STS.zip") + os.remove(os.getcwd() + "/MEDiml-Dataset-No-STS.zip") except Exception as e: - print("MEDimage-Dataset-No-STS.zip extraction failed, error:", e) + print("MEDiml-Dataset-No-STS.zip extraction failed, error:", e) # Organize data in the right folders # ibsi tests data organization @@ -90,17 +90,17 @@ def main(full_sts: bool, subset: bool) -> None: out=os.getcwd()) pass except Exception as e: - print("MEDimage-STS-Dataset.zip download failed, error:", e) + print("MEDiml-STS-Dataset.zip download failed, error:", e) # unzip data print("\n================ Extracting second part of data ================") try: - with zipfile.ZipFile(os.getcwd() + "/MEDimage-STS-Dataset.zip", 'r') as zip_ref: + with zipfile.ZipFile(os.getcwd() + "/MEDiml-STS-Dataset.zip", 'r') as zip_ref: zip_ref.extractall(os.getcwd()) # remove zip file after extraction - os.remove(os.getcwd() + "/MEDimage-STS-Dataset.zip") + os.remove(os.getcwd() + "/MEDiml-STS-Dataset.zip") except Exception as e: - print("MEDimage-STS-Dataset.zip extraction failed, error:", e) + print("MEDiml-STS-Dataset.zip extraction failed, error:", e) # organize data in the right folder print("\n================== Organizing data in folders ==================") @@ -118,31 +118,31 @@ def main(full_sts: bool, subset: bool) -> None: out=os.getcwd()) pass except Exception as e: - print("MEDimage-STS-Dataset-Subset.zip download failed, error:", e) + print("MEDiml-STS-Dataset-Subset.zip download failed, error:", e) # unzip data print("\n================ Extracting second part of data ================") try: - with zipfile.ZipFile(os.getcwd() + "/MEDimage-STS-Dataset-Subset.zip", 'r') as zip_ref: + with zipfile.ZipFile(os.getcwd() + "/MEDiml-STS-Dataset-Subset.zip", 'r') as zip_ref: zip_ref.extractall(os.getcwd()) # remove zip file after extraction - os.remove(os.getcwd() + "/MEDimage-STS-Dataset-Subset.zip") + os.remove(os.getcwd() + "/MEDiml-STS-Dataset-Subset.zip") except Exception as e: - print("MEDimage-STS-Dataset-Subset.zip extraction failed, error:", e) + print("MEDiml-STS-Dataset-Subset.zip extraction failed, error:", e) # organize data in the right folder print("\n================== Organizing data in folders ==================") try: - shutil.move(os.getcwd() + "/MEDimage-STS-Dataset-Subset", + shutil.move(os.getcwd() + "/MEDiml-STS-Dataset-Subset", os.getcwd() + "/notebooks" + "/tutorial" + "/data" + "/DICOM-STS") except Exception as e: - print("Failed to move MEDimage-STS-Dataset-Subset folder, error:", e) + print("Failed to move MEDiml-STS-Dataset-Subset folder, error:", e) if __name__ == "__main__": # setting up arguments: parser = argparse.ArgumentParser(description='Download dataset "\ - "for MEDimage package tests, tutorials and other demos.') + "for MEDiml package tests, tutorials and other demos.') parser.add_argument("--full-sts", default=False, action='store_true', help="If specified, will download the full STS data used in tutorials. Defaults to False.") parser.add_argument("--subset", default=True, action='store_true', diff --git a/scripts/process_dataset.py b/scripts/process_dataset.py index 5fb92c1..ee2512f 100644 --- a/scripts/process_dataset.py +++ b/scripts/process_dataset.py @@ -35,7 +35,7 @@ def main(path_dataset: Union[str, Path]) -> None: if __name__ == "__main__": # setting up arguments: - parser = argparse.ArgumentParser(description='Re-organize dataset to follow MEDimage package conventions.') + parser = argparse.ArgumentParser(description='Re-organize dataset to follow MEDiml package conventions.') parser.add_argument("--path-dataset", required=True, help="Path to your dataset folder.") args = parser.parse_args() diff --git a/setup.py b/setup.py index a9502f4..1cd1e6d 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ # Check if current python installation is >= 3.8 if sys.version_info < (3, 8, 0): - raise Exception("MEDimage requires python 3.8 or later") + raise Exception("MEDiml requires python 3.8 or later") with open("README.md", encoding='utf-8') as f: long_description = f.read() @@ -13,17 +13,17 @@ requirements = f.readlines() setup( - name="MEDimage", + name="MEDiml", version="0.9.8", author="MEDomics consortium", author_email="medomics.info@gmail.com", description="Python Open-source package for medical images processing and radiomic features extraction", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/MahdiAll99/MEDimage", + url="https://github.com/MEDomicsLab/MEDiml", project_urls={ 'Documentation': 'https://medimage.readthedocs.io/en/latest/index.html', - 'Github': 'https://github.com/MahdiAll99/MEDimage' + 'Github': 'https://github.com/MEDomicsLab/MEDiml' }, classifiers=[ 'Development Status :: 3 - Alpha', diff --git a/tests/test_extraction.py b/tests/test_extraction.py index 8e2b17f..0fc1b04 100644 --- a/tests/test_extraction.py +++ b/tests/test_extraction.py @@ -3,10 +3,10 @@ import numpy as np -MODULE_DIR = os.path.dirname(os.path.abspath('./MEDimage/')) +MODULE_DIR = os.path.dirname(os.path.abspath('./MEDiml/')) sys.path.append(MODULE_DIR) -import MEDimage +import MEDiml class TestExtraction: @@ -82,20 +82,20 @@ def __get_random_roi(self): def test_morph_features(self): phantom = self.__get_phantom() roi = self.__get_random_roi() - morph = MEDimage.biomarkers.morph.extract_all( + morph = MEDiml.biomarkers.morph.extract_all( vol=phantom, mask_int=roi, mask_morph=roi, res=[2, 2, 2], intensity_type="arbitrary" ) - morph_vol = MEDimage.biomarkers.morph.vol( + morph_vol = MEDiml.biomarkers.morph.vol( vol=phantom, mask_int=roi, mask_morph=roi, res=[2, 2, 2] ) - surface_area = MEDimage.biomarkers.morph.area( + surface_area = MEDiml.biomarkers.morph.area( vol=phantom, mask_int=roi, mask_morph=roi, @@ -109,18 +109,18 @@ def test_morph_features(self): def test_stats_features(self): phantom = self.__get_phantom() roi = self.__get_random_roi() - vol_int_re = MEDimage.processing.roi_extract( + vol_int_re = MEDiml.processing.roi_extract( vol=phantom, roi=roi ) - stats = MEDimage.biomarkers.stats.extract_all( + stats = MEDiml.biomarkers.stats.extract_all( vol=vol_int_re, intensity_type="definite" ) - kurt = MEDimage.biomarkers.stats.kurt( + kurt = MEDiml.biomarkers.stats.kurt( vol=vol_int_re, ) - skewness = MEDimage.biomarkers.stats.skewness( + skewness = MEDiml.biomarkers.stats.skewness( vol=vol_int_re, ) assert kurt == stats["Fstat_kurt"] diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 8844bee..e514031 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -3,10 +3,10 @@ import numpy as np -MODULE_DIR = os.path.dirname(os.path.abspath('./MEDimage/')) +MODULE_DIR = os.path.dirname(os.path.abspath('./MEDiml/')) sys.path.append(MODULE_DIR) -from MEDimage.filters.gabor import apply_gabor +from MEDiml.filters.gabor import apply_gabor def test_gabor(): From c92de68c546d1e333ba0d230874dad88393238fc Mon Sep 17 00:00:00 2001 From: MahdiAll99 Date: Wed, 28 Jan 2026 18:36:30 -0500 Subject: [PATCH 4/7] Added MEDiml logo --- README.md | 2 +- docs/conf.py | 2 +- docs/figures/MEDimlLogo.png | Bin 0 -> 1284771 bytes 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 docs/figures/MEDimlLogo.png diff --git a/README.md b/README.md index 1702442..2dc7ff2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
- + [![PyPI - Python Version](https://img.shields.io/badge/python-3.8%20|%203.9%20|%203.10-blue)](https://www.python.org/downloads/release/python-380/) [![PyPI - version](https://img.shields.io/badge/pypi-v0.9.8-blue)](https://pypi.org/project/medimage-pkg/) diff --git a/docs/conf.py b/docs/conf.py index 986793d..0227c7c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -99,7 +99,7 @@ # The name of an image file (relative to this directory) to place at the top # of the sidebar. -html_logo = "figures/MEDimageLogo.png" +html_logo = "figures/MEDimlLogo.png" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/figures/MEDimlLogo.png b/docs/figures/MEDimlLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..3cca18281d9e9919950455b1370eb795f0902dd4 GIT binary patch literal 1284771 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%0AL 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%0A1bEZiCK+?WD(+ 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

Z*p~p%n0{sUAH8Ruj?AMqj3QPGeE& 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≫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℞zIMv`8Q{LPyBd_z=8MdfeA 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

fQUAb|HDAH5Upm#%0Vy$Dks`|L2P%yC^6RN! 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

R|kiT?f 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�=j?kLs0q@*`RedPxE87V)XNKU_8jMDn$ z&b0s^WMR!FBU4mp+So$%?dqGVL|+xn^o+Vc`{U^m|}4rRmYdCxh9qe0=V9HAig~cPsPX{9FFwV0M%6 z;95gMf|s8`3HDkwyLUTjS!9e!byCdqWPLFZOU~fE4)joWx61!ZP|jk+Ip=6|=B_*$ z0mPfs>k=c{rlE`#GS^O&1-dh1S%{OCdp=v!6-i(5h=m%1|WW=*OjgW9%4iS8h zUgBzlAS(wq1i?;31BDtB=)0Z?d-Dpw;Vd>^yW3UNIz0S6&)>Lt^YBqx+MBv;Nt<%% zt4$DoTyJb`p>88iSxNCnwp5APsnjZcs3{8i;boi}jL|uQ*`D83fWC1M+N=?SM{W*t zd#^)Gc;`T8p09<41=>)j_Y)>B|4-D9&FB8jg=)Xn#(H{Mtk~Fy;#GCm*#R*nmJWiA z_5LRBLcpjQwX;KJpIEwO(#0z4)>I;r4Kgq)J~tw5z2h(lOB?Pr-yDoJI#q1~65Bk1 z70_!j0i2yDT@Jcz0$ZOou0xN)Gc5zK)E2l|jJVG6^cBTJ+wKs^mX%_CJ(njsVqgY> z=QBUGBplxBQvKKG|Lr03)@pYn(afa&^%%jQgQawt}JlL#95nffYT+HvMd`5|D}d;WSVDA-7ms zhR?&Ipp9vc^|e*SHUGKYno7d>{o3rQu(aR5oq;PD2C`b{vM>NfDxgVGDaioLWx5J& zut-Fl{sWB_`i_eW(e*hD+k3z1fXKc$PkbJ&K1u>CD@3ixU5I@g)>C3_Z44D=S%ZRQw;OM^a*@(|lG&JsS!z>FytuJ`% zr=HFKf{yyc3SI`XKKb68DPk^MeGg`5=YH{z_8N4YDC|>v&B`%ii=C8wu51 z)v^&gYiTXrf11C~KWbgiixZ3By5HAA4YD?lE3ISso^?hnjZw3DjE94W+_gaiVb zX0DNRO3FIUm{V0U6Ww|V;JI}5h07-F;IR>R&;%-daq|5r>J-)lwq0L7n*kRlc?rF! zxIu8&$^E2<(kk5f1C77|oJj8HQXhm`IoP=83)p{B&eDmb`l^kVqzWfDlo3+HPexQh z{{w?2j~~X#V#z2s)j)l#q&Q;~a7n(A;CY}OGc#KO!-7qR3k%W)U|5w24DS;fGH-wg zh!im4-2r9dqR8}&ed50SE=im}l%fdKQEuvAw8OuG#Fi2(%mh~9-WTmFvHiFOJ-9`Ab=u00a+=JOe`K1 zP@h(Kr|op_G|AMMZccm$NC%`flhyRGZa@!J>=NcKhdb-(PM`w5J2Q4dG$W8Lu55e? z8UZN;=Y5RvYqj>j3O_V?M@SRVOG0psNjLnt`D9Se8EnOE3FeOxd>mj#b8PbtWCQ?M zR$rfQ=LN!z@Lx&*>s%*+sSOy2@FXPbe%H^d&AxUx0>RQqTe~MM=IH1vi-6jly3X2f z{_ip~Z+n)PqbTVvZYhR*=jf5Ku`VIn57ZFZn0sM5Q6OzA3%HS7Rvp^CEiz)bjPd-f4 zY1$%bVn*O+2du!iUMSU2pq`>vc>{|Fp)8l3K|Qiu5b#YzW@)T2>yln@JG7wb|HQ5~ zP_eQBp*T6n@u-bCwuv;=#|%WM!e!9h7Ybx zYloJVi}7uWk>j$Jii)M|FF6wfpKDy$dAE?YdI7{Fw#Rllvhb{Lh}1cC_VN7=m%Fuw;c(Z8z-<7rDhgzLZdqY4e-tqVEul4HZEhF_XaGXI9 z$ET?)B=Rn}H)3f-@D^)`90+SDfod65T+b?)i&~|rDh`de=iG4tvH`jP^${WbYq9CK zvxP$U`C??P>8`xBZDa%G%I;S+?FPW3N#rvcS}CX77(_!2yaq#%A{KzrN;CB<{7kTA zf@yOp2j{HAa?d5(NGO5fayW-^f0P2jvusv)1Un_1(7F4}RUuG8(sfR3)^6 zpC?81tawN+C&(=vx!X(wOjjdFA4Jupsoc6?g~>_+St*N(p>D>&{sqja0J0SUlzlr1 zq>u9pW8Mx9_Dia$C?KV!H(U%Y50VO)!({npgZI-@6R&o8sV&^=e6qIc>vO(9Mprn+ zr4NgLB&Lfjl(_{_M7Zjkan79xojQ4VUEHA(j_wSzRYxlW)=O|juqcS86nI~0A{kUv=e2>|$($TH)MZp6ZkIE$;ij=u=+~X#zYFBve~vyr)vh2F_$z=oe%FNk9PdaJh*aSgQI)c7pyEgL=+y{Z zCJ>GAU3_GdT%4F$ulOd+xFxFv&nM0Q7z;oGQkJja2d0FDN7E&`Upowi)GxW*HR8 z0qdQv^+d{uXbOoDxf`Uo^b7yhb%M0%61CS|7`dCo^8Eb7VJt1}38w;TPmHHUNABuAFd{=%|k2dz{sHy26zZ$9f+4pl1-zls60;fU;9>+h@SP{&` zi19{C3TLR0Z}^m*?Bp@l)4&`mU@pNdgX#gd+jzE;To5Y3VR3vHD~3&BEd5Mq#ojV) zWp)=ac(;o^oplntDG>_rBv3dCR?`BFOGJ4adtzW`x!hlTaz~aHd)cc3(~tc35E^6E zbED(s7B)a!`FC%TWzm28>YL!j$~28W;WV-8+GS%tfPilU3i=7*B~Vh1RsMfK@7M2- z;6R(qV74xj62K>8cx^qW&bj+WXTkgHBinHMt(Am0Qh#@(RLiG6{a`M7EmSSc-y(F6VWBI#S$l2cUU}?5LbqjE-c`&85cO) zSYQSk@|NHo|4``unWP^~m{ZKgCp(c5{GrHw4w@Xa6p&eNp60faGL4hQ!xiKz5h!}5 zPjq9R=S8laalb5rUzKMX8ROGs8Xk*gBF!$CPb7{@UbA8$qT3-21YO;CN#oI8Iro{F zo@vz|Nh-5S# zRvQ!|QgujPoKL*Y;j0+L=+qJ3R_nE_+xn2~yL$sXIM^_`%<3LIK@L23=22-&Jhy(# zovVG5@V=8BGx@>m&$AK4u(G1HIwcL1*uXcydNf$ZB!o6@O5}Rr=&f zXIpi3z5ks%4na4Jrpddzc_U$W`SzQ@KuUd;_V3cs{xfGIe)V0t@%eBj02WD4aC3uo z0gp(vZNy1AQsfu`wiodBW`N5DY5e9Z4+7r{my`Gc#%jP~G?q3#f*ITxHqw4^nUhTG z5#JI zGE@>8%$!j(y^U}h z(2HAJBTHD^mfwAH1rjRbQLOPF^rz+i^A{Z*1qvTNZr8l1bWjNT(&v(%S&|1<#pIPW zm<$pVEY`7F*=h=Lcx0zSAl>xT66j!d$X4Qo8G45CFehr46kuohdzb&O7GSsk3_>f+ zo9@$NV{2 z3hY|1<47a#b;H$wN6Clz&IR<^&G5;ja8oHD9dVEQ=kPEnxemM2d#WqU;sm-{fyiWl ze5fkWEg^cgy4|^xizdm!@xR6P0{`(*#n)G2sR4W{-l8c|j_tRerM*$hm05Xg!^a=C zIapvP`ZPVqEPUJG*ArRI%7{NhdsAC)-aK#?2GVgx#MWtPJg9=oVaxzBO zh#f$;CntbvI&jMC-F{5fh0^?L*-laobsb2}mH=T!+i_4cAoAw~c1*a6KJ@M>X2AA& zN*cp0Gtk)P6o7o|`Lk!D<-GNCs51@j1b|FDf`MG1yOk_G(LlLMDAjjNo7?UfnQibL z%L^3h%K0iwFiaLB3W&Y}0^_XsLv=*bme!Dfe|9WGg zk*9!y#QRn&;gcg!%$RT7fENlh%TE}5S@%Hw`wtJt4Q+Z5Gq(&Vb z7W%Kb!6bg{8Wq;oeN=rGHWRI-2=FJ2&&ac_^d=3F+7eX>fHyq>?qMZG2Wv$v{KW18 zEZB;6?hIh>aX*n`S~0G>axe?yvts6VVHQ?bnW~b{0Fl1|CwYVh4Kz)HlBPPGWNeC# zk|JS2NzoPP!8s}BqIkOf7{N3NZn0EtSD+2pcu<)0DhRwIHocD1UObswx)RZOt@=vs zuyaR9AiIe#>o~*JA0XXMwv2E9JCPd#&%SdzuByLP9c)YBJ|ATHt&=KJfy@MBhzaQU z@krm$xCqt?si3%n+g6|;CJDI>$YnCo#oqpXhaZ8!Fg!9G`95TSd-Z(xzh5u;o|;E# z$}8XVxe;!nprm7HI7Pd1^$cw=YTVWFN2j-6`8V2$du z;Fp(|Klh@sVSfxTY-dCn{%rB_ZThRvzc(ZaFdA_7TC$#OKpo8qdcb#i<0KxXb0J#G z+U^tDu*Vggb}S{jv=uKF@0#!%CNq{+oqo51BrAPV&iHk_`GMgM`%JI$(nUr%?GSr$ z43!Xd34{?f8OBA_uGstdl1>8WX2Kzu7v_6Uwxsl9#gI~S(mSoEkx8Fa?((+G7Afx) zs^9k9`~E@w`Ey;Dqo~_&L(>Ze?xsmE^Nni!=;Gy1I(6V3SR_aLBF$YiwsFB7#F_Vf zBE%?`w~J*~N|W8zhi}o_Gl2su+tUhO^Qc?u{EQV1XwoJQ~Rmywv zrB>AsIesJNU(1=iQZZV-#H#vNQQJIPpnwcns(QP8K7QcMkJl2xXY4*?aJTGH9&`*{ zxGLGMb?Epl%sJBT$MWAl*4^&fbBIhSY#SHfC|OtFJ|kD?IQte~Po_DGRPs+gzc z8z!3DLQgHtTvaQ{?73`Wuf$bF2&MF2a7SaTNot8N&S#I@|A<8p_1d4Yl9||zm9Hc= zA#7}gHS%=nt6>}XyU)#PxNW~bXnOR`>sGS6%GVqG z?u}nuqtjk-;NTNdh~StnI>orzCf+pk(q7*9?6Yg5Qc_aa=bwoR3J!=WC};}H%Ln;b zTF%9{xBu$?*c$j=tHu9)g#X@~uk^n+!_RImbnz$#j3QW*AaidpUYyG@4I~j}&dSAB z%Zup#(Q$3L#GD^ld+6YIGf_FB8aLUa!N{JvMQ>wrP)vnF%AD!WHE3=jbO;cEkk)TmIC4uFkURQ zpI=X5mC|~)oNv}8r0!K%mi8@e_AIpB#`pF0Dc4q1s2ueDc-tMgb97)npZ9;*dhc+m z)4YRWi@2tWqI+#IaXI_K3r=PFkFXmB?NR*)qzpv$LZ@k-hJi z&v)F{?|0vqi|hQs<+{%6yq~YfdOn|&`d?1&i^~LOL4v!hwbfk;MOn3}k$mC#H*W<2 zf$0?pJ6_tBis0?mRt^tWO2p4VxtUZmuwtd}oP~t(@OX6l%YlwW=6DeaFTKm`v{*Z8 zADE;wEXH~=zTBVzl7P8;7KR|mnc+w^X+$JxGj{AhBRe`c1d8sf^T~X zZ%khX#FkW*ihn?&V>MWRAEgS-`YVfdC4F?EB`tLP>RNI)XL z!ujx8#V@i-@&SXRqr2s=mc!5Jv2E7-nk}uAlqJt9x%{cAR^G}R!->Ee?VVA{3R?6U zO_d3L)lA+Bg}+;&!KDlp34Z>xA?_DB8gvJ@!_U$Li?Ga)&xVcDTi^17B;uy*bnW;8 z;`zGt2U{?qagq3>r;t@hP-!*`br1}f3VZtKS6a3**TB;sp*|P>HI9DSZ~S(;YAY{R z?y%!Y|KUQ>42&VWYvBfcYaM z!l_6SL2_^$t`Zc1jn%R?H6*h&9ga zY^57R8w}Lz+d#Q#h{iYNGN0;_e37XosC2V?4fj=z{3PU9yU(f_UbLxdm{+Ci>}KL0 z$RblskbJNdFW3+6XUS*|nCxY}4%rw11sU9ZTZq_=g<@#|S#?zrm`T!HlDO9t@uU{Q zjLkXv6rSxDRzpmSxkCU+Dk{}r@5^t>$xfIPKDii%UmJ!=_ z@jaJl&pC%DIa{Jr_)2E>&(c~sNysRqxB#z}XqL~TD zjruo>K}3Z^?_Phw%@;Q5+{z`c^FlUg_r9soxuL1Fw6i0*xhbq?&z@dzb{?v$ceFid ztMR>ZbWr#zz`Cqm#Dyo!ILq3Uju2{q(K+EmF zX;K{H%xl7~SZKloco-is{ z8Y6N}ipK73OBhR`iCC;VPfYw&3I3LH^rte&%fRm z8@l%{xU*N**C<~;aGIv2To^2~uT^$U^?wCa_rc?`gNx&%5q6tqFAR<&pxO9j%T2(~ zM&Yhrp)X8F!Q#UC)5d6*;MN{RGGim$i-xHn3>2`H9T;aKgq?Y#B4NrsJlR`edrN%c z61)Mr9XiPPA)$D3R+NW_$5Y77?ewFQCold5vYP+DpS_8x(w2u0oDKW8mJ6il%$JvS zaAziu4^~9Lv30@eV@E2p*}yX5ZajisK$9@{DI5#VBNK;sUID{;pqe7c;?f*(_tPa| z3b0=s4fr$97mKa4Y6Qot2buni|D- z)ytw{F)eC|Ehi8=Bd8~Q;a8(ZH8P6)>5o`sxf397lvdubm4#z=R6u&RMY22i3|zKnN=`ewuBWwbPbqO zExh{gMBTVu{JAECnuu7>^O`@Vu<(`kJ}XwCuw;cL1>0+#as#TIOr?)AH1v+wE=2f= zB~bFi{IVihCsms&5jgsYEQ^ncGq?gCEsY$G*IH&5Ki=7!SF0W@e%8 ztAD!dXO{~k<=Ggn+TI~XT#;5wq3y~WZO0*G`5?5El`9%?lltPO`n$ehi=bN`XuXK+ zaO{7Bu>Zkhs-|%Eb95Y8OYpyp6HlooHYC zjGPc*TIjU_4SM}NaUQABl!>^vOh3-kWlvW%X_IsRgTg|ql9*jMU@1-a43e>m4kc5@ zk@5VUlE`y0FEv!uAUYia&0Yhc0%Ts8*tD1NCByr2BCs{>nUrfWd?KQ<9V}#Ho02BE zwKVav6@$C@sX(gP6Mu=d8!l{)v*eS}yJ1qXf~TT!Y~UVt5imfn5_?p=j4S==-{h}G z`$g(b$qFS#7Mp}mlkd4aD~q}Gjon6XE8?U4fk>Ec-$-y$_!NF{CphUrt}wUxFJWZe zdXW3h=Zkgk>sIQ8p9|IPS!{M~=H2*Kr1sD$L%BQhWHqJyq1t6kH3Kgd?Q8I2KtX7Z@@vB zm6Sulw8rtVdMr(BQyk~naysIJ6qxpmTg)c9y;)3v5< zvUX7gY?l!#_TH0bBQ2#_k(YDK>21i%5YhkwdGn$bbc2QZuy{4a!4FyLCgtZ#g-Ibx zpEbja&F#((A#c`{(hfA(Ly4F#{$S3YS-UM+E8j#c+MTDP8_!VXs8xn%!RyB{_>Q#}({m5`?kH>T`gsVxnXM3Gt`(eo9;*Xt_=QWGCLg@r|Dc|Dy zYOBNjgFEu}`AR9ORa3d|UdIY5IB^I5Jgd{I$|7ddZWli;36qu3vVjIXb*q6Cn4nom zJ=ugg6e)>)zD20jP-xSAfsh#MoeE{8{{&GnT=>-kynO7u-LnDsxc(OK`27{5~Sv9tED`--A#H zPDT}ef^&HUHmn>z4W-9Pzr2#^CL}ry@%g}z$Rji@``JV;5h_Mj!mRmaK!c3u$eGYn zJiRmsKAx)cve*0VIC!QrQ?j6qaH^4l6nIt$VbHpJbFfIbmNK%h)tXEp#LjP46gHX; zRqqx(crfnlTD|_cx0&&<+#7dr`Sg#oM(}6SL9Tzst0?DmUz`}joEn+SZFxs%SAoHJ z{0F>I&EKT8^m_SXFZZ-D z&GGRy4?kdEny81j{?H>}?^(S7<*b6M*A zOMClyrC=5%6WfToJ2x*%tG=Hnr|S%|oK6DxOnv=+JT313s&0f=(W^;L62_5%5KO>z zlci<5+{(v0(bsL;QgutrRVP(clu+*vzx{4%r98j08F;_)&-aUm<^fxWTepSfbau=e zxS_^kk97M?S@5CD@CODpkQR6)fAP`>`&e-_fw$^WF+>(K>!aO|obfb&5EbRjBU((P z8$6$(pdC>`8uoGS?(QjW|0MnW7r%-zJtM+TSwyRYg)`0yY-OXa^T4@La`74n!y+ru z9-_+0c%4Q>7`9W7iyVf%-_VZGKxFhrVDHN}JOaDve_^FXM4?PESCMSL?HPEk!|zC3 z@}U3fqBtnwZ7`qFc{zuKb79-?hJHpwLsYfO6O{X=AcL2}o1yOMK65#WLQGgc^c9n4 zaoQQO;l7d*9p&g6d(@LGVYxi@L>)8CS@67)?tHcw&#I20qhOrQe?=K*<@4oBNYS4Dd@@bvMS92AJ}Yvk zT@vMWPAi;0rdLF_0WSMh!)D+nN4;v?Sbns75;p3jx9R2zwXb;U1V^|%A)F31eRMgb z@VMh;RoDu-U_uUr?GOv)@$?cFT)*8BC(HFvO`KGI-TD}VUH70yl<&%($j zD$Ek2KuTJM&o!$^%D^j4>?DlQOUq_*_RSoUXgFgcQFjo+&hlB@xjj1A#v@YED55d} z;z7?4+`Xs&QrUB)GezF;Q*Eu0n3xzU-=enqm&3*S{jtIM`J$q&`|RVNg5!VvSvmp0 zmy>c|_+6Y;VQH1u4(CU&Uz{HUZ$#j%=#|591d(*T!IxL4IA-$887+6A-jh5|2LI?P z$?Z7AObENW8p;dH7>~Cmr)NN4`9fhZvJ;=CRb&))#UAR@3{kt9`3QqbMhVjLSog<^ zJ6bhEhUEHsGaPx+L!racUU~1F)85r?SoFVTwiFmY`Yn8?F#pqsK>wCr6>od@R8@bk z4gKJM$?&HPBw`rgjjU=I?XKRI+?EBxv%LEY1cDX%k3f7VrXBoizDD(W!kgPo4o}jJ z(N1pIQ!z>LH@vhDeO6S@tHK8_Y0W@K3>A#0hT86dt$Nqb>83up?3%jMYEqV;Fgn^9 z^3}7Xd2;-_+1__Ea|Y{Vh|6iwUZQ|<_$*ZaL2w}`PtX-&Q7A4jNJHjj0n_?yA@g%7 zra3Fm^*#=cHjB$m&h0HZ9!Uaa+*z$sbibIsYTDa~wd*AePLWxZO_L$lN|Qf!z=ki~ z%zcEDYk1#>G%!sau{3(OTDMoMI3|Frg7m4YlB9tweWE)o^<6Uc1T6`&41Ya@0{WL#BoJpBAcN<% z;833$JTILKlbGBsq@3v~BBb#QhF%O#PXlFEElAaAYiLKku)mhNtyBA6aP5MzbXir4 z!>LXTk{2Qqo(?TLhGHTpo7e?FASKR~+j_mE0s?u~YmUZyxcJ4jA3Y~{<@wV^Ii+;G zl3wRZ^cU-#)6y1G76a_fhixUrMd1iO&VIC#S1$9$fQGpzi3^;<{Ty!Mj6wF_>hiE1PcZh1WTmRYHFC`_l%q!)^ z5|f}OL8VHM-!`%E@%34jS9)V!x%0qO(QU776I8UN;SYJdT|GWe&K-eNoty)hwd$Grp)tqyLtYV_*GJH3UXk0_QRR&>=WjPJ-){nWaZu}LE zUPkS9#tYkHRbE<$RbCuSF}TS3b0ve&>Be+XZv`;{=n7;+8Lk308p0r;%-MW=`trGo8+7zu&*_FZ#6J$LOH|7_V1iFB$fGAim5!J*QsKkF*j zoj88^_r_L(qjkJO8~K zA=`qd@Q7;fc@w6`+2;7&on|`V{UogCnm78**TIpWJhPYmxuT6d2S@dP?@zIw<74`1x@hWe) z`FFL76HQ+=hpSv&a>yHMCnI>%i=u}~%wBhKEmIKxcm$}^O;FB6RVQV+@d89*e|P2o zP|g2#0fHkJtig3NIDTCru1Dxn%oReo`nYcC{dLL$$5R+z)y|{TIBlZ@qx<{w-&2*; z_i*2%gSOrW%PJcj($K|D+G!L2-GQ)$j1W>g^(Q!Afz-MSMk2An4Q~jBR&~S`@Kla@C&9YqE{HzAHFb* zl2e6Q+dky^5ywD!@e=OE`IQOY#u&CLOBVR-Wk*!mw~|#6v(rFj%Vj*mvpG4$qVcL3 zba*ym8|^)Y*t4;x&&soNgho=A=nfiEsRB=`o^;yEzlmq1oOE$nwequro(WK7L;t7j zsR6~Anw+1Jr$Kzha zUYPuT;qqFyzM=1AaFFA-^awrjy$cIfI5CQ`F(VKy{0#X+77ZrNSXE54DS7GN=#$<3 z$Q2FcGM>El|C0q^b<90wrJ^+1;#SQsXCgnYG$_O)o<4mw zT2(gPRg#H2^4po&=^neATL1UY($9!w1@mQB9h@_B|KNCwhexDt|2?W5^|Vdtyj&zg zg11}gAM^y1l96{*x5@XPTAU>vFh{%%ADkS0P0Si{n)H(qepH_ZH8GwWH&N3*fiRLV zh-`yv$sof;p{r3)7`G-Xg8KZ12={b9QwbzBbGiXfVTfx`fk}^Z>*iIP%wZoee)K6I zHN%T6{|bE?=^vTw?OV6pT-@5}ay3=&{MzNTv9%U3;?x@FAg+3F{|BfG*2iR=9?L?? z%9!#-Df94u5n!c9(DIO(u>^0_95?HTba(_*7O7>ikUX@zs}_xn!2_CpN|aL6-hT4z z%>pqnyF*RGrXWKp2~QpDf#u^>;SZ|qMR!xH;sh@%4i672I}9T8UimV+jE*c8vA+}) zJp401vmalRcU^sNOcM-2RW@k`fm=BN0nfgc+uzk@;#P?ALu%nlNgf{P2E=PZY3oxvmwlO_$F^HlDN$9tGz8$2$GrQo4Zi4JM#$+Qs4nW&RBqiqF35etr5@ove87^v@4ZT_2bZ z)$MiQ@+S6n*WPda39;O1oYLsni_mH8?iHYpvETzpG#&2$3{jpQpm}N8m+X;mE;c95 zj-^M^QZRu$`4HtUcEQlEq-E}Je3LT0zS&hZ0aDnJsG?ncLbn6&u1=By}f>* zo7?iYphWHV_btpjwS)4XE5gC1+hto8`VTidJ?pb#TbxDrHDkDJ;erIf6q7FE1mK51!b z$@llk`967;PcXZ^*J;|VR`a9Vv=S9qVmns&*Z0qGf2H@{A5BGYT@V`ZbWXe@VG5)b z13Ts%BU^I(C1d&zq!oD-gp_Y_@^x!=rc4_+T2cMG)lYx^B=YX%8A$@ z@?V-fYA)i^XtPRgrSoTdhpe=A1J8C3+Q;cA;QZfTaI(++>8+qF(6|a=!nF@VC6#G~ z*LM@aW6J#@!7m_y_nerR3-bK=hXvMc35z&3q5ZWVYYRvH6McPSBZG>=-zK{5mse;h zH|Y+dYzq(qLer@@X{S`6PY^k15_2dx5H6?=18@B)7{40|9tu8IGrA)DZqE}43{ve4Tq>s{vZ#G_Vu%TNEB)`Sc4C2IJ21%qr#YJ(ykeJ=l|ufB0> z1e7S|+oQ{Hlk?^>TFvls71$?pv@{>InAjtuMalH%kbw_M!vlR|!my?0^hZd5>gM23 zNj@#TB>q#tInV;OeH1D&$q%i!NHjw=1^p?Wtd!QjvT>^+>uRzMP!ey-eEsr1ctT*Y zpTsM*+c46*D$mQi@Yme@<(`W2%uLJ2h7lM`JVAd@+4)f$LTiy2itDk9e`jAB)r@f2 z7ZqCFpw&E76Od!GEfRhnMIZXZT2sWy5kj;slDAGa*Se*Qw<)?}IPBb0{t=Qy=IF3^ z<0S6Eta)vI0gx$lB?e9HaB~vb~{Oa}%bwpB-R)ZtPBe z@#dQRdOhAPfn_VG@%z0SBdD^DI@>m{wfEk)tARdfcBZ3NJj0x^H(2j&MM686uc2XPIy*JBi3WP4af zd>9-hr;_(B8@3L1G$KPq`Gn4T%Pp+3s#oZ1N#o8N z**D=)cqH3T#l#+w7~eg-IxkH^XZ8tBfdq(OBm>@RjVSm<>j}nXX+&9|XNvsUZx51y zGZ-K76X-j^`L~(y0o#B8&kk9wcpWWl+-k2v$w>|pnZG4Wy!@S!vuiy}OwlnuNMzAW zxV@%_{nW)R`Fqa;7>z6r3oAGKcqg9bEIB^lx41T&2pa-@&Z4BjWD{AAEExz4BgFj|;V%O=wAj z#9+kZ&M0-Yx3shj^m)C08y_E`Yc<7r^y6!$VKSHD(CPW-0(ph+0~SU$C`S)W4+{Un ze3yPrek-6RWgi-(tB3kJjRbm+t~{9+oFM+xwlBv_M+t=LEqd6Xz2 zO5FGa;;>GD+q$3k=ij~BqQoKI*FtU~_sy0(0uDc^;B<9$hprr~_pgM(0&`9Zo00iu zt+jvzkG3o$4$#LBaI*r|dM|cfGxb<}y8iSNKP<=~auX|6a21`OdZ3xf{ZomsfLYb@kfCrSAcI!_(UJ^?rq` zYtORtCLj2edlw*pKzCvW>Q>4?!zri=M!}!QPa@UbMhty#5H` z#{iu@QN~U=iKt0HOY=AmBuI1npFm|eJc~jjYz$Eu-Y9NlQyBR|H7ml^Ne0N123gsJ z>e19Hh&Sr4aU_)$h4IdNmt6JjyR6^d!w159Z|dFayjCaM*aWw|51#4%om-eU^SN)f z|E1iqUvYo#oVuiK$oJF59k(5o;_Px!hA3q|(GrLXE;&ukB_Ya%SJo~eiXNfNlO6`W zTLVcrvtbRDWsyF~!>S3l>p#sZf9*h4jC)`R3uh$vjN(`vg~*r95#Lndyh#m3d0$`1 z+QQ@GP-PRkY2w3D222D_HLRXp-IQ*%opWxeovuT&gM%J1YcRjtF9=TG7Z z>Xosz+bbfWc7SVX@9f-3Dg1wvZO8EJF!4>>$@m_jYaRZOv!gQqp5*3sARp9^NIQO> zfKn`_@t22v%RkLl{v9~jSGODp@;kEFxX7QqMAuM3f*7K5-Q`4fSQ*5ce91%pGU58Y z9>#Oxbk9Hl1WeNLVc15@hxrrljAfjBRg(Yn4G5 zFgeEblr*vnXQP}sBnp$w)JTT#)5IC{v~!DcqinEgP{S8rCBQ3q6c(mHXxT=7;it!n z^|afmCi2De^?KozW<2aPR2dayvs*;a@Ng_%S&Nnvd`>G8jSYYl%NY;x0GQ1CxZvTZ zp{7^-=-(8X=;#a)sDmP^GkRf&U`Qe`71~6Ee$l17G>ssyOfbUr*2CICRZG=f%2neC zfi;rLf<($n*yqfCI*15Jy8m`?y1U2dOsP98+#PQ_y5|0K=~Vkf>DcI5)J|?k|CFw? z-y4l9>O~_T(lm(W$pkg2#eguGdePba*-;GhU}pr(&8kUC?|) zlMj(KVf8PY;hG{y;J%)aLUQJlune?;we_oOtGg}VzwHzrXw_UO%eNoUf32z#GLUm) z8iDm-FKs`+en5qmw?+K=volk42IjMRa9N=SM}O?yy+5u0w)bu)|w$>ITL;=CP@$gPGgV4qG4DF zDuWKo0qdnjEK8~K{UTtz!8?~FI6n7|@X?n%cEvnGsd{v;`tsd6*XqF4e%{SLhZx5j z)_IA~hc1BKCTH3b1Z%wJ+R0}@J`jBZ;Rh-Z1}_6NxDpi@C+Vj%FO#9;D2C-#COGXw z|DXANhdRwA#2#s_aOmhl-|?H^E$>Ap@)1zniNuQJ_LP#67Mr&~dI!L*-BGZY5r;>? z12-XW)TNgeh?5^ZXESuf^`(o8i-%?1Cq6N=v5|s*{rtJ2!>iQIXimajTzB$?(?VQi-P$c{O(LX=>E|Jz#eq!fe z)~dx%!B+Za5emr)lCmP|QVGt2&=tIio}5H96~PB~xOUmyd3sMi?}8WO(5?B6Z%=;& z-Zp=-D!bRVe;(oK@10b?H}RR|QhF}o%)(9!mp&40$rVIJ+$?s?p(4r`;eeayEw(p@ z1CacGmZT^0$c5-u_$~~)UR#jAH|03~v17i><=x)=z(<_hLrK{k2i~E%*R|eR`Lmhq zcj{Jm&Q;X^TsHN5U{$;x#4uO?H3HZI?Ppks_06HfL;M^;6dH0RluvUR$S($Cm_pfx zh16{GmSh5t=UZ#Cv>MxSs#bvKmtCmqJKsDa56>^F1~cwb-Ii1W&kg;a^0j!lUmo!( zI*-W4shKi}?H*8c6|>P}2qQ~M@o{R(a`i=?w1)NrWG3H0-+dOzdqY~LVOy2#?E~4xpX4*^{hmjnAa03G=^x+k)naVL|3gM@v7qqNZ+o8(OAKuhS%ZiAXh# z1}L`?x6;6+8UGx1KfynAYA%5z+a;^&eT=lj^n%NDqAt$_7U`@x5(O4aaqyBsvY+DB zcY7MYQ`C}ZrrUSa3_Nm$pO_1txRCmgF|Y7&E4VlPXh-`?LLu8x_^SKG zZjV(ySp(WRTYxB!P8Sm(HzDS<^+s7Q!33agU#`TpLQBrL-gLK*35%VsF4@fSX!p?T z!7GGzguMdlQz<_S`+lmvoSa%@de+uZ>zEkFk*ccC-Q2uI!$!4U8x=mz ziu-O~vjyKY^QV5lyu~IWlhH4o;l?v^UJetXK*}j;FI#M!-`Hn~=ive8y6EKGFq-OA zTwNVYJ!N2Yo5{(#4FA?XDyj{BGA%=by%}zz3WlnFvm$d7F*X_xwYb%_xz04!Qkf=y zM}C`<{i^lN*v^E&0VHVp^hO#hrXlH!c#Hu<_+^eHJ@iEVw|n()(7z56Rcd8>W3>>6P%IPStaq}mrgMdT5x zid$(vE0o~TNWl$&aBA0kQKsmt1AcR;=iYwBOlL&3Q%ZM45zKK0y~Nj&uv0u$BB}ih z8Bi7Dx%^8}wq);ApfH2Vr7YuR!5A~u`#Q5q2Nee~M%%-_}4p{`R@|r5SFLiW$QJtOrv9Zr>*sp-VB_JI_f_~(&G{cqoz}pf8 zjF<+7Sz>fJ1py3@Vh=BaqSz9nKFEd?fo%y^O;^phL-}KpY zwQ|Wl!2IP)KDC6q{R&R1GqkG^HoVzD0Z?aFZ&fBj$`n)uK+EooxU+~56Vu9%yGv8+ zqAo%)w`R!(Z1UOzES~b{E|@BVm^3@33Kf@b~c!nG)Dy`2CmCoP) z#t%XGZR_`!(t7Fc>j&EQUULOGZ)q<2D~~-#mZzLivi*Hanb{%naD<()`mOo(tBp{g zdIps#%ylc4zp-`kr~x=aZW+E%+BgtJdzs9%iNfj>Wq@1=nl_%#63vfA*{p}0h;Ik_SPSALuDsL| zmhiI~VVCXP2&AznfWHz5)6o3m6b?!?<8{7@MoPZu3UN#mg4JrAk-#Hw{%z@5j-I7*M9AhvP^;4iMW%K%N1g?qlZ6zC36(hab3NP@X4>@rj2-@R0$qzHXg6$mRe#s9_uP;eS zX*&n823=UR7U9%B3=#IB5Y2|Ky3!l|vVo?Xx3qvPd}~KX$3F?iXsb&d ztyFRw-rQQ=sHva$zG5~xVSbV2&&Y=Aa4nY&`HBEf?@7dE4!+z4+7e>6HGLSF^~^Ua zyoo#h1h%{w;>0`M*nX|>c;56WgPIJ1TeejA1rZ)ic@&6An>dPGM4X(5ww_os35klFI#bca7?i*NYP z=)VtiTK@@V8Z=AMkb06@Jt|{Z&cXa&SfSsWpijkt2m4f<@q3n#cG3Mk!}lh3ImUCE zNyK#GoaPE-XUbrE0Yk}y9*)eiAAxG5U=+~|g&i}=F!tz!Vfx07RPc!1$$tCxQ+W`W z(4x)AzW+PWc2<4VG_`*D*KdK36a7>`j0mL+O;EN(v)ax_lmct-(~ZDin+m1JiSc&7 z`T-huNFws^ zkw?yJeIS(+t$>*cF+j6oQ;R%$Uu9@g;W_46jBnB#iyWP8GcF zve+y-pq7B>*@I?z=Skf-l8UYCm4Ik6Ag`pk9F{gvjiAR30i5spLdkqj{|K;xR4!`_ z#zSW2T}F4W-+Pex0N^bo_!lLUEHAZ_W{mzFwihvfV+sn);Zpxd23*TZy8*zZo@mH^ zYV+Td^*1uHQc7}i?~99HZ0?7jT)QmDBenku>>;s1Sxr%9v~K=wMJwgGGknA?@Kzt(P%-!K4SEyJ^(@+Ln4xqpNTATK@v7s zJoLdo*Hc6tO!Bc851m0q;y`ej+od$B4FQl?*&PqWd@45RB4Mlm8hRUNiaWgM56c8xiGSMRn3 zP+y{HUc0D1EH8mfAj3f%-8d^b@dU=s8hWj){iQF1aaoE~R>YCigoIpjINAfLMR`*q zlI@UvT@PU{?gC-^n!AGltLkq6%Mr74yA{i-(2_B5SpY9C~lLO$4*d8kh+ml9hL z#Xgbo{b6cT_lwz;%RH@TzqH23w`mF`_-U$o5knEt{>q$@E5x2lP1J7!3)6TMZ^TsiAHiqW8mi7fMz|XLhPx8B)^uPR;a8p+&4mZQdi+*e7iP~fJsSK=GB&#mXrSB$eF z-FYN09xv$_0ZTf#xYO;)`*9%E_6$;LIHPHQ6A1#hv>#vFrIs8 zM1jQu(tcT~DD2FYS3%2CM)kHw{2{^H4bCB>-wcj|U2oqDxNkRSjl)GRgLKAwaJaYl z_ndPzK39^ajOiH|5jMtvkt*|uj$vAgiJ;K2Vev@d7y#<&m4Mw2%%t`rZ}pC0+84d= z_CFOhM!(lZ=YNk?j1gBVuTMz121~|^K2Fye!Yd^5$(hQVg`&3w!YxC#Yo`_5#$Wts zZB6z2O!c*WeU;_5zfMz zWeR@^(Tl{(D;xg|nxUJcob;Z_3UwkZ)8rq3r$iD~@OH{WNzXZJSh7A}WaMAkkc!6L zw9&ADH6i#_g(Rt0XYS(8ukCcCzR$RCVEC8`pfBq+ymqgT&HrQpj)1Qj@N~y9@y)w^y88zQ~qt+gD|^=FfhtOI{xi!F|jO2_9@2;$hU@Ei9WWPv|KuTQ|3isYp;| zJGRgM*Tnp&DnPm~W2N@x6B{dEZ^|3TArYu*>4L-PeNRXXnos~2;{YbU zJ#fcu&S#li7S}Z{bZXl^#S@7UXReC$f--9%u)kSoGXWXB(Pg6|4-tPQ4sReQyXbMK zBmhI*C&5NVYpxj0#wyF`$e)JgP8LW?ARBM4rRcQ0;N(o~IT0f!HllQ*Ge!rK()#wz z*ZbVeaN6U(zb`sZOeD%mj58s}_Vn7wFoy|Xp=^n1!)2aUWq#_Bh#<3<1}{fG7JJ&x zCQL)w5{iNQ+14uDj1Um8<~p_Dl#o6MRlIR-R|7e5$t%@eutRmId(Ql)L#*^B+mqKv zf44B_)ON)FKHUmQ`LuJ*(0F=tK~P)nnz(+2#U-!xez9XYh`_GXp~NqByASjMomsni zEb`5nn%;~@ly{^~k3JImt*fBzUe3Bv99SYVEyz)&%F;E?P5e74Lu7 zooZ`4%gI@ak`xtn4f6E-95b3)QK@vLW;tknTd>hCfjYKsPfV z^E_^t%4vjzVLrIdydzlABTM;pdtL4x#i=51o-DzijmEsc@I~Xcza(f6W%*BgXa;Bx zeZl{li+s*I$BY@qr%6&VECQ z6dBXqBO`IpNK;oEtY&evdjG**-hV~lMqX1?`n_smI_5F}{%B;h2tI!p_LOgcuMkU~ zPEAX%h)n`{as-7QfdVbEQBi>+=q{gLdqN9nLmKCYjcT)$Fy6Pzge*_K*-eA6hQQd~s+A1N51AgHQ<1E(_?ENR?;M?_oI|53sBJRqrr5 zc)>7t{KEu>H(AgIpq({kUnjEhfxfrX)?{CgGDf}L`?CK@eFW78Obu@Qsms2=TYt%O zslBb{jPHJ_{f}7Z7$#Wl6fI9LBX*tItDHMW?d1!{5QgS6gA<4LhsrU8t-5qHDzWf?Q zK;YC{uL8C z&$3Y5%!u>waS)ihj3uiDle3EVEe*xlXlh=1nSb@f+ig2tp(rruG3EB#5gl_AUw2o> z!H~Sze)S8~tNX5ZUHtn0oejRYQ>{`^T9)$M#$ItGWPe+hgj=ny9fZHO zv}Z2dbDFNI-1&kmFcx&2f7($E-Z^IlM+p@2igBxE{2yr#)%gWa4a80McXQ!TzCvre zXVGt54K|*=7zi?Y@VsKe)pTR9EhIna@7Trn>OcSHO^sn*j6a}B-r%9@o3zq6MQOPV zlWD3TjSuSdBC8rBpfseM|A=;sZ!*A@Sj|6mEfk8&HwQcbGzy2qeL=W`g82xjEUELe zJ)wNw0W?t1BkphmM_gQs4v`TLcLiVI?&~4uLSV8`O+>3ia&#y*jzE}uDGf_Op^tT6 zA#W5s7Pp=T?OhsbfXZrUMTmih!x(JVG`{{$_TT=KVt$K6ZjkYaIRS#TXb24#Zcnf> z1n#B3z2LA$O1eqqwFdZY0iG~J)IJNyvz?^}qlexeZ(F-~JZC?&X$h6|CWXbTzoG>H zP)qEAyx_#G=wmT82J*vq2GJa$i8kgit`@@!kQ$g$MWiD@M4-0LLg>izfzM%Ca>T(I z5bWrtrd$cA`CRV!Bl(=34ou#jC2$y6pjX4l!pty!X&Jczdno8g_=dFk8Nr;(=yQ__ zpA(3?mD%nAf|(~cRX}f$2Rh#;G7|CZyr;5O*S9GT`Zmx3-MC6zIgnN~q7#Pg<@zae zI@#E8UU~3m{Fb`=-;Cj(KYdh8Op5Z&&4<>Tn7a>Vwo5WYYP^2U7?}$S)+!ZlggSpW zH(SG-UY94h)}6YWit1PJTZ7PxX|2za%c_?V@yEg4IBeuI&^k?&(T)HU0)X2Ib%~m{ z*FQIe{MTBrIMqdc-S|3TaPRp=RItCt(S?wr$-c5ZGK zUk~p}?)=gZIkiCWK+^41xjVoj{m`hV+vTvF-3W^V$T+ zC4pZd-rAcqxLZJA@BS+T0C2bP$7M_ z9lNPx10h@g)m^H^f?#ui|IA;4oI!&m3qw3%ipV^9X(roI z5NKuzQ-Hggtr3eJmbOxAg*Hy6Ytjla(UPqj$9<5WUdJc0%!)fd2CNkFr&Vl39Ba|34v*pI4&H zUa(h4hvcv#c7^cM>>0#p{n>M=W6F1jF>jR_jNEngf;YUhP4cmyFnLz1vQskuqapy8 zTbe-`N(E)6*NNxXR9TvKjftIF7-l|$2^JUyQm4laa&3>>S-6>zHm>kXU|ho(T1Ld? zJT#dn5)Htc4gl$p_?(C^DB{I+3EmP8F!6myQ|6bzH3X&1Sb|_TBl+aNKpQ*^D-Kqf z%AAe1n@hXJF!v)=oQt(1_?J!-a6F}SN)r=h&x*rkF|y5akHD~j-NsvRF%j7;|40|% zl8JX?s&eei6KYNRgX=VxpFDEmSzfU1Zl$>=dj_YdC*h>(#o;VyJ?mo-ftxa8Z9#-Z z_nJ|m;PC#F*lpX@5TpE@4HDTz3lr5x7&-M~n5` z^ooQe541~ZYf>vz#iy>B2_MBA#dk?F^Y7XVMNMrRAekx$8Q9GnPxjE!bugAHHJO|G zD_o9Jyp)Slul%|5^l8O1MbYi(;PtDEL6r_$q}qT7^*!|t3u~RgB-LOhx$uGvLK$dT z*cfX_DA=%S<2j!1K`d@($Tx|kqyrQ%NJNot z7#q>a^Od(8)gdjy`@K``&8c~-wp=ncEkrBgM;#pW1G1~^{ z8M(!kR@2<$kO8H@VhNcm$WK5bBBJn$atGr?jEOVrTflEFv4|)<1jZmI&MwAn3{2l3 zp8E4uS%nsm1VUMu*Rx?{g!gvUMEwrz_IMSTz{xepStV?(_^!afNnYBbU3d7(!5sgA zmcmXL)v@7V0Fz|-b8CU&posgUog}>FIp{MWUr?JRj-v7F6`)FQp@=J(~ zL_l}RK1Eu^i1a$WMOGiZDbTv-`)lzYO7G9?XTLxBfnzV+E=+%sg=*~hQA(p*fln*X zo;fmf3m8p-jb8Q^x-3kSmKZU{G9MU21_acDDOnj6I1u#m5>*2PJC#QaR0L|}9{}`sxqQrS`U?n4;y`xeY3uGPs36eE#*>N}~&WV1q4g~I&LJI-i7qFGZbPxbW z%!e2x5w(FA%#HW;nX^pllg&RjJLWOdg2d?ihr!W6+_Cb4OUI`qPOx#Pe?;Pt=1a}# z-#|U~i(#`&hqyGan9us+Pbc4PL$f-XQ)eDZ7k8zOi5`D0=}PrHo`>Ag+Y^`vYQv=r zC|wlGq_7y&UqmxUBl%X~tb)9mVtP~0>+Bwr3ZbzBr-`pWv+8S2Pu;;m`DbhPZ_E69 zlYXk-v;WwM5tME4+GpGPVcnG+lf7^wD9gmvX2_^;OGT8^mDI6&AgV(^Tq6ImZqVdi z;9^&jY=H~i#VMhl5~O4V_rfP&!mr3nFhI4TIiG?W8oXTE+*WQ2%DtK(rp`*d|G4hsukr}>1ioL*(%R`S+{6B0 zq0_C`Ph*z3gkXRKQccdp6RFvOXqT$$UlKUVUt2EK05UQ_etv+Y^rR#Ms(H+kMBD(U z%qt({)mWAx;OoVcrR9SG4_31~=F8iim+2qT(b1qfLwM1@zTT0Zj?UDe%0=_!9=Ig) z+lO-%?Hyf?585l<2d9qtk^)7?t(i0qFpb!TXTda*GWnH?7667SaHn*IQ^@rqtcl3U z(QSN=o@|%^ANI@-c%BbX%II=T5MGKh+HhwtMcHAj1@MHts`lK; zcgd+S!n%uFh{xA=4xo9<`0DG&AQfO4>aQbtw1P*RICeO1j+ZEJR+zO!uMLm&zn9!d z+6p0Yl^LJ97@X+Z5t7zR^ecW&+zjOg8}Ew~2uCa+U0hs17SjN>#tV4L8cg3SaPADY zBitSXTwIxw#1lej=N(=M#PH0h%C5Ep;w} ze*aYg!ZJY;{22Tz8*yue`WO8FbRFk$?-r1V2utWKQL)z%(4){1UK#XHSh6#Hjb*Lb zX`#*&)|a)WLJr5hX^d5O&Ww6NXvdpBG?Yw@-9ZY(YD2TuO1o^7n6IIer~&rgU85A) z6m1DK{R<*T<~55TEt)xD!q#Bu$mNO*&YBK5JImUL#3i-{LWg2ePQ=1PeYVt4M?fWf zc{W21D}4~=#FU}gzj_7wV5*N+vp7cufHT&xXf_k0|?CjEQ2Ea8Qbj$^pe-aS8eoPU9j)j{_v_$^rf>!GEtgx;0jROyc4;eJ60PXqIZFYdZLvzZ+&Eq%lPcU}03Z*8?wcKPi8r)3hF{>0K<$<(L8Vm_3ftim;Nm1o293EQJXZk> z;x65_kouM!zcAhFA<0DkvjpP<8o#LyR-_g~yKL}R8VO{Si88S1aVxo|oP~|vUQc~J z@M$yE)uUzUsV**$#VuOG_Mr&1rk6(B!Ed%jPb}^aCxg>(FT>fWs`5|ZR>k4Xck+j0 z-OM*%kmfoTnl|h^@BI)uoHR@!<7N-;MSztI=o;AJ)r7f@H|%>yjjMf3^Zkim^6oD~ z4T;D(UJbCRb0XMxuMCS5Qt}`ok$r1wWVqE(IH|_Kb~C<{M1&Whm;)IYp(i1vG-Eef z)NU(>7K4p!o>Eg!{_EgObJO(ZZ5zt{9>_yI^#9;9oe> z2q}{^{n-&0Cn+v2RFZNlE8zFEYzOn?#H_np@kK?#@M55hyllYQ;-+U@?-eJGr{AuS zQ>zopEG}Q&fskU1O$ME);?Q+K5E}rXwjycB?%prhZp*mi{S}lTbo|4 z#z+Bgj&}^eVc(_}R4&7L>0~8{1A*Xs1qevo>!)Rsc^^u-srp8P))zIjjb4$A>Mn(V zz&4;>O}ob(si4GbY-2aLnTE-PymBZrnnusq=PY z4iCp{%e!EzSIDZ#Vq>`w60vX~SEmJTRf#U2J#g+B-{h+?5sm*Nn$AFVL+{zO-s%gS2x=K zZBx=&2F@J!9|bN*T-)gsri_~}K*(@5v!VB!@hBPgi6ScWyITsGKtD2IOht_zCoUzI zYx@Z$hy|n0rEvLc4w!BDrD1>WEe)zpPiPK#V+L8lNm(NsslqmRd z5cW=;pm&7YCPB%z_V~#6?+%6e`MH5eq^_^K`*F?CE5;bFij4rBMz1kuzr#9-&WMS! zjpxOF{B3Z*=0Xmz(xy5CE=LeHBAgkH8h4taEa_AO5=17;sAYplK^P}a7f{)irP1K> znMB>i`3OHuBKv;TT~eQypJU1Pqk{#W(yOe~J)WJ7fG#sJ*oPW4!L2^YfZ&L;Y@mUh zIAV1P+|kLw@pC`_pT8Ckq;2^-4!1U$j7v&Wb}~VjS{GQS=0k5D#jjgbVHGqa#B@bV zwLb#uVlNz^{-bY^Ay7eK_}?oko6SVzOW938Z0urKhUF2Lcv!&Q8lwgho2q?~l(F#z zh^1C#FrI|o++e?SUC{i-$XKo#Q<0<1%eOnVzy<`NpulMa242`wP+X<{^Qoxu{5$Su zfREipLnFJQ?-D3Z=|9aa`1mit)^CdYUE!LYBZ0Gs=7)l6a)~&s^YOO37z)WY4Hy98C6j%qwo+S zsDh`kJ052fviyn($n7v>vjQQ2;kY!`8i(qK0qHYA)jUIQMWOP+;mL&J;fY7C){AFu zbNPW2^|cFQLPA1?XtdgUgL1cp-;qB%ieUc|(g84O{ANL)I2~~Qm*3!*$7&4l%R9z` z_!nTbx=i}01@3uOM=jdgh5(>bAyMR8>&j|E02g^qp63o$e*^pRE;G1^!=wy=RkpeS zp{k7VnP0&t4u+AlG7aZ!C5hV}7#L0cbDg|tcm(2!s4t#AeYXBY9HWYmkXejqhBo*k z+2ZU{&i=cMqpu zkJBJp3H!gw-7r*~ayZ^CL7*qIieB=JAHHlyv{a;y{DqMpe%r>5Q_#7ay*}ch?b7~Z zCKQkSzp1$2@z24z2>+{A#x5#$E+{VkbApqgOkvNVK1-_pZEE1+^6cl>IJwU*{<~A3 zs~XQOfAydE3e8$2Wdf-GM2bjk&RS_5Y;!fOK}Tc2CHva{m36Fa+0sYO$ zC9vEHtaMR^kgsUNCfx1o(z)Ldftt}e{49h=3@My$of*af{yt>cdEY*rSH?Hnc} zJ7Q;v*i7v6DcQ`jz0?%6Ea1mhZ^6Y_zv-t1cxh;BM8^Asb?2Wou|K)~)?Q$^1gJ*L zts=ig_5O!6ozQQLNj1gx` z^w;0$44~rNvH98B{>PkaTk%sfV$v;}Sy+CO;w{$($zX?{@@AxIyjv+VZ~8ikeXgJK zax0vFBcEWv0Ab#)D#TU~A2oSt6f zc=RY=tH`YJ-*S7*-^Qt$CmoG!&o-+8^hQA&-NXt532oCQY!Xg$-)#8pz$QWJo} zO+|m`%+XX}n)piKH1i=CKBS)w3{@{`!C{(rMe@y)4>g%!(_{9~w??TGd=mJ+lJh@Z zEG~R6QCCYJDx3a~D`7(LDYv*FX*NGHuLS(nV13?a&tLYAiN5t56g z8fgwy7lj9-vU6-D^q1(EC21`hX-0TbP+?N|J|;N^;2QC2Fd&|;8zl)03_A1hn`q3v zsl}zZyR#5_a=zW-QTwOR%*eYw`_uMaOUss$4?#i9;4N~xB`%%^!tQj*PtMYEGp(TR zA%MXkWWj^jy#7Iq<{$U5yU1W*`2kE*y4)-RnW3oD6$mTcJH0~)*(V_~?I zA|e9WCJK=S+7oxTNMmHCYmFM$Wln2EnHQ2WzS9}Fwv31xi{7;Z5@TQ~l0E?>UkZKs zV&jx1ZF_LbDD|Jo0marGV<*>(lCqzRe@xz`9Rn9DY6n7t*#`Z25@xMhqhEJ{1ekM& z!Jczx&!))U|0`4d_WYG%Cv|Q=4tC4){>1OrF#zM&W2Wq22Q2z-DM(q`Y)%>a(g1=;Tpu$%33!|Yy}-R5-(K## zMj(HxU~+Y9%L#64nB;dGxalbd#>g6D%XB0AM==2W@u~!pt$`GDcHz&Z7Jzi8WJoz( zxBKsz`wyRvjzo37G>DLjj%UP8nlI($Mi_mi z7%RzYT~6DVL!HaQOym-~`(Wl`*=;?dAtW);^1^skBp^Pfn+fiaQT7D1X0{_vme7<5 zpOm{CCzA!BFz)0p_Z5>)uX9;3(BIW~(1X=@{)i1|O|2*MYl4tLV8JYbXNIE3d!ARs zbx-R-IV~>3O5TF7l8N#9@2yZt5Xcl%niPXzTN(3_0W;hm0yHYtKP$Le@TT+KVBFr^ zRve)qoFL}KuEx6FG@>69_fyES{{WK^sjzqbOy;80u#I=R&huM)ZSxli+ zlDswAfznO8(f}EWdZ7mEsVG8(&us5ezw zUo3A2`v(fol>Xa#^etROP%tuaX2ygjC8cjBFDt7oBB0>pOc4tsz5-KZ9>CUM`T280q#^K=Pqz zq&_`4dAyyN5kkUIE#(UWaPQ-qX3U>n$Q`)v3fN0)LRY*2{jE&l3KT+Zouk<7&QOp$NoFuO5#&xT(fx0EcN z`xQ7Fn{7r0GWNd4C9n{;FLWwN#6ctvu#$0@`UZr_Fu8xAP#9*!KRsWN`+;=h@s|Z` zG~C-woFM}-J659>Q(mAda?EFM+=^T5n7!Q*Ue>~a11*jz!t z;YncO`KPk1?bhGF-_Zy-O!x(bnDgR^5D(Z{$o0Hrv3vc>x1V z6~{*l-*^m!Y?9@}X*d-KXIWlKH<9h;ROPsZdG2X$T71rN%Px{0HszDpg*SJ$EwsL5 z>wSNFZnU>oTmlX+&;%Q-&r>C%wx zVEs0tBj4(>UN{TW&QA744e0aTb@N+aUfkb;%TJl5-W099HDzt{x7=PQYJb0-KZf(R zoagp@^R_b63-j(%U(FIDEy-Vax?AIdJ1YJ7)l3Q zyU7AQmK)TQ+5KXK4x5Ku+(L03FL+3n=dos=MF4dv{kwRrUJN!-Tj(VS%sZWlkqEs$_>3%e&w9%%n$VeBCcxj1EI@~CnaaRT&f*vWypPDHlvj4HV=R;nq zk+MV=_rmWAx2Tb;8Lqo}=x4{O|9Om8<6~hWaJ^GU2B;c>zw<6edaYZjis`qXFXpGL ze~%k``^6V?VOj@cp>=Ef_O1H6iRkpHLIBQzYofxVimf%mwLxZffiNh zi_576FlCi3xB?ST1b`PN*Yi zGyejn7ok#sAR1IqNhyG;AW~YO4>usE^){+z;&QRo9zIO%% zOv@B^D$7dFNBi2Bnf=D)EFD!R5O&$p>CL0fD9Da|f@l?Q(te+Za?z@rNux!`&F3rDtu+NOI0|42ftnjEK8#N* zXIRQc22YQ$#3wRQToqeXwy+@-_T$O50wNveh_;(tNy>S zwRh3Hwn0*%D-3_~N^Q0Iky%q`65iK0A;(Ze>}j_NGlPl$Gv~g)-mZndN58m61NN6z z9&vejF}}bAZypL()5}@r4#zTmsiEn>UY9eBa_L<8KTDdLfEja&FZ$fmj z2N*!i+uo1d7|M)738x5}&-Q@0A#LjbdNy#MxH5xv1i|(&|I#XA3Ep@tVU&bGs8kgA zY-MO4j0vl>@X!x@YUP2@vGOx8L_*A=CU>OQrfEC9uC5yY1b^j@04zg%SuF9M=MBs1 zpe4y4(RccPv8R>k_qoZ&Ea}p)gs)^P-KTZ0Gm_~TAtT{wYYf!g`_zO-IC)B7Y54|k zPg8+d-i{}y3x%e@Yq?GON?m`S#w)lhjKvKEyRxCSg~w-LYi>jT*H=+gPyl+ zwMR{CC7v#}M|B=(b9z7^_qd#USm>ouB=+YZ^*r)rGs1WS5{BUPtdmh#?3Rq(cC#i= zD=y>U-Zdt}%FPj2nr&PmXO>g?{_FnrvdV<-)swSt%ue0UF)Uv&_aycKA+6__FH1~Hrsr*_r1@xI|aW@j4b~QJ=9whdS<0Loa#VC zuHJTjmIB&jW-W54qvmOR!#THO$3{orJ2^QibbtRo+uX})iC<-8W$iv>W-4r`cA0s5 zSm`|R?^|E#I@jmx2c9azrL}a)nG=tCLCKM-PN5UiLJ7v;k06W`=cEQO(Q8Zw_gdha zl)dY3!p*&S=7e8SMC}4K^)I&wzEDRdU`>w2K`dR|#PtDV%dO8l z-cLb%oT=YduU7^+8xy!GgZjC=Tb7!?H-<}Nzf)OJ@?xgPbCz7SHO~e#zl|Z zDDHd-p~-d2-Vx5VCG`ylo+uTf$@4owoL*D)9=VW){dBEOrg{!Mc?SZO}|IBdZ+nnFE z1tKG9qc%-a@grqeV!~Hb-%06E6y!7*7h|2U^b(6!qF07*1a6U6$1JIdu$4&BiAmst z6pkUvuX&VcC7t2fd8|)x5WyA?aU{3<9xTChSo}I>sm3xu?Urh&X1{`NJzkc4I#{N_uOgr0eW?!T#&eWKmIB)%;xRCKX9$`K?965dF^>py8 zE$pkIG;^ZfN`JbZ9ALv<0XA%@e)qdj@gw5<1MwfQeEP^4N-A>PzIPOGS@Vjjw7@Mr zBpxwT7(%LXiOF#A)+|3SLv26=@^`>tB z?-8~M7yRDkS0D8#D}_xzwdlaG2TayITlVml*yzLh<%B47TgtC&C5sYiWsLJ%`kLPf zLtoTW3h>AEg>uMD>R3$L7ksYc{~ex)^niDX};G;^%I6Z8P7z=?ahL z&q@E;+ThlYxbySe3a;~0t3mYbndikOOB~N)`B+2n(t@-nq$F=!WK+f_WK(DvqNnvj zK$pQZ4@-4x-{AJ;64+t^eG!|ZR|_FjTb52Jfuv8^wL(bMwx2=iE}y#(Iue%VZ9!Mm zFxp5f(IIYV^aw5pPk=AGVEJda6U5bubnxR z7a-Bn9CHc6?BqcnlDTi(ctiSzJT{FrTRz^u{?DENO~A(lL({2=vh{<6SSqTxKz*~5 zZ)R`D%B5~n_f9!Dtr57cx27&_=g3f_!)9e8?|k_-Cu^U5?FGX4(^F#qDr>U^$XffP zlI9NuEeVz5e_)MODQZr+uf7_~a?A2N(79^#_7;TH)%)YDGU;QPmY6D>XN(UTf7Lz< zG|@R**ZT41;$A?ufFpu;$bM%3U<9f0UG$14*9RBO0e3B@jpPS)EZ+hWV z4Eolgm@)_sHOjyk@_f^Jw(Y-Rfpej|Zcop@ z4L1M2w-;`6Xxl3$D+#gy3P2-J=fLRb!At3X6`lG^jT|xYDX1RsWf-wq81Qg6LCA4E zBkMCaGVRN-+TyPU#)M8Yg2!t$zj2P^VW{`l06HUHQpEo-1^1rp5*%=o38{(&IHl|5 zrvz$r7-Jz7vBINtt{lNMB@3#~bIPHl|7h4G1me#pX~lf^S4D}4LdXAktl0Sa*8IWZ zaJP+gbw>7@~-lm{#Mn8)t<JljaKcp7TZ4@64Ne&-6}m9sHR(`xXICY%MKy7n~vN>2Sx?NpyAf z^S}FR!_w>P>&wH9%<3_4Q31N=kM)V2nV}BgwShzzJqC{xk1zlU8h9Wb@B_ooMZp40 zB_r!*wOHC>rc_mWzb$1hU0nS##=e5ivFc&s`8+g(l0uxAIRz3Z8Sj*i09kLE=LNcr1~eWwkI(^;V+-@laY}I0|Hb5 zro4c?Ev1)5h)$Y-N%h%nbXxL1D{X&rYunR{bk^lYk1^t%Gi7E`s_JPSh)AY^&>TD| zEMQfji$LrAXN@PKa&1MU!xJSfp1(mCYy$tu)5glOa@``L9Y>|1iVrynVZ0K;Bk%Lp zco)jT5Mr?Zxl{jl_dCb+FG_MoF=?F z4DhL2Z!IjWJ2Vh|bu4t1w;mOI1cnz-Hv_{jq`A}o>XAF3prgN9ZNYI<#j-F;T$6r9 zDInppg3YZ7$ka5TqGHU>%=Xz$PQWkEq^OPvLT$6c=$Mp^)O2m^cmbh#`7Q5j0VR%s zs6Zv?2C+A|s3Yg?kSly?Y=p`Kok{sGe3P&l_`$?@<0W@xRvrnlb+FwL%BrqNBr->< zsr`k@DnMWA2*=hE$R3v=dJ(+)>;ymo7g0U50j_8s}IW~dxbuDsGJ>mwPu zKycZbZC+W^tNB!ynl|$J_@L0sYwp6>=l8H;yK}9e_L>P0hLqG3do6)DQ&Bzgivtls>@DwGermfw4(HgPy7I{KU z%u27AbXCdTvqK7Uipb9U>gsc8>3W`iGwU&2E>op@-BBjBfzu|{MfEwpietirAfQtm zrBjhlrz_SA_XLr|tcuEIp}hxUav#lCq^>auPL-0p-tC2_N`(}#Ne79rjfVDajFI;b zZ0lQ-;80OKOkGwbnC9Il*Tp5e8TEOYta;T!C2S)2=C42Axn`3!_QPqR zCx?M<=hG94CiU)X`IAD44b@&h*J(7VytJlLQsw@XOL$jFNge=^x;9w$E%=lUW>A~G2B_9gMx7Vab(Hfz?{$s6tkWF3wlwRU0@6af6#?jHny|JKr zr8(`bi=0WXS5-hxU|K`1`@*T-idOn*cVyPfbKR1%&9g5(BggUcP2!bOQhv*+DZj-e zB&NUQ=Nlm#)qdHjJ2EI|be6RMq=vk~)u+@Vfb`)T7V)fxEj~zxjm|P+-tEI&qO*S+L`9Oh z8t?0d+S+{jTSzEU3WSC4l$KSV36|ZwL+n2LT)f|vs6!^3UrJ8<^R4#kOUufn#Z$L5c8s6hK!7h9>Z>j zd_$4W@VWbblfUYMoX5~xWu!&iw!hFXP6*g95YEN4pEb0YX$OsSPWlHH}#lKN5f_2OUp z=!^aC;Y^y^+P&FarR6DM7WEeigil?yxtAZfiV}k61F++D1mkP(iY^j1cbfc=g>goY zFM>#`=<@3&0I4c3HV<=UZJt6gfOdv_NCKafPwQphKZa zJS5kjO@_NfJ6=VH@i`vv#tLV!Ird+F7?0I2OB|>@4|iGKliuKu=28s!K9VP{DJ;xB z{Vwgc7y^-%FFsVZK3Xudzf+j@LveG;quBA{y{fs^-+XiLU#8mR{C3~-8H3HU9rQ>( zg>AvXE#u5&Q4Ensg-YSJx8SD?F@%@D=HF|Ic=gEqb{qT~%A87EHPjMR_O?pmM0F&R zxf46!YhBzzl6d>eVrzmma#~!mI0%a4y(1P@ZNb3->vSqahaHva^EQzYpZMj2gisqO zb&RK<-`8h8&%d58F0F2dfBe=W4WcHgHIqaO-NuV8wk?=XU%VRP6N|@WE7~m?ghB-` zF+7kR2MUBDP{&u*!ctIev%fUKl_h`huyT`(E_$C4*o6?;e~8+ai?cz(4!bfJHlAM-H?yAANsVL;||At`|OEDI2#3fv1j5RLhP4As_-Xx0Q|pnqSwt3yq1_wb(i z|f0@@@YVESo^!O(sRzpE=689ynVjG8#^*HoI|c^v7tbtJjE#K8UrpV>acHe?tU0sE zTDIL4`L(HWmB~w=m$SRn!zB2BwE#*M&~g(X5k6vwNq_7r43gcYMXp^Ur>l;lZxHi2)+uNwza)!M#O&hX=^1p@~gg;AW3U^Goz?zVaWGe!287E z*kz0~iwIABe5ZxGXZ-5tY+RSV3MSrqI^%}u`)*R4lZ5p?;{xyW6ql2VJ)ZM~#zZLvWd!uH!qz=hobF(zs0@UuB5c_k3W{lbFLeY@0 zm?d!-T*r1|LiWb)9tC+^qOX=*#ZhcIQ}`|QZIRdsqZ0B5{8U2MoawPyTYYUw_wG= zmk4a<-Qvnm@dSu#;UrNu&_}{8(zxspC!h@d7E~T40KPf9GzW#j9_SzKpGygjIzR?H~8sNM%o?IDp5v4BpIoY z**)W*PHINXWJ0v)Ad<0|U_XD0FjC@=1a3MK(*!50IJt8H~- zBeyS2Ub?@v)yh#{e|)y>=*TLZ?cZ)GeAZ^n|E^3D!FT3=iz-?VSk^4O5%XDw(3r@ik*Z zBmTJ`^Kj>WX(F0lTCT>myGIpexs7tqj^=a84n|Kab3UJjN}_~}=rgl!{`2wUEGa9Q zaBeVfE@TzAablOWL^gx-qO?3)GAh0p5`{ea)Cz|h`nxhUmq6~I5Mh`4CO6Sc`&s}b z#4p=zkMZq$sp|k)&YZx<3MGz*x?^tF7%~E14j_|h;mYM0DaJPn8G*izZcME-{06b$UK)jdUsJpYJBh;#!xX6ni9p3=9lUC)y`>OVVQg z()$^q_OMO5R8w+`LFKW+56ff64;?rbS|#FSUJrb*1B=D%HwBC1x~?)~!j*~KC10{P971;$ysGY0 z{Vh{``(nF$nDLMIvz0on;An4w_+2Ivx>;=!gar*$)ZL6+t!~wa+q|t>Qx-j(Z;>5N zC?)b)Ka2nYVY=mLDV|OthbFSMI}(Hi#72-I+NnM#2e)DS_DPu{w|Oj}q|qZfO+oQp zp8Mpw8UB-GQnft_Sw&3%sKvo%k23G}=_YrMEs=UM1 zJ@b~))_?+$EBo4$BmT16_{Mdgu=Fn(W%Jr(qAQk9F_GvmYvThYw|iQkWDxXMQ4pUX zt*6?N7E2a}=zz+&-@Ow1ZsW>R1@`I@g3_XA55uKvKQSg=?5WOnDV|-kx4n1I{PBrpU5raWRs`EfxQMD2Tbu?^n&qd6H7sxo+}*Juq;nb-BLIo@SF00Xm!feJLwmcL5N z5EP!DM1iV8Cft%u4!jq>!;KJp+YA?^#kEC<_rl3#n8Prg zYHHEng9BkLElpV+WJH3%hoRc+Xx;~&;# zetdXR+#Gtw7t59?_2m0&O>x^E83#Gn`w8LyLgETUL@q2Gt@c9GV!Y(#^{hhG7~{8n zh@Ck>_R<^>Q9!uZFO6U{dbXsO&XhnbI;jhGlNL4)q(vY1C7rL5cH;KuF0Km+t_SL5s?J^A@sgjn zwt8B~e)rDuP!viNX@RDj{U`j%R0& z)8L+m-llxDig*x?NV}fw`+;6V{=-#t^jDz#e~I9OuqVefST07 zS+@4j;DqMH9I<)zHUF43LHmom!AagfLsa^vOU(Tf*uhK=Irt*ss znbe-R1SOLJ$>`dgpiZ9uYR;Mp}0EA~+Xw3rbmBO^K+->8uz^5Rx_xo($v6WDFc&|;)-u=E5 z*)pef-O$q#tB!U%M@KSubK*Nm$F8cE|KtHOSbvrQagO52{!uORhb94JW=W4sjda%} zv!5**#OJ^vJgv}D`0yL?0ne*J-6UI9-#J@d07>^oz5@Asl4Lg zb_p=?D6}XtbnIX+fMz6Q!HJ64!Pl355vV!vzs+s+mVPaiToWB=RN&z`pHKObe{)NC zX4X*W(aw3Z-eI8oaa+mFB9rP}=d&#%MIeX?nAA5L8w86XDmS+xk3fa)b44|@TtYHv zx!reQTDv1V&vXLWa)mBnXxZkE6x}s}YCaah57~`)LKWW(S_K)p>W`FjoI`Ap+zH}Q z)0UZbjCr;t7$Gtg3vld3mTx=5dwb%FA1t}tvywhLtX&O!d+zh&_Qyb~z&f|zn~kyT zK7acG0X;X@;I&sI^~K2Jra0oSgB@mj5Or|yh+?^a?vCT=7ph}l-*5Yl4}S|4)wNiG z4=gMUJG7Jd={6Sl^MS2fo&kRk;QZ}Pd2W_BDSm4y)yF8CC%Lg52 z5+;R*%tVLu6yQ(VBSsWL7V*JJ1wc3RoT&h@o`QCh2+_*14^t~2pBp}CeM#e;Tl655 zU-Mm(jm(qpa>q4)i`S;By(oHm?vz)SpMLKf%?Z8FoaRCu|4C!)lT_Sz5bD|YWhhhZ z_^kqj-Cn09nmSfS?KmHDzrGJ4MjQ&x2gVULtg`oVFhzeYFQGzc)M5aYZzy0s79tTXq0{u) zfH7Ja*#rqi!6tefbwNUPbdQ6%m5MDNbDc>T8a})+!Qu{F#@7=CF>V5xO>i%Uq_CX# zXAk1Kk--{DVy4^=Q&j;v2}a^VP$$bn7rGV;{NRg1diR*_M^af1e!3pveNT83b;Wn# zi4+*qpCOr01ql1#oz`Cxi8`43mOLSN7b#2qrPV7-*k#t|`eg#!ojgCHZH7?MhDz?* zl2+tA3wUxuA7V1z1ZsgSE!*AT`{qGUt}=7RiUNO(r<|IxjoYmp@dO-sqJgIkm$oxU za>uvV>SRe;9npP+auhUhx3E}74o5jDPbZoZfI9~F!GYL{^J&gcpdx(06x&n{a|P9e z$)7XmUwkGCd*?!yVLPeZHt~$stB|8D&6O;&s3No+$H9Gam%p=d7FAGpJe|D z`y&)>5&kkx-$!jlBh{Hh&S%@)rw=E?G=h+*||d%Ks>>{R9R zj+NT)pBsukk55(9I&h7fR50u6{U&(CSj)HtXO!n*%F_+P?}{ zoWQ^FWMg$zm_y2mrrS%7mPh_8&IK~3k2kIa)d-&#bhxoauV*FNw;?B`R{8a zi+5O8<8a+6m#UhjrR<+1|NbAg-a4%5_-+4Iav(K89AkiN2&f21mo#ijs8}E!(%p!3 zjT#ID1*8!~x<#a0X(XgeLQqgf*Yn=@e(vY~-M{1c3-s{NL$+O?D_-Y$);$w1GMai& z>9e1M3a9sXI$6WdrO7#;46OFQJNxxDbiA>_`S-8bnB7{L=gBOLJa(7vJ_<%kA|EQn z>tWb>XjLDm1fgjX2@={@vBtKTQVQr3M79c&pU(Pmu=48!5kAHRJr#JekS5Z}Axv|? zK-%*;ygucZWA)tUr^h4j$NPPK&ImpJ?_@m)KnG3dSK+^vPG4(3bAmm4JEj^EmQF`a zkY8C?X!LIhI-H^d&C=#|R*r}dsp z)P`7K@xubyJz@?zdsRn}~l{coLL1t$O4c$&mwr1MlOsZH! z5*q^W=;yD)bR$SGt{8mw&T zs)2ToLvbEZ>OgWLfkV7lwUNb-LFJ_{^2#U-_INP7`p6!V?m)Re8m)Vl{eEBwN=f271tX4z8-_nB(IoK5oQU0q0rN}5CEJbW=J~QGy1L$7_nsEp z&U~>x-tv6?bRhQ0Wg-{z=XYLPRb&|u>Cpj!o_TpUj{1QiNQw~VVTjsPDDP(R zBUY@hCiq($Pf}IIU%w96EsSiDrXyxUT2%t?jR^}pb6jFdZaZ?CMpK(cLb=k7DqJ;4 z;jz_K*Ci?B#IqmZ5RLWRvVf9B3t6>yjWl$P%v8^G{8-oXk=c-cD=2E4Y;Wq~bW~-% z!K;OMbMHJwAWNQCc3#u#)}9G7o!HsbAcQI!A{G^w3BF>iiK~17J{tp-i^{#wPk9pm zjL2Z#hE~hoEzsMtNP@$^Kr41=Fp=jyS!FJ8H^;Noxq~-9Dg-^a0zGhIm$RZrh+=Mf z4Q48GDTmY12EVoc%6ytD#l!Or#HUBSOG_IM4-0wS39{z6+dJ3?zCQP8$jx0F>Sur7 zB;_(cI#4=l=si8HGdDE$ePVQS;=&Y~4~sVbQzhCGP_x1_IQbp&9L~1(UBrazZW>8E zpJIf|Y~BJ38*}1^SjgIgH+oi&&l_lS=dY5*kc3Zb=cUtFI_;UIsHHpk!=vDTkR`U7u13$+KjvYR`fIW%d0*t02|w zr}#5bA~$xj8kYJLyhhAXk~X`YNIqDL(Z=Aoc-1v^I~;{?F{ADG;1;9U?|oT!8#;e~ zv+Y+1hH5rf=FxI%6Vn|+QVK8uDh5jsc-9J(uuJC1!PS?w0M>^qHljdaQ_XtfOY5I8 zH8@@jl9DFosg^ol^oxqm$~gAF>#TEKl@k>aJ1vf>9;M%_5jOxFAk#dgz&oS^mirhz5Xz5CEuYFJh~?jo-(<3vx&&mLxZ-AW$$248!{qx%G-V;@7C zO-B*aHoffNQ(V2Y&XvBm;FME3rC*ltJyIJ=o%1yy7#=4C(Z76IF-y)5 z^G+Z~4Pn|Kq-uZmDNyMM7H4~9;&c{dinpMi5Css8=2xkd&=_VN1<*IWQmXIXq!0(A zT(B~fBILAl(C+Beuj6p^oCQH9)!*Xz$VnWDNJ0o&SM?_Q*NCuF0 zD4N2i0|ED8F@a}?1irQ`JpPpcs;>Pf@*>X3N`-Aj_KhnER~CNc*HAZvXXc|6ZW)%8 zGip5=jb4aNpTM^fVa^qjq87|K@^JTCG=3RZG zd~oz+pa}aFX$SOFpbYzf+3)65+`wOtdCzS2gApo~c??+y%JR0POC9O*Xw5#Tn65r8 zb%N*Vb*sCctx>TYwS?#!3UKs%n^1wzmeTG_F5LJe?X6PvOfF@;Z(V`FZrU4d$9VOQ ztJ;ngh4n20Zf5Lf{uv+gCG7JG3JSnZF9H`mFo5|s`AA2{#b+tZo-@h5^gz9%|5w+O z%RcFwC6nU~^)7->Xv;BD@3IQ=fMD+4#0{l<{UQE}OPe=ldc=VzyLlmFl-*nvmEDph z@TaTzOjabQ`~Gc~u5wCN&o_#jE|UE5@7s|;Ok%8iPqrHq`P0HGDQI~D5(i_aSOnS= zCzdD`0V-n$$u_nDb&@ZNWf@l>%a&HMTrBIO0@7ee4+`%P`9eGveE8XY5B^V;n~rzrT1F@8!GZYn^SZwUGM% z(5pPAe{%UJXOhFzzs||{?6IQWXf_jdrT&+n>p9CRD?aL2S=5A{!Ag>aUwplHWR@af z-W{iLWJZ&PNlE&(89lfh{g@j$#y9+iDy0$JHvw<3dVz|lMajJm&+>p0+;MKVZvK4v z^p{w%G()ii4iHSwM{M;pGYJY+E7n1f_n_<3OewJCfVQCq3cbPeN*O3oT2b10wr|I8 zLeTTo{JsXM6nbbHTH0@xsc+xa3K<>9=-F=g%Scanj*K_o+4HEmkWmPBx~~I5Y&ps| z09SK=49Mn~V$le2fi`pyXzpseeBfOt;zedy*=D<6cGZZ1b&)cA&Gsc0y@(8Y%hQs( zcJ1ml9-7>auwWW2n(<8qu%Jk>XTKZ!NCW&tv`7kFv=#0;+^DX(+F+?^_$wj)-RpDa z7uWpp87$`}HX0K#?-d(;PN&nJPNt-)#<1U+v|by`vUt91d!MjWV!ea?%&Kx*i08ZL z_bZa(55_aJO9YL^*5UunAO7e5vf8w0<$|Be@Bm?BZKhi=W}gqCey*h=uK3i5C+0Q} zP8L4<*6lW(`~KC7ZMt>%AwPfKH}b1rI7xxy!>jAG)a^Krhphk20yJm^T1n%<9i~+x zdin`Ds?4N)7w3Wt%gAkM0nPdU+a8ipqAKy%;mlFJ++eR|kee=x&S8oEj?w|EL?s=S z4<;%_PjUr~bMMQyUIONho_g$c)qmmh=Lu%Cqe-J?+yo=d$^{ptA-%KjbRy#a!JGEX-*_b=4vfIkp zCmF6u(^=z47tu#k&NhcjEDVMBnm(CT+Uk8>aVS72(a7?&j+oKk*a$W#_cXib8=l8= z?&H2Yqx68R0Z-?DE3faJR98=@#lx2xpcG91yg3URjExM>-yhDh@{UIP8^kF2-llx? z4X|ce0OT&8oE_> zf5U-@OB}Z0n?3}mF{pihHI58z99`AzDwaD2C0yhfD^1XItE@n?ez7vd(z5EJ?Y7-e)O%FFKO}bCZ2xD zp8sv|e#+l}VLI7gTKG-{kCJz7NWrJRL1@SNqoKit$8h5ku;tQQw=4O55>#F`B*w{6n9AIT)Rb z!1EhCL4FV|WuFkb@&nE!Tk2Rj!XRSU56XbfOmAUR_2Og1{nQnU^0MA_U~wJ+JbI8B zUiEnc$Poi=?+|uDl?!rO)QrtLI8<$c78IkDyvY20n=ShW$1jzS(|}g- zgm{n=QUZGz*`R?@0woTc1tzNI<^kF_CT(HlaG1tdi8P7*urY6@TE zwPu{lRhUMRMay2jKkU_VH$|NKlQWS8p$QsyN_IF_WX{0+N-2#%`H0ay{bTKP~$=^c1O9r({E}AZjoC zD#H3%GrlAIM(cP)nPP&63auWTL+{sfYhJBRsJANBANjK1p(wQEt6h* zn$vP4hD5b*)OR!kI&IBN$ThDZqOfo^dwza!0wna_@|FsHGhXuJ#ACh2z`t~&?)dMY zufD(Q3-@*bV3T9>al!)+1jZlw2Ol>B9uQQ43GJ{rmL{SvU71cuau$`zjlzX` zdRBhM#jYXQl^&mTGK%gIIG@+~4Q0{bg5+xE7 zbCbZU)eCG}z7r3}ZQ;7x3$I^OrM$TaW)zhVATy4It8Xy#0r(cYRUYvDD=Nq8fW5y? z$T?i;^KC)*`;GDvtM6ws$H_uzRE|h7A&vQKf{2Iv0xrzBO#0JpPi$1*Xl5}WZ={)V z+aecERxi&=2l=Hnr-JPVkc@|fptpa(5yZQuTzfeE9*Tk%&MXEFjgm7%Igj#LVaozS zVg|Oh_6_#sIc9ZDr32|7y1P-{Qc_nWiuCl1o_{_yeBpgK+qlz~ z_I_&aN4f7*s-A@Xr>2je%D8Tc9Y$u9ZaGuR)TbniSUThzxsF96q+B}6Oxj!wy?2lTqDsM_=4RhH4} zmAm%+BUPFTGq$+T<5x9@pQ#LI*cxB26icbXVeC1)4FTLjj=^Bb{In)oskD#WubNN3 zzOL$O(KlV&+)D^c3_X+~i2c2?S5q@LIrZbr)Y5k3g{AF`S?LAtuLKHiacaQ9xdWP7 z|I-Q-0+8%9Aj0d>90QW}zzdF^49gn@&A@LVW$*$68w=SYDo7D|dU~N9RZwnn^^P3E zoJTFF$h^iKSP9$|202d5WzrI~i%=q^lfFxye1Ck!@>SCf-k+Sh5$c?w2QLvG<+X;l zzfA~rHybaS9@$n%?U&zHlPghsz6iA0K>6S2nPR%qScQ|dl>{=j|J5_1+jfeGVG0o6w z5c4DiQTua~*ZL&xE}SB|O1n7}Clam-6B-7W7zLqWm5a6lPc>VABlBqOy!eG{FDB;{ zSb{^0#hobCXe#1y+MQWwS(vuxvGk*|OmLua3i|M2?zZ!@UgdtfF zk&sOMLoeXG(7l0RR#a9%O;+odIMqdmgs2IadXa&0yPatT9*IaR^8+fmnXibh?CWqm zR`uo2vUbET3KX{*IS#Rjw8+J%u`~2r37FBv{e^H0yTy>;zI@Gp1jVZHnEr?ACfrsK zCa0oC=TS97#o6yk2APYeNx7}KB=6s#02KFpMI26wJ&N>e0o;#xUy=Gwhgjp!Uh=}) zvMUsuP)acYI#zj(TOH5{~_K6|5x+`Y$5 z)E{r|2YjS{QHkj&xgnhhh(>wU;NC$YV{pM{?gK5U-C|ky(QYsC_j7_&BZ0zff?r#^ z+YFDOi!w*)aI-I)H(yUN2m4B_#*j+j>raq4W+n>8oTPvQ4DeSE0Wg2mSd-tfvA5Y=c@T!Y1=jz!Vs=Bm zLzRwJC#o2S!%jRW4@4yZS#JO9^S9i(4UV*W!a(BLz9%5U{hs?DuL#yUf}`nJjBV`t zx%_#Z^Y5><<2Hh)8^3@Ttx;(MAvUTs?fBa|Q|-^qT4#Idcn}&*KvlpY-qFcdW$W`cpvvc)1ZsnxzP(ciyGk9r`tcP$EH^-a<+; zE@WY`a*&j7M4idnz~=U^_us}jr0$Q<>_8f-zW>LGIgjs((|v>P79 zH^NkNdxtO=QKrTZAMv*~?GY$>@7Y&thm*d)%2+OO&6)(`Rwlav0P?u`3x9PbfEXGa z4ZNmOVH{iyhCc=c4>%aqV?i8*XHCT4i>@M-ENr&ljHV_hURj%&?N0QM9Yx$}M~RQ! z0ggDY(~4=YjVWaZp!Pg8t^zeQ_Kt{d&f8+=$2B+wA`*t}W=e`S`jVLY*bCp{dVrL-c?^HqiRhB!ZEftkAY4}GB{fkaeZ@51 z|9zLLA2W2>x4)y`Cb+ueWVwz?C%0xZJwLf4(9x`x!bhFz#K}i z_9+Yulp$@%Bva$uhnJ&!6*G2GrSl4O2KBI!p|HOH^=16+{?QW`t?r|qAhaB~6dlws8G!S*t=arSZt+X}C zSa|T%)2vufVlhJuvHp#wuibg6Q&UV_JiWmwt2!~m$k@x=+|K#w)3fyW#a-}8oZC}e z-}@(c^V|N3K2Z2o=E23IHgjM?B&n)je-I%ab4zme8eQ{eKLl!YbKQ8uRqtH}qq&G` zCHZTImK30~1%L7?%Yuc~(>DQt>;Y0|8kXzEWtiKu{PD%aZ6t`R!%?zE0L9BA(Yv=7 zu5#UY-GPLDwE$o@N7O+OgTEug@@j`&mI$#fwyauSWdTWb>)TH5XS2TLBl3vfn{pN9 zRd(VcVt0VQVswmIS9CUy2H4+uXFP z0ovwgDj}i2)z$AxCnY9(zx4Llt4q8|T~e(3WV*_Gq%(Y~*5PziT>AY~Z9}E4eGcXtdRA^9 zyVm`q-Py@@-Tl|$pDyG3Z>tN3I5RyzYsmOhQgraUfA}}2-Q4HHG|i3iJ2$T1u+ytU zmO88Y{rUZZm9_eAM8qnxwe3C`IT>PacTaA<%Ie|h=8wu+7%RMFWPIdio>BcJqBe1VqYy_is?7@*{4rAg%F zjW@7F`qCGSBO2nO?>@g==}k@q&X`h;78ZLop`nKvc16XF<<;e9$@U!O>*ckTm(xm2 zh~k;IfD4M)xp$8 z?K$!fbkRbzC^Z%bwWsO{#c%9n;#1s;y!{Q^c(;mP5&D`U<=6i70PIKC@zK@an$+Bl zih-ZTpR{0rVw!Abskz$#*yJD+M5%>_fk_oGe7KDUbc>F-c5@I;DlK#x0pdP(@E%Zd zSV4aC=F3qJydf>I^;{Hg(E1@DT&x9Veqj3-8ybkL?MMakS#wz~Uwam45YhRBkGe&e zIu^ph8nGhH1%nTTVaVulYEF_$mhIQLuhi)`|4R{qv(mXHePb2K$G=;2VT@7{6^f#Q3Cb{tfI>0KL9Ux z>GoErQAK6i}Flw);?u|~8#t1V&>kngQ z$-?DV|Dx2QGfeMsK6`e5AB&y%@csMdm(8M}hC}b0Y-6QU|4i2RBAXJoKAtsxc5(F4 z-1Un*gFXfU1t4Z5pJ-jc5w$u0T=i=+okgUYlyc565YCW_D7Mu`0&B!bDSqwmm!gD3 zM7={;Q+k(k;;~Xt^q(ikV)BAe_tRh1Yb!Rfv4XWUw|k>Nx4s= zAq9bC6n^$00T-0wKV>OXUZdfZV0fKa(;jOOzuO$$5&OJo}+rl zHXcp&p7;C039+1;Q+hia!IXxlr$cSwXI_hIczI5FmxF;Wx*VzY6^8h>R;ib3gO-G* zjmjlA9EbG5dW-MzS()SgKF7Vyk|U?BKfhwvGQOdYZRTU2Y>mk08~rXR@iOy#(SdsV zFYN`rc(Kv=(<&ELv!_qT`j*>6)bR@@mdZh-_Yb_h5iVtwKkTg?3>_>io{NLhrAa|S zVY@b-U}|76(+b?wE;kpj9*xt~U=?7k_j^Cs(ms^9okNGsBpzUqWmrZ0wdWt1M7j9@ zhY5;n9}+O<8kEN`2hP} zs^ZKz-f|R}lNGHy?qCDQnk8c6{-eivJq7^mSRH_fA8ov$KXvCz$Nq z3BQi<-r(@?2@^%^#3bmltP8C;z-i)_9W6l4f!12wL*sR&7)ZoQu-U+p#5S)$@7ECT z?&vE)!51KC+|30{djX&t^Vr+}IxQ$+T(l|8ZYvRWJZ(2Y%qpmKgAJgn`=Kk$64?3J#C@>8B90wL@Lqw;xWj4mr{ z%#UQ&n04!d+5}UxQkBr~3TET@T zQATniQCC;TE)8_EJYaF+zCE-5h_D;QtM%@aY46*IQj?RDKiq`F={eKoz7Z=Wn{#Y5N#fUkf$w3ZNO3iES%J$U_5(m*&ys?#I&73>o{$s^0`L!fxoLD<@-Lzy8~*sS%f-oX`<*QjvO+6RGxi9z0YQGeb~X z6Xr2d2`HxV4lRKGl5jXgUG{39o1!H`c#cO1a^I&+6_aN{C*flcIr2>yH zU6S}5WnEduD;I?slY(B{eRx%dRcow0Xqu>x#$TXLx)#*B$^D>8HW~2X_lo<~w+s_9 zhU%o{PrDZ1y)J!fk$=K-^ZJURhR*zD$7lI{t}!uYv;@MJs&Ja}1vv_l>TV-ct|(Cn z&Wi2)a@LAJtNPhv^}j{ZL-RDEEKm|uE?b?iJuGl2S!m#8MY2(QE4G5E=X#eO9kZza zk=ozXIe&u_!|{=zeZDdpTUf+POdtg88&R|sK~Q?S=ucqF2zHHE!ADGB_`{?C zP#qqEI9M_@pudo=F$i&lDV76E-mo5fYK<&21xsO;(QE!W%ig4MgpC9-*|_nCi%ZvfMzJr2W!ubULO;JuVkG78uY=2>~X z9sw#qgIC1dQ9UXCiWk65_6srRiq&ok06;UJ9!e<(11*__)xD?7!HXnxQOipF@#Oz1 zMUq)lrZrfi%z^aqwO1UT`OoJ|U#~zajBP83UfA8;y$x7qugG`_ z?UYf=MKhAJ38Fy9flW+us>c`+XM^PzU^4JQ^UO-B98GW8rb+mlH4r@#9gA#9RNEbD zY~wA5q>+UA2b(4)M)MHhFHX4dfCdNA9Q%)-7{GzBnq%0$F+#OBnc4^65U}am0UrD$ zRgYI~zl+6f$A!?VK+5pk6k1bG5u9?bypZkOWuw+#Utj+Qc9NCg2OfXN_lWQg97UD~ z((lw;&3^H|wzJZwtLdL+XlZFyIoi|X99~~<8(M#p-O}>yJ-HH${=2lo?;U*|p*^PP zE*HSQ43qDyI%c1zZmjgDH4wdQHCEofExIk9Zm4oTO$~yxpI=aJdb<3dr5niI`v$J^ zi5&}Z(Ex|c9XjXyyT@=Ff}~K&ZD!C6z%p|q$yD3(EJIc-F~vk7;Q5`e!MtvfYByL~ zN~K0kpeILjeQS5EEOX z9ky<;VYpQ!(ds0+90O#63!o6AwDr&jFjXC=#tr5DL&P5KwQe!M!M9x zg`{%7?~1@lA(4oTLTp~A^wIk|iwi}5t%LtGk5MjNfy#8)(aJw-(;6#_LD6vNZhjb+ zU0h$G`7&d$w&5>vB<4=-3$2dz_OTw^P(^lGPx&Z^EHem zHn%JIq?fGBR2Qj}oJbSts;zj}0bVUF-(c(R*O@R%&N_GbMgEDH#H~L|y&>;4si0hF z;8s>6DKqgxBSW|&_{_b0#jj7Z>ZQ@#y}Hh8nz!yL4&{#F0~`C<`50~ys+DHk|0p!$ z16%(uHBA7grU2%cL~Wa`K9{XlX|5_~E;GSlEPD`psL{#Q3N{Ml3YO|0E;6 zw()%bBfZb(9h0{1(upuUw_ki1DJpCKI>W12l0dhUE$!xgI`S{;UM|K!6#}z{I>5?T z@~pO{;9wi>2uqI2g1Wl`kwXiwb)M-$3-)4|YC09H?iP(|JLn#2twQLjV&euOS6E}L zE}(%;&U0X4tcLN*6|^#swpzkiI!aa;!EhQAg0jzY1c#-wCnblohyet*)wUtj=oRop z{PdO!{4G!%q8&7MPb3$CMliqm81kP33Ii!RJxE9z%9|1N;n97?&E)*63!9JPjwT<) zxfie(eVHYHYIOeI%;2}g9B-nSc%MO)dGRdayld7s;Fs=cpL9JIJk^BE^^}%BqLJcSAV~f(d$nZ zNwLqrDmCk!zF+Q)V7ugI;kjD7HOJ1Ow2ivsMD)f&?hP7(B+o~b0^-W)1Sd`E1jwCN z2|N1Y7&wtq}RI)@hd*Fp1==Gc8KMoU%W$THm!>nm9zmoIaOv%r}Ng7S`~=k7C&r#>ER z;xu}?x?;EVKMDP4Z6)D&+{es#n>pq!b@baDT>t}t)XzGR`MZv$C1a3aFb%t%92;!< z=&z@~&7(k_uUn}&0B;Fgw|5R8gyzEoOo>=z`N$gQHdRdFL$IC1iwPZc!?|RGh(4%b zP7cHeqjP~}0x1F7XA{tU5eeZ?+x)VGlc8SiO@5@2Zw^4o_n5DcAJ-)G znJ@)mvOJy4+sXpeEvPW%*GMQLa=wXqDnes6kW*NzgWqq zENlr_L!Xq5qepZwuHQ0w7f_Y#;J=0Av)*P3LYex6F>pYGP`Rfn{hz=BHEtf#xWxy8 zR-)7fYxocDtTdSS#Jj1hfBP(EJBFCGvh~SGSQ_*E(jY5OM@5$%M8YbW9jt#;UJAQ84I5F~gCx0?NZObB`dvXU={Z@5dTa^2w3Kkk*FydW-AP;Ltx%`l14!nl(pg2=$tyOi*GjU>ajaw z)OI;b?W$7(|76Lo;#+hi6PrBP9mHGqmN7<_KS+&3lpUV@ZIzMhO8N9H4znSwmB6$qk}|ee ze!wCyTB8lM>JZo=TtPPQL(lknLaH$<*SGZ|1whJVjX`bsB@qjifB2|QvoX&$J?N6x z{2=Gkf%;tZVu89)B_@>o)x^Mf=#uNtyWn#H?5gI!d5=8m5B33)jJ>Bxx4l%T7+W|E z^=|2u8K(hvtc08#GbeNNSBi#)f3rTPBvDGs(0%dSZZ6N@lpj0Y|1(0G(=ZcUd*l6rh6MP;h zCOVYQfda3PL{n^}97vP3-%)y8{#6DkiWQhg51E(cSb5~~2)lkANmx{{QR2toVgad> zb^gwjkzMLC6RQVGSakVYTxN6A(UI5ro78W6tL(LQtYJ%JMf|ta!NeDDV`6fV{v(Ol zT(kxBHfYyXF=kfV5~WRm2_USzud`@SkI71oshriMQtrKYZ#lXMDlN(kPq^-;fUOmc zi1`RUlqW5meiLZ;Gg4*vv*21BHl_$Lzg8RA?-Ouf#=xnk3^zc9p+hcENjIu}3{;`U zLlhYSA*aBA_vvEyTyE)P)y6GDX8&bZq$;pd?W)W)Pm+`xEf; zr}DS$Fq0BA)*N^nc93suWt%$K4KH7Taw$}#F%$rAf(hvgtD$VNi3v;lhL0(Yoqv8b z_N}MH){bwj+ine*Z{eZH1!yUAhmqMmTlbYH+Uu zaG!-Iq?Pi6CP#FnMn|=Q>238&A@1m3lju|ClTUmyyY&Umc+wU>!?dXyX>%8^_Pb^& z7I(4+VA>miaY|=KJRB~uydjyL4gLR0$bYJdnJoNvo{38Tly7c$#r9w!bpOHpAK3+wJy5*=V@y@)5zl?^3FLS+#w$pfqP7>=%g?A)==-fq;r z!QLTp;dT01I}C3q%k|SqTn}ZNfBMNK82|Cr|I^_@&&8vFa@`UZzB18^T1hhY;zqJO zlyuROkK)emKw19aS0ovNz=6BMP%GXNsnX=`~Hf=*uzL~lu zX?%>^=Gw%_50$RKehUG;Fd%M|)_Un=uEdFZH`)UAqyzW4_BS|AN;C-n!p~F2Eaxy2Wl67bLK6~7C{}Qh0%tS+Xf1YQ2+u+;N$xdAz{s8*ZzfwCc z3)t_U;4(1_OEws>g@cPb_hJrGb(P{&95Cmy7Bqc}9Z|*nw{DHw1AD!M)YQ~xPEI=7 zZsz)@{b|>(z~Pf~cf95gGG1!Y&u(sTq?n=yX@cbFA~SQWyo6lTqExj4MY`?_2QFFZ z@Vg-1&8rO|jQuED9zQ1`;mf8ZCeJd%bz7<4_Fs)6ueFX`x>Wq5HKRzHCbWHPqsZ zdV_9+JfB3arpoor2ikMRgfnI8I4t{($ZF2WnN&zj;q4k{(7P{k zsHsldJLs`gPav1eq_Pt?@;gru3M%!0%n>+E0FfEkcDvEgN(Y6V--!^9>Sk9CHZ#AL zV}&_}y^OK$cIRo5@2Tr&w*AaF6~5hoUUPa%#d4b^ zRk9(rFs1>ZBnC^E;MK;u$+;a^SXdMZ?jC2IF*UsJYNZT3Jw1=Rfj6|7ztNmO<-sBD z`1ol0-_GCke`bFnl$dT>ZfV(UTlS$aWo`ysnU{)01qEOt7!uojC?+%@TPWf9C55Lp zB;8u!UfjvnBK*_%&Vb$cFEkHoL>`s8hp_5CGQ0r4X}p2K*SP04l+bLJ2vYi_er#?W zgWLG)^*w&(`jhW;|HON|zbIBX-n1l?@`$6Vy85o3z4dyN*Pm(o9XCP6I61w+4ddbN zn^GAPowIY122AQ=vE6k%Nj~;=e3{~-)uq(v|Fg9bGqdFxgsg|@Z^p<8!?GO3iwq1T z4p+uP-XrSs8@2;EWuKpa&G4Hdj9tl)@CWwG%u_=DlHA%bG!$yBpFDtV0NJ+jGC50> zm#c}|u#+)a2NL8F*}}j!fCJV7S9<{7WCBVEG_Y?jENtyVn$iMN2{|et%w*LLqJgf& zFtlJvM1|)RM1{9*(?x%TrN*_O$ zERqtBJv~1NAAWlCSxHrOZLKE_1OmZDXmn2|>rFg-^u*uO=Ue)ly)BuOIdts1qvPa9 zz9`>24AC?Xfoq_LtC`ioL(zP&qGo52@;zZDRn_8@QCyXjBu5V+?oewXq)2d_awIr* zX&M}m2v6}>(6cLprlV0eS96?yc&Ob3>OiCUNb{N%IFjmuvOr`iq$c@W&G769mcTxw zHB~R-Uo;RjBaDGJ5i4-EbY6onm&_XW+^NRZ)R zT|$QA>j1bU_;^F%g2wqn@)M{9=qqztYtak97jJGnOf)w0HwXlRvKatH6)Z+pAqd5I zEyphRDisba&XE-NmKPb{&#MR=#4(j;R1W&*>t78*;ve(#F>rxLRVsFHjrG+DjWroi zSBIz#!lw9vg$PE|BiB~_wonk~a-@MCJXqC%5_ZUwjh`BlMUtU0#i%e`F%EOspm`o)`Aye?7&V@RZATdQ#)tz&owc8($OSe3B#n4-IF+ZYZzj-GoL z$$u2ka9n@CrRg!W&v!ZWM`k>yZcaG*05A5Uw>zc3liJ4d(Y4W0ec!0FB`dVSp{3@N z4$dn-a}8o+Y1ZlXc8Z)PzeqXsrHJ`OT@mycT9dOqFnW}=VOrOR;+93O9!(7H-7zbP zc_NzqKa&V(BO>@+GoeyX20@Ucki_?>&CM~pf0us1AC4r(c`v&7tS8o{fAn4OY_jt9 zcPsjPnwn6(RY{BLV2C~mAn~5dSJ-1??tW#!8QtE{8iePgiw;2K|XtNhF7zvnykwc7#aKYoCUk^OqF65^&?-0X+CuzN;1&9c(O}<<`2U0<4MO(Bam^ zaMOkKXllXO;Nfsd$$t|Q<|AfCTHg(iesij8HT|{NEcqihUKaFARb#39m6v3I{J|BU ziTf|R1$V{y09pKNp>f)K&@@0ri;Zf%Mdav03aetJUqjo<@2NX8IcteYNy$dqkH>Tk zp7@s(d++a(8if1#ovbAf^uPPXw)LB1xvV=5ye#&Y*U4wP`BFr9b%emj{!y1!z$0QW z5z>VWZ@FEh8`6O^m^Z!>tLhPanl=H0V^yE0Dp1OYf${yx&rG^5q^h^^v%oW0iqb0@ zO}KCv4@i_~?+VD(p^E~}9}l6-sZJJTnvcEK#-UkU8KGnpXF$ofiV~#Xx1FlBueWSF z+6{kNqOU8pg_D^#(FqB=6P*$P1yLX>JrH234~46uh(#l6KAhUBua238YxJlMCKA@k zEHRun!v<%JD?Q`L&Wm2 z>jp1E-Rx*_XfK5zQb~^3xJmTjkQlcDV?=u#1tS6v5+JNz$1q})Y{rCG5@FPuug^qi z6}j#OV&i$B<>GkB7{64YybFP=VnCa~(t;lR&+>&U2i($cd3d#fh#6+vFUa3$7ce(B zW2LgN6CCCknn3PaTQ^$2t0m^)BmR`VJQg>md112POitCoQ?xchcA76|EwOvg_a6`fZ z&@ct1X4KF^R&O`JCAl{ipni71kX&glm3h^|$%{Lx1*Nfrp|ayJL63<(Wx4H@Tb)N% zHL579O}8idG^ixWWAM^&u2gkgBdo;QJ^MKGi~Y;k5=g> zM+q`CL+n@;Te`)3g-9wxMR61@NDuHNgT!FKzRTD=kr`HZj{>D_)&BT_iYn>`{Ptxy zJ_xnbuNjW2xDWw>>v9LE<+b2VtclE>lc;G&7Sxs|#%ci)q;{YniWD!Q07F@HAy+2g zd+eTFK%c-Qh3QBJF10}$9*VhXJ4^nXm*n&DLOi}Bt|<1(()8nB`~Z4m>$pjNmGjk^ z&%p5jxk-4-Pp#C73%{<0K zO8xYpcmm@eX{dMt58J@AISPyrgZbfUXnntez)GAXKv<5E3Ia26$5T`MJ|AjT7lFLc z$Cx+ed-u!Bmmhp^wGaalT*JRNb%D{e2kHHhAexN9(>sIrKK@nrs0~!;{e;2Rcn=Hq zwoF!$TPqwiA;LsxL0b=DH01~ICBi@*y$-P3s3V7!fo@z`pb0m!L4V+V0AXgtZ_sgY za)_KpyC>u4)j108atLKu3nS^nXC$`D{I`m6w2|}<24$Vv*8(0+(y)!cu~8O@3fEQZwIO9zW6q^;80jm z=3bN?FrsGcpc8oSR_&Jae)>PVr+(XAdTI?i`8_GOUS2ZgX>5XW+&%LYs>x{d>|OfR z-D=I<_h~)9eR! zK){@alr1Pr$vmV-UGMJP;sCJX@;Ii_d|r%J4mqaYu_MN$y-}o<(K&8A1`<|zqGZoq zxb(KvkkML5@Iu;<3rzA(3ITQ)A1!h$(8R{TnH4UGOggE=r~Qy_Tj*L&+NDWc=lG+L zuZakvV{RI8WgFn%?W>T6#e9r_qpQZjA>;AIY z#9SIDQyu1!#=?rB>F-o@51Z-qVj}hsowAlLN^dhsWCy7@2Cv*?wf2p!b|ici z6Thn^A0+#72tV9s)Qx%L7S>Cr#JfBynRIH$Q?B1Fqaq0ulN&FCn_n|498~35!@^N> zjaJ@p?}5#%lSvT&P~$dptt0M6<3BfcnpsF{-xeHR)7VV5<0P4cxLPyke7`~`wQM~t zPxpr(MvY-31)H+Ph5qVzr1y84JH_ego|)O5*GJ1G<-2NLLziJTicr2fkO<5Vsqd_U_%iR6f4-ackQr-o@{|)&Po9AucXH zGs~r1#MlRk%S>WGBcCu62Y~KIK$j8N1m{z}f%Az9Epti7zDMr3(Kizdiy^3N)bpY2 zz7Vyp2jk}vBA?q(d$SpRD$D^Ab`ZD*ZWvN0dTIOdhg7Wh^B@ytKpTkg8Hdh6Gj~A1 zthSvWaR3}lqz?NkS30r&Q?z7!4sxTb`EAJkEB+!pr$U;03Y^E#PSja zM)12SgQ*~l%Ay#qjN5lM>NCa?>bfouKNg~o6;X7R{hMa>`xO-#PkaX*=a04L(;rtx z=6kgQ`S2B@3_{~{7Z-8kqbl2}|I-3=^4y{V|Axl(dwc$?Y*2ZB|1`&nr(S3L>jFO8 zuME?JT6r^|Za>MbemSD%S6A2Y=kI3P#;(-{Bv-8p8;Pe{qS@cvJc}@*21%_1oMxy7 zjeNPVk`!|*CR}=#hdnN5V3)u2CwmoNc`@qhmpUB{t}EqvXtp0@ak{wMTt9;x^1QKehh2TArj?$&bWW{ivM4MI8{4-)KAko!YZ|hB=Ij2*`o``H>N|0t{#kag9sfCacar?d?xcVI_8dLu z8^1`ZhwQNeEGQ$-0keGdUvD|_Ps6D;rw8d#vzM0}AyX1RBIRREvV#s}AH#_&UUHWv z6?iyc8oby%Vs=v_U?TL;A?M6z3^od^85KQsEpGMNex_ohLs&m+}Ndmv2^AS1FB}l^fO7I`(H_Xo1`! zl>XsHpO~KXmNU}QPP@>w)led&VKd4KOom`%AOu6pA}1}U0}ZmX!x|IyDhQ$o*Axy4 z2;^yJEj$aN_TvG9*g(H3)0|C%k9@N5h!XV&9zMC+2zoTs5Fdo5V`9aJv0p`UX$dkg z+=o%?#1cmv?lG`R(nW3Pp&y{8ghS+`K{W(ikLEk?RlTsv5i+*uss$7#xoVd$d3ay{ z&A-p_{dR`|)(#SXoV8F9%}`Hxh29oqs#YN1hb9u0vm=(;cfj4>$62roPh-J zeWvEv^G9f!m2`lr`$C772$uY6mx;$c-+R_<5x;*2?riStOGz}wg}Bn-K$C;T89-G& zb|SI?#L><~S5JT%zqdn{xQJjlElIYR>`cb5btZqASJ+1gou1DTlA4o#!WdgraH)${ zHg~l%dH;`QpPoKVa$&RvlM`P-Xff(1J4piNxKllJl}fAfcO|E)jF zZCB$t%jG%QGFI@VeO$=~Tr>}OOt+vXj?satqD?5x%Q!&K3i^|{-Tj@TR~&>Cem@rA zIGgb1n(6eHhfBL_ftvq2g;coq2+JVpW5xT$jjJqkV`7|dw*;5ImVWusA$@P7i?n|7 zPyXZd!LR8xwNJ8Rdrh2owm{f&^i+E9(+I{B(^Xr)OG&1WV|Oi9JLIAkNs9cB3sc-mXFXT#zOxOi)2niM^4nEPM*_V(W3DA$1go zvZYGkvELO(N5?0&wu^6Gz1mBdoD2#5`}gAR+Jx`MiV$YmRg9})7|2a2>y#`q)xHuD!He;jX_OIn&+w2RZTLvNYLyI} zS@u6m@n@Fo98RqP1w#w6=lwpV8~650P`i8P`MICV@-gb-390R0AR}@yIl>aMealg9 z0z!g!ON`KIlj53f15qSqel|9i<9m3$ne`+8PipQSj|2R+gg;7`8d;B(KOCbx?vZ92 z?AM$)d#-Cj;gobD1=!{Q9$c@?#L>*;Zz4RL(O=}mHJ@fUf0Y+>q35hIWvd|iclv)k z74O4}D>KxAT{7<~5Xu|qwZe;$?W9b> zOdMzfEMa4D&XvY}BqZp|EKO;RKY9Ksyo6Isj98YT{dCX?kpTep6{dv|hn zW@YQ4UeL6*UWOWEWA|bEz@DI$aVODW1eHW&0I=`M2Y0Mt?)S zu>XUrvU^eQ9g@r2ZUU6!RufclWL%6#A4ndA05I_Ajh*)Yq6O7995M0j19;h3@e23H z59X2#=F@zB)DI*_Hk?>@I0o*u9iQ;V(5YAKx zKpN{iL&yT0u_pOJyK7g_VG(Y zgHsnIs&_BCp{JXW)Wk26R7Atc$e#}`NfWRe?ERfS%fMn(MzBn%7(@-LmYeSh8+L}% z0JCNm2CXIEWDqRQo6L&4%R!-YnxJ%X%;iSX1Q`uSUFgzUPX}Qib22uFW)AHT+uRKFn&^UEMQ3 zQ5+T5EGtHeb1th4c3AI=@Mu;5FES}>4e81ZQ1+crt%DtC8UCIx?`XF+Rajbn#FpGq4;V(>i=?v1aas+A_- z(j(!86+Mz3{&FgfaxxJ}j$HZ(cUDN-l*l;;KF~h#y1M-;eSjvt|KaKM(M-hC`19xD z8Z#o@Gy%4*uDKt3n-Sk!rfug*K(O@NeL)5k_BC-oZ9Yx7Zx?i}<($6e61b^ev z454TpT%b|I@tOFX2xg#b2OK0K`W1By3^d)bas@+u9nHQP?=K;Q!nphWuR5u5j7R1q zW9C;?s+l5QQIlU4hHJA5KzWRS(xUbL+tyQAqYsc_P!?xI(=J6Xm5jsp+|vYmVNjS% z6&(hx3V6p%rgN9R7*;tMg}=ys_;N7uVjKutKU8X*USHP;K5Z3zIzXQK(Et3=sGBU_ zZ<`Vbd+7Jx6;OyR7!Vp2)q}dP%z@CE4lG4E&ZsZ~nFF~6RqBIAG0gYx$}%Is0b(tM z4>%`Po=4YAHl@uV+w+N@zBrVd@jj2aa_B`UDMF_-zkqSovEcNRs*#(BsX@=VjkJZ5 zD=CG6w-Qo(R0NeOZDT@B$LM%dzMZ{rm!>!M$_PgK$fFx%pbCwAx70JX4^3bvN!xC9 zUN;SVOaI}Q!N{taG>e?gt^XS??6|l3hB)oC16m;d!3~|`Pv0Axq+PS@SZ*%&eE=Ij z8ec#0?frOij9ZL*q$?MQ*WEPFdF2S}(rALnCt#Ul!_+1Igg{`x(QWcCg1net4JtJV z)O6U@cfYU$OTpx~O5)8p2G~Ej45)?#kYf1DxVMGb0WrB4Pk(q2R(lrg24G0g=_h!v zK!z2XGs#1t__@vu3Ha;;R?eLAH-S>dd#+Q#HQd$OkcYJAMo}7sy&h~L(FvvYh15acgUFJH6_$FJ}6Rnr;!|Q)SzQ zDw~3#ee~0V5PG&fi;zBMUvF1+B?*c7VuOMf=Ya_$iWy$JS$QM82MH)g ^&*)P*- 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

xV`0 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 z QCI)PkdeufG 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

vHj_b(WNWz_X7jd-|hwZTu2gWOZR#;014cc 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 Date: Wed, 28 Jan 2026 18:37:51 -0500 Subject: [PATCH 5/7] Logo path fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2dc7ff2..afca02c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- + [![PyPI - Python Version](https://img.shields.io/badge/python-3.8%20|%203.9%20|%203.10-blue)](https://www.python.org/downloads/release/python-380/) [![PyPI - version](https://img.shields.io/badge/pypi-v0.9.8-blue)](https://pypi.org/project/medimage-pkg/) From 431a6156aea4a7eac8f3fa391ffb361beecd2189 Mon Sep 17 00:00:00 2001 From: MahdiAll99 Date: Wed, 28 Jan 2026 18:45:16 -0500 Subject: [PATCH 6/7] changes made to the documentation links --- docs/index.rst | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 36a9549..37a7e1a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,7 +11,7 @@ and optimality analysis, streamlining radiomics approaches. It complies with `in `_ in the context of radiomics. ``MEDiml`` also uses an interactive, easy-to-install application (see image below) that grants users access to all software modules. Find more details `here \ -`_ +`_ .. carousel:: :show_controls: @@ -19,15 +19,15 @@ and optimality analysis, streamlining radiomics approaches. It complies with `in .. image:: /figures/InterfaceMEDimage.JPG :width: 500 - :target: https://medomics-udes.gitbook.io/medomicslab-docs/tutorials/radiomics/learning + :target: https://medomicslab.gitbook.io/mediml-app-docs/learning .. image:: /figures/MEDimage-app-be.png :width: 500 - :target: https://medomics-udes.gitbook.io/medomicslab-docs/tutorials/radiomics/feature-extraction + :target: https://medomicslab.gitbook.io/mediml-app-docs/radiomics/feature-extraction .. image:: /figures/MEDimage-app-dm.png :width: 500 - :target: https://medomics-udes.gitbook.io/medomicslab-docs/tutorials/radiomics/data-processing + :target: https://medomicslab.gitbook.io/mediml-app-docs/radiomics/data-processing .. toctree:: :maxdepth: 2 diff --git a/pyproject.toml b/pyproject.toml index 4cf50b5..33e6293 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "MEDiml is a Python package for processing and extracting features authors = ["MEDomics Consortium "] license = "GPL-3.0" readme = "README.md" -homepage = "https://medimage.app/" +homepage = "https://mediml.app/" repository = "https://github.com/MEDomicsLab/MEDiml/" keywords = ["python", "ibsi", "medical-imaging", "cancer-imaging-research", "radiomics", From d98494a17b7e329aa099ef368ff36f2f2611030d Mon Sep 17 00:00:00 2001 From: MahdiAll99 Date: Thu, 29 Jan 2026 09:26:51 -0500 Subject: [PATCH 7/7] docs bug fix --- .readthedocs.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index ebe1267..e86c5cd 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,5 +1,9 @@ version: 2 +sphinx: + # Path to Sphinx configuration file. + configuration: docs/conf.py + # Set the version of Python build: os: ubuntu-20.04

T9Xf>9z^=+ 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%0Am6vk8--|^ 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 z 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