diff --git a/pytheranostics/data/output.json b/pytheranostics/data/output.json index db61af9..ee54f14 100644 --- a/pytheranostics/data/output.json +++ b/pytheranostics/data/output.json @@ -4,8 +4,8 @@ "ClinicalTrial": "MyClinicalTrial", "Radionuclide": "NA", "PatientID": "NA", - "Gender": "NA", - + "Gender": "NA", + "No_of_completed_cycles": "NA", "Cycle_01": [ { @@ -16,13 +16,14 @@ "InjectionTime": "NA", "InjectedActivity": "NA", "Weight_g": "NA", + "Height_cm": "NA", "Level": "NA", "Method": "NA", "OutputFormat": "NA", "ScaleDoseByDensity": "NA", "ReferenceTimePoint": "NA", "TimePoints_h": "NA", - "rois": {}, + "VOIs": {}, "Organ-level_AD": {} } ], @@ -35,13 +36,14 @@ "InjectionTime": "NA", "InjectedActivity": "NA", "Weight_g": "NA", + "Height_cm": "NA", "Level": "NA", "Method": "NA", "OutputFormat": "NA", "ScaleDoseByDensity": "NA", "ReferenceTimePoint": "NA", "TimePoints_h": "NA", - "rois": {}, + "VOIs": {}, "Organ-level_AD": {} } ], @@ -54,13 +56,14 @@ "InjectionTime": "NA", "InjectedActivity": "NA", "Weight_g": "NA", + "Height_cm": "NA", "Level": "NA", "Method": "NA", "OutputFormat": "NA", "ScaleDoseByDensity": "NA", "ReferenceTimePoint": "NA", "TimePoints_h": "NA", - "rois": {}, + "VOIs": {}, "Organ-level_AD": {} } ], @@ -73,13 +76,14 @@ "InjectionTime": "NA", "InjectedActivity": "NA", "Weight_g": "NA", + "Height_cm": "NA", "Level": "NA", "Method": "NA", "OutputFormat": "NA", "ScaleDoseByDensity": "NA", "ReferenceTimePoint": "NA", "TimePoints_h": "NA", - "rois": {}, + "VOIs": {}, "Organ-level_AD": {} } ], @@ -92,6 +96,7 @@ "InjectionTime": "NA", "InjectedActivity": "NA", "Weight_g": "NA", + "Height_cm": "NA", "ApplyBiokineticsFromPreviousCycle": "NA", "Level": "NA", "Method": "NA", @@ -99,7 +104,7 @@ "ScaleDoseByDensity": "NA", "ReferenceTimePoint": "NA", "TimePoints_h": "NA", - "rois": {}, + "VOIs": {}, "Organ-level_AD": {} } ], @@ -112,6 +117,7 @@ "InjectionTime": "NA", "InjectedActivity": "NA", "Weight_g": "NA", + "Height_cm": "NA", "ApplyBiokineticsFromPreviousCycle": "NA", "Level": "NA", "Method": "NA", @@ -119,7 +125,7 @@ "ScaleDoseByDensity": "NA", "ReferenceTimePoint": "NA", "TimePoints_h": "NA", - "rois": {}, + "VOIs": {}, "Organ-level_AD": {} } ] diff --git a/pytheranostics/data/phantom/human/ICRP_mass_male_source.csv b/pytheranostics/data/phantom/human/ICRP_mass_male_source.csv new file mode 100644 index 0000000..3c71886 --- /dev/null +++ b/pytheranostics/data/phantom/human/ICRP_mass_male_source.csv @@ -0,0 +1,28 @@ +Organ,Mass_g +Adrenals,14 +Brain,1450 +Esophagus,40 +Eyes,15 +Gallbladder Contents,58 +LLI Cont,75 +Small Intestine,350 +Stomach Contents,250 +ULI Cont,150 +Rectum,75 +Heart Contents,510 +Heart Wall,330 +Kidneys,310 +Liver,1800 +Lungs,1200 +Pancreas,140 +Prostate,17 +Salivary Glands,85 +Red Marrow,1170 +Cortical Bone,4400 +Trabecular Bone,1100 +Spleen,150 +Testes,35 +Thymus,25 +Thyroid,20 +Urinary Bladder Contents,211 +Total Body,73000 diff --git a/pytheranostics/data/phantom/human/ICRP_mass_male_target.csv b/pytheranostics/data/phantom/human/ICRP_mass_male_target.csv new file mode 100644 index 0000000..498bc57 --- /dev/null +++ b/pytheranostics/data/phantom/human/ICRP_mass_male_target.csv @@ -0,0 +1,27 @@ +Organ,Mass_g +Adrenals,14 +Brain,1450 +Esophagus,40 +Eyes,15 +Gallbladder Wall,10 +LLI Wall,150 +Small Intestine,650 +Stomach Wall,150 +ULI Wall,150 +Rectum,70 +Heart Wall,330 +Kidneys,310 +Liver,1800 +Lungs,1200 +Pancreas,140 +Prostate,17 +Salivary Glands,85 +Red Marrow,1170 +Cortical Bone,60 +Trabecular Bone,60 +Spleen,150 +Testes,35 +Thymus,25 +Thyroid,20 +Urinary Bladder Wall,50 +Total Body,73000 diff --git a/pytheranostics/dosimetry/base_dosimetry.py b/pytheranostics/dosimetry/base_dosimetry.py index f599f86..8af3cd5 100644 --- a/pytheranostics/dosimetry/base_dosimetry.py +++ b/pytheranostics/dosimetry/base_dosimetry.py @@ -123,9 +123,9 @@ def __init__( # DataFrame storing results self.results = self.initialize() - self.results_lesions = pandas.DataFrame() - self.results_salivaryglands = pandas.DataFrame() - self.df_ad = pandas.DataFrame() + self.results_dosimetry_lesions = pandas.DataFrame() + self.results_dosimetry_salivaryglands = pandas.DataFrame() + self.results_dosimetry_organs = pandas.DataFrame() # Sanity Checks: self.sanity_checks(metric="Volume_CT_mL") @@ -151,12 +151,13 @@ def default_config(self) -> None: "with_uptake": False, "fit_order": 1, "bounds": None, + "washout_ratio": None, } for key, value in defaults.items(): for region, _ in self.results.iterrows(): - if key not in self.config["rois"][region]: - self.config["rois"][region][key] = value + if key not in self.config["VOIs"][region]: + self.config["VOIs"][region][key] = value print( f"For {region}, the parameter '{key}' was not defined by the user, set to {value}." ) @@ -165,7 +166,7 @@ def extract_masks_and_correct_overlaps(self) -> None: """Extract masks and correct overlaps between regions.""" # Inform the user if some masks are unused and therefore excluded. for roi_name in self.nm_data.masks[0]: - if roi_name not in self.config["rois"] and roi_name != "BoneMarrow": + if roi_name not in self.config["VOIs"] and roi_name != "BoneMarrow": print( f"Although mask for {roi_name} is present, we are ignoring it because this region was not included in the" " configuration input file.\n" @@ -176,7 +177,7 @@ def extract_masks_and_correct_overlaps(self) -> None: time_id: extract_masks( time_id=time_id, mask_dataset=self.nm_data.masks, - requested_rois=list(self.config["rois"].keys()), + requested_rois=list(self.config["VOIs"].keys()), ) for time_id in self.nm_data.masks.keys() } @@ -185,13 +186,13 @@ def extract_masks_and_correct_overlaps(self) -> None: time_id: extract_masks( time_id=time_id, mask_dataset=self.ct_data.masks, - requested_rois=list(self.config["rois"].keys()), + requested_rois=list(self.config["VOIs"].keys()), ) for time_id in self.ct_data.masks.keys() } # Check availability of requested rois in existing masks - for roi_name in self.config["rois"]: + for roi_name in self.config["VOIs"]: if roi_name not in self.nm_data.masks[0] and roi_name != "BoneMarrow": raise AssertionError(f"The following mask was NOT found: {roi_name}\n") @@ -231,11 +232,20 @@ def check_mandatory_fields(self) -> None: self.config["ReferenceTimePoint"] = 0 if "Organ" in self.config["Level"]: - if "WholeBody" not in self.config["rois"]: - raise ValueError("Missing 'WholeBody' region parameters.") - - if "RemainderOfBody" not in self.config["rois"]: - raise ValueError("Missing 'RemainderOfBody' region parameters.") + if "WholeBody" not in self.config["VOIs"]: + if "No" in self.config["OrganLevel"]["AdditionalOptions"]["WholeBody"]: + pass + else: + raise ValueError("Missing 'WholeBody' region parameters.") + + if "RemainderOfBody" not in self.config["VOIs"]: + if ( + "No" + in self.config["OrganLevel"]["AdditionalOptions"]["RemainderOfBody"] + ): + pass + else: + raise ValueError("Missing 'RemainderOfBody' region parameters.") return None @@ -244,7 +254,7 @@ def initialize(self) -> pandas.DataFrame: tmp_results: Dict[str, List[float]] = { roi_name: [] for roi_name in self.nm_data.masks[0].keys() - if roi_name in self.config["rois"] + if roi_name in self.config["VOIs"] } cols: List[str] = ["Time_hr", "Volume_CT_mL", "Activity_MBq", "Density_HU"] @@ -294,7 +304,7 @@ def initialize_bone_marrow( ) -> Dict[str, List[float]]: """Initialize activity and times for Bone-Marrow blood-based measurements.""" if ( - "BoneMarrow" in self.config["rois"] + "BoneMarrow" in self.config["VOIs"] and self.clinical_data is not None and "BoneMarrow" not in self.nm_data.masks[0] ): @@ -387,8 +397,6 @@ def sanity_checks(self, metric: str) -> None: def compute_tia(self) -> None: """Compute Time-Integrated Activity over each source-organ.""" - # decay_constant = math.log(2) / (self.radionuclide["half_life"]) # 1/h # TODO: Check how to incorporate into bounds? (flake8) - if self.radionuclide["half_life_units"] != "hours": raise AssertionError( "Radionuclide Half-Life in Database should be in hours." @@ -403,7 +411,6 @@ def compute_tia(self) -> None: } for region, region_data in self.results.iterrows(): - fit_results = self.smart_fit_selection( region_data=region_data, region=region ) @@ -443,7 +450,7 @@ def compute_tia(self) -> None: tmp_tia_data["Lambda_eff"].append( [ fit_params[exp_params[i]] - for i in range(self.config["rois"][region]["fit_order"]) + for i in range(self.config["VOIs"][region]["fit_order"]) ] ) @@ -460,33 +467,19 @@ def compute_tia(self) -> None: def smart_fit_selection( self, region_data: pandas.Series, region: str ) -> lmfit.model.ModelResult: - """Select the best fit based on Akaike Information Criterion. - - If enabled in self.config, iterates through different valid fits orders and select best fit based on Akaike Information Criterion. - If a fit order is specified by the user, then the method will just perform fit following user's selected order and configuration. - - Parameters - ---------- - region_data : pandas.Series - Series containing Time and Activity. - region : str - Region of Interest - - Returns - ------- - lmfit.model.ModelResult - The best fit model based on Akaike Information Criterion. - """ + """Select the best fit based on Akaike Information Criterion.""" # If fit_order is defined by user: - if self.config["rois"][region]["fit_order"] is not None: + if self.config["VOIs"][region]["fit_order"] is not None: + print(region) fit_results, _ = exponential_fit_lmfit( x_data=numpy.array(region_data["Time_hr"]), y_data=numpy.array(region_data["Activity_MBq"]), - fixed_params=self.config["rois"][region]["fixed_parameters"], - num_exponentials=self.config["rois"][region]["fit_order"], - bounds=self.config["rois"][region]["bounds"], - params_init=self.config["rois"][region]["param_init"], - with_uptake=self.config["rois"][region]["with_uptake"], + fixed_params=self.config["VOIs"][region]["fixed_parameters"], + num_exponentials=self.config["VOIs"][region]["fit_order"], + bounds=self.config["VOIs"][region]["bounds"], + params_init=self.config["VOIs"][region]["param_init"], + with_uptake=self.config["VOIs"][region]["with_uptake"], + washout_ratio=self.config["VOIs"][region]["washout_ratio"], ) return fit_results @@ -515,7 +508,7 @@ def smart_fit_selection( y_data=numpy.array(region_data["Activity_MBq"]), fixed_params=None, num_exponentials=order, - bounds=self.config["rois"][region]["bounds"], + bounds=self.config["VOIs"][region]["bounds"], params_init={"A1": activity_init}, with_uptake=with_uptake, ) @@ -529,8 +522,8 @@ def smart_fit_selection( # If only one model fit, that is the winner. if len(aic_results) == 1: - self.config["rois"][region]["with_uptake"] = fit_config[0][0] - self.config["rois"][region]["fit_order"] = fit_config[0][1] + self.config["VOIs"][region]["with_uptake"] = fit_config[0][0] + self.config["VOIs"][region]["fit_order"] = fit_config[0][1] return all_fits[0] # If there are two more models, we check the top two models and compare their AIC. If the difference @@ -544,8 +537,8 @@ def smart_fit_selection( ): best_model_idx = aic_results[1][0] - self.config["rois"][region]["with_uptake"] = fit_config[best_model_idx][0] - self.config["rois"][region]["fit_order"] = fit_config[best_model_idx][1] + self.config["VOIs"][region]["with_uptake"] = fit_config[best_model_idx][0] + self.config["VOIs"][region]["fit_order"] = fit_config[best_model_idx][1] return all_fits[best_model_idx] @@ -636,8 +629,8 @@ def calculate_bed(self, kinetic: str) -> None: RADIOBIOLOGY_DATA_FILE = Path(this_dir, "data", "radiobiology.json") with open(RADIOBIOLOGY_DATA_FILE) as f: self.radiobiology_dic = json.load(f) - bed_df = self.df_ad[ - self.df_ad.index.isin(list(self.radiobiology_dic.keys())) + bed_df = self.results_dosimetry_organs[ + self.results_dosimetry_organs.index.isin(list(self.radiobiology_dic.keys())) ] # only organs that we know the radiobiology parameters organs = numpy.array(bed_df.index.unique()) bed = {} @@ -646,7 +639,7 @@ def calculate_bed(self, kinetic: str) -> None: t_repair = self.radiobiology_dic[organ]["t_repair"] alpha_beta = self.radiobiology_dic[organ]["alpha_beta"] AD = ( - float(self.df_ad.loc[bed_df.index == organ]["AD[Gy/GBq]"].values[0]) + float(bed_df.loc[bed_df.index == organ]["AD_total[Gy/GBq]"].values[0]) * float(self.config["InjectedActivity"]) / 1000 ) # Gy @@ -702,7 +695,9 @@ def calculate_bed(self, kinetic: str) -> None: ) print(f"{organ}", bed[organ]) - self.df_ad["BED[Gy]"] = self.df_ad.index.map(bed) + self.results_dosimetry_organs["BED[Gy]"] = ( + self.results_dosimetry_organs.index.map(bed) + ) def save_images_and_masks_at(self, time_id: int) -> None: """Save CT, NM and masks for a specific time point. @@ -718,7 +713,7 @@ def save_images_and_masks_at(self, time_id: int) -> None: time_id=time_id, out_path=self.db_dir, name="SPECT" ) self.nm_data.save_masks_to_nii_at( - time_id=time_id, out_path=self.db_dir, regions=self.config["rois"] + time_id=time_id, out_path=self.db_dir, regions=self.config["VOIs"] ) return None @@ -774,18 +769,21 @@ def write_json_data( cycle["InjectionTime"] = self.config["InjectionTime"] cycle["InjectedActivity"] = self.config["InjectedActivity"] cycle["Weight_g"] = self.config["PatientWeight_g"] + cycle["Height_cm"] = self.config["PatientHeight_cm"] cycle["Level"] = self.config["Level"] - cycle["Method"] = self.config["Method"] - cycle["OutputFormat"] = self.config["OutputFormat"] - cycle["ScaleDoseByDensity"] = self.config.get( - "ScaleDoseByDensity", cycle.get("ScaleDoseByDensity", "NA") - ) + if cycle["Level"] == "Organ": + cycle["Method"] = self.config["OrganLevel"] + elif cycle["Level"] == "Voxel": + cycle["Method"] = self.config["VoxelLevel"] + cycle["ScaleDoseByDensity"] = self.config.get( + "ScaleDoseByDensity", cycle.get("ScaleDoseByDensity", "NA") + ) cycle["ReferenceTimePoint"] = self.config["ReferenceTimePoint"] cycle["TimePoints_h"] = self.results["Time_hr"][0] - for organ in self.config["rois"].keys(): - if organ not in cycle["rois"]: - cycle["rois"][organ] = { + for organ in self.config["VOIs"].keys(): + if organ not in cycle["VOIs"]: + cycle["VOIs"][organ] = { "volumes_mL": {}, "activity_MBq": {}, "timepoints_h": {}, @@ -817,32 +815,32 @@ def write_json_data( "BED_Gy_uncertainty": {}, } - cycle["rois"][organ]["volumes_mL"]["different_tps"] = self.results.loc[ + cycle["VOIs"][organ]["volumes_mL"]["different_tps"] = self.results.loc[ organ, "Volume_CT_mL" ] - cycle["rois"][organ]["volumes_mL"]["uncertainty"] = "NA" - cycle["rois"][organ]["volumes_mL"]["mean"] = numpy.mean( + cycle["VOIs"][organ]["volumes_mL"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["volumes_mL"]["mean"] = numpy.mean( self.results.loc[organ, "Volume_CT_mL"] ) - cycle["rois"][organ]["volumes_mL"]["mean_uncertainty"] = "NA" - cycle["rois"][organ]["activity_MBq"]["values"] = self.results.loc[ - organ, "Activity_MBq" + cycle["VOIs"][organ]["volumes_mL"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["activity_MBq"]["values"] = [ + float(x) for x in self.results.loc[organ, "Activity_MBq"] ] - cycle["rois"][organ]["activity_MBq"]["uncertainty"] = "NA" - cycle["rois"][organ]["timepoints_h"]["values"] = self.results.loc[ + cycle["VOIs"][organ]["activity_MBq"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["timepoints_h"]["values"] = self.results.loc[ organ, "Time_hr" ] - cycle["rois"][organ]["doserate_MBq_per_h"]["values"] = "NA" - cycle["rois"][organ]["doserate_MBq_per_h"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["doserate_MBq_per_h"]["values"] = "NA" + cycle["VOIs"][organ]["doserate_MBq_per_h"]["uncertainty"] = "NA" try: - cycle["rois"][organ]["density_HU"]["different_tps"] = self.results.loc[ + cycle["VOIs"][organ]["density_HU"]["different_tps"] = self.results.loc[ organ, "Density_HU" ] except (KeyError, AttributeError): # TODO: Handle errors explicitly pass - cycle["rois"][organ]["density_HU"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["density_HU"]["uncertainty"] = "NA" try: - cycle["rois"][organ]["density_HU"]["mean"] = numpy.mean( + cycle["VOIs"][organ]["density_HU"]["mean"] = numpy.mean( self.results.loc[organ, "Density_HU"] ) except ( @@ -851,96 +849,107 @@ def write_json_data( TypeError, ): # TODO: Handle errors explicitly pass - cycle["rois"][organ]["density_HU"]["mean_uncertainty"] = "NA" - cycle["rois"][organ]["density_gml"]["different_tps"] = "NA" - cycle["rois"][organ]["density_gml"]["uncertainty"] = "NA" - cycle["rois"][organ]["density_gml"]["mean"] = "NA" - cycle["rois"][organ]["density_gml"]["mean_uncertainty"] = "NA" - cycle["rois"][organ]["mass_g"]["different_tps"] = "NA" - cycle["rois"][organ]["mass_g"]["uncertainty"] = "NA" - cycle["rois"][organ]["mass_g"]["mean"] = "NA" - cycle["rois"][organ]["mass_g"]["mean_uncertainty"] = "NA" - cycle["rois"][organ]["fitting_eq"] = self.config["rois"][organ]["fit_order"] - cycle["rois"][organ]["no_of_fit_params"] = "NA" - cycle["rois"][organ]["fit_params"] = list( + cycle["VOIs"][organ]["density_HU"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["density_gml"]["different_tps"] = "NA" + cycle["VOIs"][organ]["density_gml"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["density_gml"]["mean"] = "NA" + cycle["VOIs"][organ]["density_gml"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["mass_g"]["different_tps"] = "NA" + cycle["VOIs"][organ]["mass_g"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["mass_g"]["mean"] = "NA" + cycle["VOIs"][organ]["mass_g"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["fitting_eq"] = self.config["VOIs"][organ]["fit_order"] + cycle["VOIs"][organ]["no_of_fit_params"] = "NA" + cycle["VOIs"][organ]["fit_params"] = list( self.results.loc[organ, "Fit_params"] ) - cycle["rois"][organ]["fit_params_uncertainty"] = "NA" - cycle["rois"][organ]["R_2"] = self.results.loc[organ, "R_squared_AIC"][0] - cycle["rois"][organ]["AIC"] = self.results.loc[organ, "R_squared_AIC"][1] - cycle["rois"][organ]["TIA_MBqh"] = self.results.loc[organ, "TIA_MBq_h"] - cycle["rois"][organ]["TIA_MBqh_uncertainty"] = "NA" - cycle["rois"][organ]["TIA_h"] = self.results.loc[organ, "TIA_h"] - cycle["rois"][organ]["TIA_h_uncertainty"] = "NA" - cycle["rois"][organ]["mean_AD_Gy"] = "NA" - cycle["rois"][organ]["mean_AD_Gy_uncertainty"] = "NA" - cycle["rois"][organ]["min_AD_Gy"] = "NA" - cycle["rois"][organ]["max_AD_Gy"] = "NA" - cycle["rois"][organ]["peak_AD_Gy"] = "NA" - cycle["rois"][organ]["repair_halflife"] = "NA" - cycle["rois"][organ]["alpha_beta"] = "NA" - cycle["rois"][organ]["composition"] = "NA" - cycle["rois"][organ]["total_s_value"] = "NA" - cycle["rois"][organ]["total_s_value_uncertainty"] = "NA" + cycle["VOIs"][organ]["washout_ratio"] = self.config["VOIs"][organ][ + "washout_ratio" + ] + cycle["VOIs"][organ]["fit_params_uncertainty"] = "NA" + cycle["VOIs"][organ]["R_2"] = ( + "NA" + if pandas.isna(self.results.loc[organ, "R_squared_AIC"][0]) + else self.results.loc[organ, "R_squared_AIC"][0] + ) + cycle["VOIs"][organ]["AIC"] = ( + "NA" + if pandas.isna(self.results.loc[organ, "R_squared_AIC"][1]) + else self.results.loc[organ, "R_squared_AIC"][1] + ) + cycle["VOIs"][organ]["TIA_MBqh"] = self.results.loc[organ, "TIA_MBq_h"] + cycle["VOIs"][organ]["TIA_MBqh_uncertainty"] = "NA" + cycle["VOIs"][organ]["TIA_h"] = self.results.loc[organ, "TIA_h"] + cycle["VOIs"][organ]["TIA_h_uncertainty"] = "NA" + cycle["VOIs"][organ]["mean_AD_Gy"] = "NA" + cycle["VOIs"][organ]["mean_AD_Gy_uncertainty"] = "NA" + cycle["VOIs"][organ]["min_AD_Gy"] = "NA" + cycle["VOIs"][organ]["max_AD_Gy"] = "NA" + cycle["VOIs"][organ]["peak_AD_Gy"] = "NA" + cycle["VOIs"][organ]["repair_halflife"] = "NA" + cycle["VOIs"][organ]["alpha_beta"] = "NA" + cycle["VOIs"][organ]["composition"] = "NA" + cycle["VOIs"][organ]["total_s_value"] = "NA" + cycle["VOIs"][organ]["total_s_value_uncertainty"] = "NA" if "Lesion" in organ or "TTB" in organ: - cycle["rois"][organ]["density_gml"]["different_tps"] = "NA" - cycle["rois"][organ]["density_gml"]["uncertainty"] = "NA" - cycle["rois"][organ]["density_gml"]["mean"] = self.results_lesions.loc[ - organ, "Density_g_per_mL" - ] - cycle["rois"][organ]["density_gml"]["mean_uncertainty"] = "NA" - cycle["rois"][organ]["mass_g"]["different_tps"] = "NA" - cycle["rois"][organ]["mass_g"]["uncertainty"] = "NA" - cycle["rois"][organ]["mass_g"]["mean"] = self.results_lesions.loc[ - organ, "Mass_g" - ] - cycle["rois"][organ]["mass_g"]["mean_uncertainty"] = "NA" - cycle["rois"][organ]["composition"] = self.results_lesions.loc[ - organ, "Composition" - ] - cycle["rois"][organ]["total_s_value"] = self.results_lesions.loc[ - organ, "Total_S_Value" - ] - cycle["rois"][organ]["total_s_value_uncertainty"] = "NA" - cycle["rois"][organ]["mean_AD_Gy"] = self.results_lesions.loc[ + cycle["VOIs"][organ]["density_gml"]["different_tps"] = "NA" + cycle["VOIs"][organ]["density_gml"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["density_gml"]["mean"] = ( + self.results_dosimetry_lesions.loc[organ, "Density_g_per_mL"] + ) + cycle["VOIs"][organ]["density_gml"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["mass_g"]["different_tps"] = "NA" + cycle["VOIs"][organ]["mass_g"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["mass_g"]["mean"] = ( + self.results_dosimetry_lesions.loc[organ, "Mass_g"] + ) + cycle["VOIs"][organ]["mass_g"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["composition"] = ( + self.results_dosimetry_lesions.loc[organ, "Composition"] + ) + cycle["VOIs"][organ]["total_s_value"] = ( + self.results_dosimetry_lesions.loc[organ, "Total_S_Value"] + ) + cycle["VOIs"][organ]["total_s_value_uncertainty"] = "NA" + cycle["VOIs"][organ]["mean_AD_Gy"] = self.results_dosimetry_lesions.loc[ organ, "AD_Gy" ] - cycle["rois"][organ]["mean_AD_Gy_uncertainty"] = "NA" + cycle["VOIs"][organ]["mean_AD_Gy_uncertainty"] = "NA" if "BoneMarrow" in organ: - cycle["rois"][organ]["volumes_mL"]["different_tps"] = 1170 - cycle["rois"][organ]["volumes_mL"]["uncertainty"] = "NA" - cycle["rois"][organ]["volumes_mL"]["mean"] = 1170 + cycle["VOIs"][organ]["volumes_mL"]["different_tps"] = 1170 + cycle["VOIs"][organ]["volumes_mL"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["volumes_mL"]["mean"] = 1170 if "Gland" in organ: - cycle["rois"][organ]["density_gml"]["different_tps"] = "NA" - cycle["rois"][organ]["density_gml"]["uncertainty"] = "NA" - cycle["rois"][organ]["density_gml"]["mean"] = ( - self.results_salivaryglands.loc[organ, "Density_g_per_mL"] + cycle["VOIs"][organ]["density_gml"]["different_tps"] = "NA" + cycle["VOIs"][organ]["density_gml"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["density_gml"]["mean"] = ( + self.results_dosimetry_salivaryglands.loc[organ, "Density_g_per_mL"] ) - cycle["rois"][organ]["density_gml"]["mean_uncertainty"] = "NA" - cycle["rois"][organ]["mass_g"]["different_tps"] = "NA" - cycle["rois"][organ]["mass_g"]["uncertainty"] = "NA" - cycle["rois"][organ]["mass_g"]["mean"] = ( - self.results_salivaryglands.loc[organ, "Mass_g"] + cycle["VOIs"][organ]["density_gml"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["mass_g"]["different_tps"] = "NA" + cycle["VOIs"][organ]["mass_g"]["uncertainty"] = "NA" + cycle["VOIs"][organ]["mass_g"]["mean"] = ( + self.results_dosimetry_salivaryglands.loc[organ, "Mass_g"] ) - cycle["rois"][organ]["mass_g"]["mean_uncertainty"] = "NA" - cycle["rois"][organ]["composition"] = self.results_salivaryglands.loc[ - organ, "Composition" - ] - cycle["rois"][organ]["total_s_value"] = self.results_salivaryglands.loc[ - organ, "Total_S_Value" - ] - cycle["rois"][organ]["total_s_value_uncertainty"] = "NA" - cycle["rois"][organ]["mean_AD_Gy"] = self.results_salivaryglands.loc[ - organ, "AD_Gy" - ] - cycle["rois"][organ]["mean_AD_Gy_uncertainty"] = "NA" + cycle["VOIs"][organ]["mass_g"]["mean_uncertainty"] = "NA" + cycle["VOIs"][organ]["composition"] = ( + self.results_dosimetry_salivaryglands.loc[organ, "Composition"] + ) + cycle["VOIs"][organ]["total_s_value"] = ( + self.results_dosimetry_salivaryglands.loc[organ, "Total_S_Value"] + ) + cycle["VOIs"][organ]["total_s_value_uncertainty"] = "NA" + cycle["VOIs"][organ]["mean_AD_Gy"] = ( + self.results_dosimetry_salivaryglands.loc[organ, "AD_Gy"] + ) + cycle["VOIs"][organ]["mean_AD_Gy_uncertainty"] = "NA" if self.config["Level"] == "Organ": - for organ in self.df_ad.index: - if organ in self.df_ad.index: + for organ in self.results_dosimetry_organs.index: + if organ in self.results_dosimetry_organs.index: cycle["Organ-level_AD"][organ] = { "AD[Gy/GBq]": {}, "AD[Gy/GBq]_uncertianty": {}, @@ -949,19 +958,21 @@ def write_json_data( "BED[Gy]": {}, "BED[Gy]_uncertianty": {}, } - cycle["Organ-level_AD"][organ]["AD[Gy/GBq]"] = self.df_ad.loc[ - organ, "AD[Gy/GBq]" - ] + cycle["Organ-level_AD"][organ]["AD[Gy/GBq]"] = ( + self.results_dosimetry_organs.loc[organ, "AD_total[Gy/GBq]"] + ) cycle["Organ-level_AD"][organ]["AD[Gy/GBq]_uncertainty"] = "NA" - cycle["Organ-level_AD"][organ]["AD[Gy]"] = self.df_ad.loc[ - organ, "AD[Gy]" - ] + cycle["Organ-level_AD"][organ]["AD[Gy]"] = ( + self.results_dosimetry_organs.loc[organ, "AD_total[Gy]"] + ) cycle["Organ-level_AD"][organ]["AD[Gy]_uncertainty"] = "NA" - if "BED[Gy]" in self.df_ad.columns: + if "BED[Gy]" in self.results_dosimetry_organs.columns: cycle["Organ-level_AD"][organ]["BED[Gy]"] = ( - self.df_ad.loc[organ, "BED[Gy]"] - if pandas.notna(self.df_ad.loc[organ, "BED[Gy]"]) + self.results_dosimetry_organs.loc[organ, "BED[Gy]"] + if pandas.notna( + self.results_dosimetry_organs.loc[organ, "BED[Gy]"] + ) else "NA" ) else: @@ -969,7 +980,9 @@ def write_json_data( cycle["Organ-level_AD"][organ]["BED[Gy]_uncertianty"] = "NA" - if "Yes" in self.config["LesionDosimetry"]: + if "Yes" in self.config["OrganLevel"]["AdditionalOptions"].get( + "LesionDosimetry" + ): cycle["Organ-level_AD"]["TTB"] = { "mass_g": {}, "volumes_mL": {}, @@ -979,22 +992,23 @@ def write_json_data( "AD[Gy/GBq]": {}, "AD[Gy/GBq]_uncertianty": {}, } - cycle["Organ-level_AD"]["TTB"]["mass_g"] = self.results_lesions.loc[ - "TTB", "Mass_g" - ] - cycle["Organ-level_AD"]["TTB"]["volumes_mL"] = self.results_lesions.loc[ - "TTB", "Volume_CT_mL" - ] - cycle["Organ-level_AD"]["TTB"]["TIA_h"] = self.results_lesions.loc[ - "TTB", "TIA_h" - ] - cycle["Organ-level_AD"]["TTB"]["AD[Gy]"] = self.results_lesions.loc[ - "TTB", "AD_Gy" - ] + cycle["Organ-level_AD"]["TTB"]["mass_g"] = ( + self.results_dosimetry_lesions.loc["TTB", "Mass_g"] + ) + cycle["Organ-level_AD"]["TTB"]["volumes_mL"] = ( + self.results_dosimetry_lesions.loc["TTB", "Volume_CT_mL"] + ) + cycle["Organ-level_AD"]["TTB"]["TIA_h"] = ( + self.results_dosimetry_lesions.loc["TTB", "TIA_h"] + ) + cycle["Organ-level_AD"]["TTB"]["AD[Gy]"] = ( + self.results_dosimetry_lesions.loc["TTB", "AD_Gy"] + ) cycle["Organ-level_AD"]["TTB"]["AD[Gy]_uncertainty"] = "NA" - cycle["Organ-level_AD"]["TTB"]["AD[Gy/GBq]"] = self.results_lesions.loc[ - "TTB", "AD_Gy" - ] / (float(self.config["InjectedActivity"]) / 1000) + cycle["Organ-level_AD"]["TTB"]["AD[Gy/GBq]"] = ( + self.results_dosimetry_lesions.loc["TTB", "AD_Gy"] + / (float(self.config["InjectedActivity"]) / 1000) + ) cycle["Organ-level_AD"]["TTB"]["AD[Gy/GBq]_uncertianty"] = "NA" with open(file_path, "w") as file: diff --git a/pytheranostics/dosimetry/organ_s_dosimetry.py b/pytheranostics/dosimetry/organ_s_dosimetry.py index 062cbec..0f460d5 100644 --- a/pytheranostics/dosimetry/organ_s_dosimetry.py +++ b/pytheranostics/dosimetry/organ_s_dosimetry.py @@ -55,13 +55,21 @@ def check_mandatory_fields_organ(self) -> None: return None @staticmethod - def _load_human_mass_table() -> pandas.DataFrame: + def _load_human_mass_target_organs_table(self) -> pandas.DataFrame: """Load the reference human phantom masses.""" with resource_path( - "pytheranostics.data", "phantom/human/human_phantom_masses.csv" + "pytheranostics.data", + f"phantom/human/ICRP_mass_{self.config['Gender'].lower()}_target.csv", + ) as masses_path: + self.target_organ_masses = pandas.read_csv(masses_path, index_col=0) + + def _load_human_mass_source_organs_table(self) -> pandas.DataFrame: + """Load the reference human phantom masses.""" + with resource_path( + "pytheranostics.data", + f"phantom/human/ICRP_mass_{self.config['Gender'].lower()}_source.csv", ) as masses_path: - masses = pandas.read_csv(masses_path, index_col=0) - return masses + self.source_organ_masses = pandas.read_csv(masses_path, index_col=0) def composition_and_density_from_HU(self, density: float) -> Tuple[str, float]: """Determine composition and density for a given CT HU value.""" @@ -123,22 +131,27 @@ def apply_sphere_method(self, df: pandas.DataFrame) -> pandas.DataFrame: def calculate_ttb(self): """Compute Total Tumor Burden (TTB) metrics and append to results_lesions.""" metrics = { - "Mass_g": self.results_lesions["Mass_g"].sum(), - "Volume_CT_mL": self.results_lesions["Volume_CT_mL"].sum(), - "TIA_h": self.results_lesions["TIA_h"].sum(), + "Mass_g": self.results_dosimetry_lesions["Mass_g"].sum(), + "Volume_CT_mL": self.results_dosimetry_lesions["Volume_CT_mL"].sum(), + "TIA_h": self.results_dosimetry_lesions["TIA_h"].sum(), "AD_Gy": ( - (self.results_lesions["Mass_g"] * self.results_lesions["AD_Gy"]).sum() + ( + self.results_dosimetry_lesions["Mass_g"] + * self.results_dosimetry_lesions["AD_Gy"] + ).sum() ) / ( - self.results_lesions["Mass_g"].sum() - if self.results_lesions["Mass_g"].sum() > 0 + self.results_dosimetry_lesions["Mass_g"].sum() + if self.results_dosimetry_lesions["Mass_g"].sum() > 0 else 0 ), } TTB = pandas.DataFrame(metrics, index=["TTB"]) - self.results_lesions = pandas.concat([self.results_lesions, TTB], axis=0) - return self.results_lesions + self.results_dosimetry_lesions = pandas.concat( + [self.results_dosimetry_lesions, TTB], axis=0 + ) + return self.results_dosimetry_lesions def prepare_data(self) -> None: """ @@ -219,6 +232,7 @@ def prepare_data(self) -> None: self.results_fitting.loc["Red Marrow"][ "Volume_CT_mL" ] = 1170 # TODO volume hardcoded, think about alternatives + self.results_fitting.loc["RemainderOfBody"]["Volume_CT_mL"] = ( self.config["PatientWeight_g"] - self.results_fitting.loc[ @@ -237,23 +251,25 @@ def prepare_data(self) -> None: self.results_fitting_organs = self.results_fitting[ ~lesion_mask ].copy() # all non-lesion entries - self.results_fitting_lesions = self.results_fitting[ - lesion_mask - ].copy() # only lesion entries - + self.results_fitting_lesions = self.results[ + self.results.index.str.contains("Lesion") + ] + elif "No" in self.config["OrganLevel"]["AdditionalOptions"].get( + "LesionDosimetry" + ): + self.results_fitting_organs = self.results_fitting.copy() if "TotalTumorBurden" in self.results_fitting.index: self.results_fitting.drop("TotalTumorBurden", axis=0, inplace=True) - if output_type == "Export": fmt = organ_conf["Output"]["ExportFormat"] if fmt.lower() == "olinda": - self.results_fitting = self.results_fitting.rename( + self.results_fitting_organs = self.results_fitting_organs.rename( index={"RemainderOfBody": "Total Body"} ) - self.results_fitting.loc["Total Body"]["Volume_CT_mL"] = ( + self.results_fitting_organs.loc["Total Body"]["Volume_CT_mL"] = ( self.config["PatientWeight_g"] - - self.results_fitting.loc[ - ~self.results_fitting.index.isin( + - self.results_fitting_organs.loc[ + ~self.results_fitting_organs.index.isin( ["Total Body", "RemainderOfBody"] ), "Volume_CT_mL", @@ -263,6 +279,10 @@ def prepare_data(self) -> None: print("Not Implemented yet.") else: print(f"Export format {fmt} not recognized.") + elif "Lesion" in self.config["Level"]: + self.results_fitting_lesions = self.results[ + self.results.index.str.contains("Lesion") + ] return None @@ -299,7 +319,6 @@ def process_dosimetry(self) -> None: output_type = organ_conf["Output"]["Type"] # 'Export' or 'Calculate' self.prepare_data() - print("Processing dosimetry at the organ level of OARs") if output_type == "Export": fmt = organ_conf["Output"]["ExportFormat"] @@ -314,7 +333,8 @@ def process_dosimetry(self) -> None: print(f"Export format {fmt} not recognized.") elif output_type == "Calculate": - self.calculate_absorbed_dose() + if "Organ" in self.config["Level"]: + self.calculate_absorbed_dose() else: raise ValueError(f"Unknown output type: {output_type}") @@ -323,7 +343,7 @@ def process_dosimetry(self) -> None: "LesionDosimetry" ): self.results_dosimetry_lesions = self.apply_sphere_method( - self.results_fitting_lesions.index.str.contains("Lesion") + self.results_fitting_lesions ) self.results_dosimetry_lesions = self.calculate_ttb() if "Yes" in self.config["OrganLevel"]["AdditionalOptions"].get( @@ -338,12 +358,12 @@ def calculate_absorbed_dose(self) -> pandas.DataFrame: """Calculate absorbed dose per target organ based on model and disintegration data.""" model_files = { "Female": { - "beta": f'177Lu_S_values_female_{self.config["OrganLevel"]["Calculation"]["SValueSource"].lower()}_BETA.csv', - "gamma": f'177Lu_S_values_female_{self.config["OrganLevel"]["Calculation"]["SValueSource"].lower()}_GAMMA.csv', + "gamma": f'{self.config["Radionuclide"]}_S_values_female_{self.config["OrganLevel"]["Calculation"]["SValueSource"].lower()}_GAMMA.csv', + "beta": f'{self.config["Radionuclide"]}_S_values_female_{self.config["OrganLevel"]["Calculation"]["SValueSource"].lower()}_BETA.csv', }, "Male": { - "beta": f'177Lu_S_values_male_{self.config["OrganLevel"]["Calculation"]["SValueSource"].lower()}_BETA.csv', - "gamma": f'177Lu_S_values_male_{self.config["OrganLevel"]["Calculation"]["SValueSource"].lower()}_GAMMA.csv', + "gamma": f'{self.config["Radionuclide"]}_S_values_male_{self.config["OrganLevel"]["Calculation"]["SValueSource"].lower()}_GAMMA.csv', + "beta": f'{self.config["Radionuclide"]}_S_values_male_{self.config["OrganLevel"]["Calculation"]["SValueSource"].lower()}_BETA.csv', }, } @@ -359,7 +379,10 @@ def calculate_absorbed_dose(self) -> pandas.DataFrame: svalues_gamma = self.load_svalues(gamma_path) print("Source organs available in the model:", svalues_beta.columns.tolist()) - print("Source organs present :", self.results_fitting.index.tolist()) + print("Source organs present :", self.results_fitting_organs.index.tolist()) + + self._load_human_mass_target_organs_table() + self._load_human_mass_source_organs_table() self.source_organs_missing = set(svalues_beta.columns) - set( self.results_fitting.index @@ -391,7 +414,7 @@ def calculate_absorbed_dose(self) -> pandas.DataFrame: ) # Apply mass scaling - dose_df = self.perform_mass_scaling(dose_df, self.config["Gender"]) + dose_df = self.perform_mass_scaling(dose_df) # Calculate absorbed dose in Gy for injected activity dose_df["AD_total[Gy/GBq]"] = ( @@ -403,7 +426,10 @@ def calculate_absorbed_dose(self) -> pandas.DataFrame: dose_df["AD_total[Gy]"] = dose_df["AD_total[Gy/GBq]"] / 1000 * injected_activity dose_df = dose_df.reset_index(drop=True) - self.df_ad = dose_df.copy() + self.results_dosimetry_organs = dose_df.copy() + self.results_dosimetry_organs = self.results_dosimetry_organs.set_index( + "Target organ" + ) return dose_df def hollow_organ_correction(self, df: pandas.DataFrame) -> pandas.DataFrame: @@ -439,14 +465,14 @@ def redistribute_ROB_into_source_organs_missing( if "RemainderOfBody" not in tia_series.index: return tia_series - missing_organs_df = self.organ_masses.loc[ - self.organ_masses.index.isin(self.source_organs_missing) + missing_organs_df = self.source_organ_masses.loc[ + self.source_organ_masses.index.isin(self.source_organs_missing) ] print(f"Missing organs DataFrame:\n{missing_organs_df.index.tolist()}") missing_organs_df = missing_organs_df.drop(index="Total Body") - mass_source_organs = self.organ_masses.loc[ + mass_source_organs = self.source_organ_masses.loc[ [org for org in tia_series.index if org != "RemainderOfBody"], "Mass_g" ].sum() @@ -475,13 +501,7 @@ def redistribute_ROB_into_source_organs_missing( def apply_s_value(self, tia_df, s_values, radiation_type) -> pandas.DataFrame: """Multiply S-values by TIA to compute dose matrix for radiation type.""" - # Path to organ masses - masses = self._load_human_mass_table() - gender = self.config.get("Gender", "Male") - if gender not in masses.columns: - raise ValueError(f"Unknown gender '{gender}' for mass table.") - self.organ_masses = masses[[gender]].rename(columns={gender: "Mass_g"}) - + print(f"Applying S-values for {radiation_type} radiation...") # Handle remainder of the body # Redistribute ROB TIA into missing source organs if needed - approach consistent with MIRDcalc software if "RemainderOfBody" in tia_df.index: @@ -548,39 +568,49 @@ def apply_s_value(self, tia_df, s_values, radiation_type) -> pandas.DataFrame: dose_df = s_values_subset.multiply(tia_series, axis=1) # ROB + print("Calculating Remainder of Body contributions...") dose_df["RemainderOfBody"] = 0 - total_body_mass = self.config["PatientWeight_g"] - ROB_mass = tia_df.loc["RemainderOfBody", "Volume_CT_mL"] + total_body_mass = self.source_organ_masses.loc["Total Body", "Mass_g"] + source_organs = tia_series.index if radiation_type == "beta": - organs = s_values_subset.index.difference( + target_organs = s_values_subset.index.difference( tia_series.index.difference(["Red Marrow", "Osteogenic Cells"]) ) + ROB_mass = tia_df.loc["RemainderOfBody", "Volume_CT_mL"] if radiation_type == "gamma": - organs = s_values_subset.index - # adjust source organs so that they are different for gamma and beta radiation (in beta it is total - source organs + bone + skeleton) + target_organs = s_values_subset.index + total_mass_source_organs = self.source_organ_masses.loc[ + self.source_organ_masses.index.intersection(tia_series.index), + "Mass_g", + ].sum() - for target_organ in organs: + ROB_mass = total_body_mass - total_mass_source_organs + + for target_organ in target_organs: contribution_from_sources = 0 - for source_organ in s_values.columns.difference(tia_series.index): - if target_organ.split()[0] == source_organ.split()[0]: - continue - if target_organ == "Osteogenic Cells" and source_organ in [ - "Cortical Bone", - "Trabecular Bone", - "Red Marrow", - ]: - continue - if target_organ == "Red Marrow" and source_organ in [ - "Trabecular Bone" - ]: + for source_organ in source_organs: + if radiation_type == "beta": + if target_organ.split()[0] == source_organ.split()[0]: + continue + if target_organ == "Red Marrow" and source_organ in [ + "Trabecular Bone" + ]: + continue + if target_organ == "Osteogenic Cells" and source_organ in [ + "Cortical Bone", + "Trabecular Bone", + "Red Marrow", + ]: + continue + + if source_organ == "Total Body": continue + if target_organ == "Total Body": continue - if source_organ == "Total Body": - continue - source_organ_mass = self.organ_masses.loc[ + source_organ_mass = self.source_organ_masses.loc[ source_organ, "Mass_g" ] @@ -591,14 +621,20 @@ def apply_s_value(self, tia_df, s_values, radiation_type) -> pandas.DataFrame: source_organ_mass / ROB_mass ) - s_value_ROB_to_target = ( - s_values.loc[target_organ, "Total Body"] - * (total_body_mass / ROB_mass) - ) - contribution_from_sources - dose_df.at[target_organ, "RemainderOfBody"] = ( + if target_organ == "Total Body": + s_value_ROB_to_target = ( + s_values.loc[target_organ, "Total Body"] + # * (total_body_mass / ROB_mass) since it is a target organ, the scaling will happen at mass scaling method, so not to have it twice its not performed here + ) - contribution_from_sources + else: + s_value_ROB_to_target = ( + s_values.loc[target_organ, "Total Body"] + * (total_body_mass / ROB_mass) + ) - contribution_from_sources + + dose_df.at[target_organ, "RemainderOfBody"] = float( s_value_ROB_to_target * tia_df.loc["RemainderOfBody", "TIA_s"] ) - else: # TODO print("No ROB option selected. Proceeding without ROB handling.") @@ -606,21 +642,28 @@ def apply_s_value(self, tia_df, s_values, radiation_type) -> pandas.DataFrame: return dose_df def perform_mass_scaling( - self, df: pandas.DataFrame, gender: str + self, + df: pandas.DataFrame, ) -> pandas.DataFrame: """Apply mass scaling to absorbed dose calculations based on patient-specific organ masses.""" - masses = self._load_human_mass_table() - if gender not in masses.columns: - raise ValueError(f"Unknown gender '{gender}' for mass table.") - model_masses_df = masses[[gender]].rename(columns={gender: "Mass_g"}) - print("Performing mass scaling...") for organ in df["Target organ"]: + mass_key = ( + "Total Body" if organ in ["Total Body", "RemainderOfBody"] else organ + ) + fit_key = ( + "RemainderOfBody" + if organ in ["Total Body", "RemainderOfBody"] + else organ + ) - if organ in model_masses_df.index and organ in self.results_fitting.index: - model_mass = model_masses_df.loc[organ, "Mass_g"] - patient_mass = self.results_fitting.loc[organ, "Volume_CT_mL"] + if ( + mass_key in self.target_organ_masses.index + and fit_key in self.results_fitting.index + ): + model_mass = self.target_organ_masses.loc[mass_key, "Mass_g"] + patient_mass = self.results_fitting.loc[fit_key, "Volume_CT_mL"] if ( pandas.notna(patient_mass) @@ -630,7 +673,6 @@ def perform_mass_scaling( if "AD_beta[Gy/GBq]" in df.columns: scaling_factor_beta = model_mass / patient_mass - print(f"Scaling factor for {organ} [β]: {scaling_factor_beta}") df.loc[ df["Target organ"] == organ, "AD_beta[Gy/GBq]" ] *= scaling_factor_beta @@ -674,13 +716,13 @@ def create_Olinda_file(self, dirname: str, savefile: bool = False) -> None: ind = template[template["Data"] == "[BEGIN NUCLIDES]"].index template.loc[ind[0] + 1, "Data"] = formatted_radionuclide + "|" - for organ in self.results_fitting.index: + for organ in self.results_fitting_organs.index: indices = template[template["Data"].str.contains(organ)].index source_organ = template.iloc[indices[0]].str.split("|")[0][0] mass_phantom = template.iloc[indices[0]].str.split("|")[0][1] - kinetic_data = self.results_fitting.loc[organ]["TIA_h"] - mass_data = round(self.results_fitting.loc[organ]["Volume_CT_mL"], 1) + kinetic_data = self.results_fitting_organs.loc[organ]["TIA_h"] + mass_data = round(self.results_fitting_organs.loc[organ]["Volume_CT_mL"], 1) # Update the template DataFrame template.iloc[indices[0]] = ( @@ -763,7 +805,7 @@ def read_Olinda_results(self, olinda_results_path: str) -> None: df_ad["Total"].fillna(0, inplace=True) df_ad["AD[Gy]"] = df_ad["Total"] * float(self.config["InjectedActivity"]) / 1000 df_ad = df_ad.rename(columns={"Total": "AD[Gy/GBq]"}) - self.df_ad = df_ad + self.results_dosimetry_organs = df_ad def compute_dose(self): """Compute Time Integrated Activity.""" diff --git a/pytheranostics/fits/fits.py b/pytheranostics/fits/fits.py index a5cc94c..e8874dc 100644 --- a/pytheranostics/fits/fits.py +++ b/pytheranostics/fits/fits.py @@ -23,6 +23,7 @@ def exponential_fit_lmfit( bounds: Optional[Dict[str, Tuple[float, float]]] = None, params_init: Optional[Dict[str, float]] = None, with_uptake: bool = False, + washout_ratio: Optional[int] = None, ) -> Tuple[lmfit.model.ModelResult, Callable]: """Fit data to a sum of exponentials with flexible parameter fixing using lmfit. @@ -105,6 +106,8 @@ def model_func(x, **params): will_have_expr = True if num_exponentials == 3 and with_uptake and amp_name == "C1": will_have_expr = True + if num_exponentials == 2 and washout_ratio and amp_name == "B2": + will_have_expr = True if amp_name in fixed_params: params.add(amp_name, value=fixed_params[amp_name], vary=False) @@ -158,6 +161,18 @@ def model_func(x, **params): "Parameters 'A1', 'B1', and 'C1' must be present to apply the constraint 'C1 = -(A1 + B1)'." ) + if num_exponentials == 2 and washout_ratio is not None: + if "B2" in params and "A2" in params: + print( + f"Applying constant ratio between decay constants of washout phases: B2 = {washout_ratio} * A2" + ) + params["B2"].set(expr=f"{washout_ratio} * A2") + else: + raise ValueError( + "Parameters 'A2' and 'B2' must be present to apply the constraint 'B2 = washout_ratio * A2'." + ) + + print(y_data, params) # Perform the fit result = model.fit( y_data, params, x=x_data, weights=None if sigma is None else 1.0 / sigma diff --git a/pytheranostics/imaging_ds/cycle_loader.py b/pytheranostics/imaging_ds/cycle_loader.py index b3ebee8..90d8a36 100644 --- a/pytheranostics/imaging_ds/cycle_loader.py +++ b/pytheranostics/imaging_ds/cycle_loader.py @@ -171,12 +171,19 @@ def extract_injection_from_first_tp_spect( weight_g = int(round(float(pw) * 1000.0)) except Exception: pass - + height_cm: Optional[int] = None + pw = getattr(ds, "PatientSize", None) # cm + if pw is not None: + try: + height_cm = int(round(float(pw) * 100.0)) + except Exception: + pass return { "InjectionDate": inj_date or "", "InjectionTime": inj_time or "", "InjectedActivity": injected_activity, # Bq (int) or None "PatientWeight_g": weight_g, + "PatientHeight_cm": height_cm, } diff --git a/pytheranostics/misc_tools/report_generator.py b/pytheranostics/misc_tools/report_generator.py index 759540f..4c8bcf6 100644 --- a/pytheranostics/misc_tools/report_generator.py +++ b/pytheranostics/misc_tools/report_generator.py @@ -11,6 +11,7 @@ from reportlab.lib.units import inch from reportlab.platypus import ( Image, + PageBreak, Paragraph, SimpleDocTemplate, Spacer, @@ -25,21 +26,33 @@ def signature_block(person, styles, width=2.5 * inch, height=0.6 * inch): Placeholder for signature, line, and text. Returns a small table that can be inserted side-by-side with others. """ - # Empty row for signature space - sig_space = Spacer(1, height) + elements = [] + + # If a signature path is provided, insert the image + if person.get("signature"): + sig_img = Image(person["signature"]) + # Scale the image to fit width and maintain aspect ratio + sig_img.drawHeight = height + sig_img.drawWidth = width + elements.append(sig_img) + else: + # Empty space if no signature image + elements.append(Spacer(1, height)) # Line row line = Table([[""]], colWidths=[width]) line.setStyle(TableStyle([("LINEABOVE", (0, 0), (-1, -1), 1, colors.black)])) + elements.append(line) # Text row text = Paragraph( f"{person['name']}
{person['title']}
{person['affiliation']}
", styles["Normal"], ) + elements.append(text) - # Wrap everything in a column table (1 col, 3 rows) - block = Table([[sig_space], [line], [text]], colWidths=[width]) + # Wrap everything in a column table (1 col, stacked) + block = Table([[e] for e in elements], colWidths=[width]) block.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "CENTER")])) return block @@ -69,23 +82,26 @@ def create_dosimetry_pdf(json_file, output_file, calculated_by=None, approved_by # Title title = Paragraph( - "DOSIMETRY REPORT", styles["Title"] + "DOSIMETRY ASSESSMENT", styles["Title"] ) elements.append(title) elements.append(Spacer(1, 0.5 * inch)) # Subject Information Section elements.append(Paragraph("Subject Information", styles["Heading2"])) - # Subject Information Table subject_data = [ - ["Clinical Trial", "PR.21"], + ["Clinical Trial", data.get("ClinicalTrial")], + ["Radiopharmaceutical", "177Lu-PSMA-617"], + ["Mode of administration", "I.V."], ["ID", data.get("PatientID")], ["Sex", data.get("Gender")], + ["Weight kg", data.get("Cycle_01", {})[0].get("Weight_g", "") / 1000], + ["Height cm", data.get("Cycle_01", {})[0].get("Height_cm", "")], ["Number of cycles ", data.get("No_of_completed_cycles")], ] - subject_table = Table(subject_data, colWidths=[1.5 * inch, 4 * inch]) + subject_table = Table(subject_data, colWidths=[2 * inch, 3.5 * inch]) subject_table.setStyle( TableStyle( [ @@ -146,11 +162,89 @@ def create_dosimetry_pdf(json_file, output_file, calculated_by=None, approved_by elements.append(caption) elements.append(Spacer(1, 0.2 * inch)) + elements.append(PageBreak()) + for i in range(1, data.get("No_of_completed_cycles") + 1): cycle_info(i, elements, styles, data) + elements.append(PageBreak()) + elements.append(Paragraph("Patient Summary", styles["Heading2"])) + # =============================== + # CUMULATIVE ORGAN TABLE + # =============================== + elements.append( + Paragraph("Cumulative Absorbed Dose Summary", styles["Heading3"]) + ) + + total_tia_kidneys = 0 + total_tia_salivary = 0 + total_tia_marrow = 0 + + total_ad_kidneys = 0 + total_ad_salivary = 0 + total_ad_marrow = 0 + + total_bed_kidneys = 0 + + for i in range(1, data.get("No_of_completed_cycles") + 1): + therapy_info = data.get(f"Cycle_0{i}", {})[0] + + # Kidneys + total_tia_kidneys += ( + therapy_info["VOIs"]["Kidney_Left"]["TIA_h"] + + therapy_info["VOIs"]["Kidney_Right"]["TIA_h"] + ) + total_ad_kidneys += therapy_info["Organ-level_AD"]["Kidneys"]["AD[Gy]"] + total_bed_kidneys += therapy_info["Organ-level_AD"]["Kidneys"]["BED[Gy]"] + + # Red marrow + total_tia_marrow += therapy_info["VOIs"]["BoneMarrow"]["TIA_h"] + total_ad_marrow += therapy_info["Organ-level_AD"]["Red Marrow"]["AD[Gy]"] + + # Salivary glands + total_tia_salivary += ( + therapy_info["VOIs"]["ParotidGland_Left"]["TIA_h"] + + therapy_info["VOIs"]["ParotidGland_Right"]["TIA_h"] + + therapy_info["VOIs"]["SubmandibularGland_Left"]["TIA_h"] + + therapy_info["VOIs"]["SubmandibularGland_Right"]["TIA_h"] + ) + total_ad_salivary += therapy_info["Organ-level_AD"]["Salivary Glands"]["AD[Gy]"] + + # Build the cumulative table + cumulative_data = [ + ["Organ", "Cumulative TIA (h)", "Cumulative AD (Gy)", "Cumulative BED (Gy)"], + [ + "Kidneys", + round(total_tia_kidneys, 2), + round(total_ad_kidneys, 2), + round(total_bed_kidneys, 2), + ], + ["Red Marrow", round(total_tia_marrow, 2), round(total_ad_marrow, 2), "-"], + [ + "Salivary glands", + round(total_tia_salivary, 2), + round(total_ad_salivary, 2), + "-", + ], + ] + + cumulative_table = Table(cumulative_data, colWidths=[1.5 * inch, 1.7 * inch]) + cumulative_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTNAME", (0, 0), (-1, -1), "Helvetica"), + ("FONTSIZE", (0, 0), (-1, -1), 12), + ("GRID", (0, 0), (-1, -1), 1, colors.black), + ("BACKGROUND", (0, 0), (0, -1), colors.lightblue), + ] + ) + ) + + elements.append(cumulative_table) # Paths to your three images image_paths = [ calling_folder / "TestDoseDB/Gy_cummulative.png", @@ -162,7 +256,7 @@ def create_dosimetry_pdf(json_file, output_file, calculated_by=None, approved_by imgs = [] for path in image_paths: img = Image(str(path)) - scale = min(max_width / img.imageWidth, max_height / img.imageHeight) / 2.8 + scale = min(max_width / img.imageWidth, max_height / img.imageHeight) / 3 img.drawWidth = img.imageWidth * scale img.drawHeight = img.imageHeight * scale imgs.append(img) @@ -186,7 +280,7 @@ def create_dosimetry_pdf(json_file, output_file, calculated_by=None, approved_by # Add caption elements.append(Spacer(1, 0.2 * inch)) caption = Paragraph( - "Figure 2: Cumulative absorbed dose, absorbed dose per cycle, and absorbed dose per GBq per cycle for target organs.", + "Figure 2: Cumulative AD, AD per cycle, and AD per GBq per cycle for target organs.", styles["Normal"], ) elements.append(caption) @@ -205,7 +299,7 @@ def create_dosimetry_pdf(json_file, output_file, calculated_by=None, approved_by img = Image(str(path)) # Scale to fit 2×2 layout scale = min( - (max_width / 2.2) / img.imageWidth, (max_height / 2.2) / img.imageHeight + (max_width / 2.5) / img.imageWidth, (max_height / 2.5) / img.imageHeight ) img.drawWidth = img.imageWidth * scale img.drawHeight = img.imageHeight * scale @@ -214,7 +308,7 @@ def create_dosimetry_pdf(json_file, output_file, calculated_by=None, approved_by # Arrange in 2×2 structure trend_table_data = [[trend_imgs[0], trend_imgs[1]], [trend_imgs[2], trend_imgs[3]]] - trend_table = Table(trend_table_data, colWidths=[max_width / 2.2, max_width / 2.2]) + trend_table = Table(trend_table_data, colWidths=[max_width / 2.5, max_width / 2.5]) trend_table.setStyle( TableStyle( @@ -226,18 +320,14 @@ def create_dosimetry_pdf(json_file, output_file, calculated_by=None, approved_by ] ) ) - - elements.append(Spacer(1, 0.3 * inch)) elements.append(trend_table) # Add caption - elements.append(Spacer(1, 0.2 * inch)) caption = Paragraph( "Figure 3: Trends of hematological and renal function, and PSA.", styles["Normal"], ) elements.append(caption) - elements.append(Spacer(1, 0.2 * inch)) # =============================== # Signatures Section @@ -272,11 +362,27 @@ def create_dosimetry_pdf(json_file, output_file, calculated_by=None, approved_by ) elements.append(app_table) + # =============================== + # Appendix + # =============================== + + elements.append(PageBreak()) + title = Paragraph("Appendix", styles["Heading2"]) + elements.append(title) + fig_title = Paragraph( + "Biodistribution and kinetic analysis", + styles["Heading3"], + ) + elements.append(fig_title) + + for i in range(1, data.get("No_of_completed_cycles") + 1): + biodistribution_per_cycle(i, elements, styles, data) + # Build PDF doc.build(elements) -def cycle_info(cycle_n, elements, styles, data): +def biodistribution_per_cycle(cycle_n, elements, styles, data): """Add cycle information to the PDF report elements. Parameters @@ -290,54 +396,18 @@ def cycle_info(cycle_n, elements, styles, data): data : dict Patient data dictionary. """ - # Therapy Information Section + if cycle_n == 1: + pass + else: + elements.append(PageBreak()) + therapy_title = Paragraph(f"Cycle {cycle_n}", styles["Heading2"]) elements.append(therapy_title) - # Therapy Information Table - therapy_info = data.get(f"Cycle_0{cycle_n}", {}) - therapy_data = [ - ["Radiopharmaceutical", "177Lu-PSMA-617"], - ["Mode of administration", "I.V."], - ["Administered Activity (MBq)", therapy_info[0].get("InjectedActivity", "")], - [ - "Date of injection", - ( - datetime.strptime( - therapy_info[0].get("InjectionDate", ""), "%Y%m%d" - ).strftime("%Y-%m-%d") - if therapy_info[0].get("InjectionDate", "") - else "" - ), - ], - ] - - therapy_table = Table(therapy_data, colWidths=[2.5 * inch, 3 * inch]) - therapy_table.setStyle( - TableStyle( - [ - ("ALIGN", (0, 0), (-1, -1), "LEFT"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("FONTNAME", (0, 0), (-1, -1), "Helvetica"), - ("FONTSIZE", (0, 0), (-1, -1), 12), - ("GRID", (0, 0), (-1, -1), 1, colors.black), - ("BACKGROUND", (0, 0), (0, -1), colors.lightgrey), - ] - ) - ) - - elements.append(therapy_table) - page_width, page_height = letter max_width = page_width - 2 * 72 # 1-inch margins max_height = page_height - 2 * 72 - fig_title = Paragraph( - "Time-activity curves, fit functions, and fit parameters", - styles["Heading3"], - ) - elements.append(fig_title) - calling_folder = Path().absolute() # notebook folder pattern = calling_folder / f"TestDoseDB/*_fit_Cycle_0{cycle_n}.png" @@ -357,19 +427,62 @@ def cycle_info(cycle_n, elements, styles, data): elements.append(Spacer(1, 0.3 * inch)) elements.append(img) + +def cycle_info(cycle_n, elements, styles, data): + """Add cycle information to the PDF report elements. + + Parameters + ---------- + cycle_n : int + The cycle number. + elements : list + List of reportlab elements to append to. + styles : dict + ReportLab styles dictionary. + data : dict + Patient data dictionary. + """ + # Therapy Information Section + therapy_title = Paragraph(f"Cycle {cycle_n}", styles["Heading2"]) + elements.append(therapy_title) + + # Therapy Information Table + therapy_info = data.get(f"Cycle_0{cycle_n}", {}) + + # Format injection date nicely + inj_date_raw = therapy_info[0].get("InjectionDate", "") + inj_date = ( + datetime.strptime(inj_date_raw, "%Y%m%d").strftime("%Y-%m-%d") + if inj_date_raw + else "" + ) + + # Build a clean paragraph instead of a table + therapy_info_para = Paragraph( + f"" + f"Administered activity: {therapy_info[0].get('InjectedActivity', '')} MBq
" + f"Date of injection: {inj_date}" + f"
", + styles["Normal"], + ) + + # Add to document + elements.append(therapy_info_para) + elements.append(Spacer(1, 0.089 * inch)) + fig_title = Paragraph( "Absorbed dose results for the organs at risk", styles["Heading3"] ) elements.append(fig_title) organ_data_Gy_GBq = [ - ["Organ", "TIA (h)", "AD (Gy/GBq)", "AD(Gy)", "BED (Gy)"], + ["Organ", "TIA (h)", "AD (Gy/GBq)", "AD (Gy)", "BED (Gy)"], [ "Kidneys", round( ( - therapy_info[0]["rois"]["Kidney_Left"]["TIA_h"] - + therapy_info[0]["rois"]["Kidney_Right"]["TIA_h"] + therapy_info[0]["VOIs"]["Kidney_Left"]["TIA_h"] + + therapy_info[0]["VOIs"]["Kidney_Right"]["TIA_h"] ), 2, ), @@ -379,7 +492,7 @@ def cycle_info(cycle_n, elements, styles, data): ], [ "Red Marrow", - round((therapy_info[0]["rois"]["BoneMarrow"]["TIA_h"]), 2), + round((therapy_info[0]["VOIs"]["BoneMarrow"]["TIA_h"]), 2), round(therapy_info[0]["Organ-level_AD"]["Red Marrow"]["AD[Gy/GBq]"], 2), round(therapy_info[0]["Organ-level_AD"]["Red Marrow"]["AD[Gy]"], 2), "-", @@ -388,10 +501,10 @@ def cycle_info(cycle_n, elements, styles, data): "Salivary glands", round( ( - therapy_info[0]["rois"]["ParotidGland_Left"]["TIA_h"] - + therapy_info[0]["rois"]["ParotidGland_Right"]["TIA_h"] - + therapy_info[0]["rois"]["SubmandibularGland_Left"]["TIA_h"] - + therapy_info[0]["rois"]["SubmandibularGland_Right"]["TIA_h"] + therapy_info[0]["VOIs"]["ParotidGland_Left"]["TIA_h"] + + therapy_info[0]["VOIs"]["ParotidGland_Right"]["TIA_h"] + + therapy_info[0]["VOIs"]["SubmandibularGland_Left"]["TIA_h"] + + therapy_info[0]["VOIs"]["SubmandibularGland_Right"]["TIA_h"] ), 2, ), diff --git a/pytheranostics/misc_tools/tools.py b/pytheranostics/misc_tools/tools.py index 44b447a..a9c6c40 100644 --- a/pytheranostics/misc_tools/tools.py +++ b/pytheranostics/misc_tools/tools.py @@ -3,7 +3,6 @@ from datetime import datetime from typing import Dict, Tuple -import matplotlib.pyplot as plt import numpy from scipy.ndimage import median_filter @@ -199,9 +198,9 @@ def extract_exponential_params_from_json( and all the parameters of the fit. """ with_uptake = False - # Determine order: - parameters = json_data[cycle][0]["rois"][region]["fit_params"] + parameters = json_data[cycle][0]["VOIs"][region]["fit_params"] + washout_ratio = json_data[cycle][0]["VOIs"][region]["washout_ratio"] # Handle Legacy: if len(parameters) in [3, 5]: @@ -214,7 +213,6 @@ def extract_exponential_params_from_json( fixed_parameters: Dict[str, float] = {} all_parameters: Dict[str, float] = {} - for order in range(fit_order): fixed_parameters[f"{param_name_base[order]}2"] = parameters[ exponential_idxs[order] @@ -226,14 +224,16 @@ def extract_exponential_params_from_json( all_parameters[f"{param_name_base[order]}2"] = parameters[ exponential_idxs[order] ] - + if washout_ratio is not None: + # Fix A1 ONLY + if "B1" in all_parameters: + fixed_parameters["B1"] = all_parameters["B1"] if fit_order == 2 and parameters[0] == -parameters[2]: with_uptake = True if fit_order == 3 and parameters[4] == -(parameters[0] + parameters[2]): with_uptake = True - - return fixed_parameters, with_uptake, all_parameters + return fixed_parameters, with_uptake, all_parameters, washout_ratio def extract_exponential_params_from_json_legacy( @@ -266,7 +266,7 @@ def extract_exponential_params_from_json_legacy( When the parameter configuration is incompatible with new format. """ # Read Parameters: - parameters = json_data[cycle][0]["rois"][region]["fit_params"] + parameters = json_data[cycle][0]["VOIs"][region]["fit_params"] if len(parameters) not in [3, 5]: raise AssertionError( @@ -321,7 +321,7 @@ def initialize_biokinetics_from_prior_cycle( dict Updated configuration dictionary. """ - for roi, roi_info in config["rois"].items(): + for roi, roi_info in config["VOIs"].items(): if ( "biokinectics_from_previous_cycle" in roi_info @@ -329,15 +329,18 @@ def initialize_biokinetics_from_prior_cycle( ): # Get previous cycle parameters: - fixed_param, with_uptake, all_params = extract_exponential_params_from_json( - json_data=prior_treatment_data, cycle=cycle, region=roi + fixed_param, with_uptake, all_params, washout_ratio = ( + extract_exponential_params_from_json( + json_data=prior_treatment_data, cycle=cycle, region=roi + ) ) - config["rois"][roi] = { + config["VOIs"][roi] = { "fixed_parameters": fixed_param, "fit_order": len(all_params) // 2, "param_init": all_params, "with_uptake": with_uptake, + "washout_ratio": washout_ratio, } print( @@ -347,45 +350,3 @@ def initialize_biokinetics_from_prior_cycle( print("") return config - - -def plot_MIP(SPECT=None, vmax=300000, figsize=(10, 5), ax=None): - """Plot Maximum Intensity Projection (MIP) of SPECT data. - - Parameters - ---------- - ax : matplotlib.axes.Axes, optional - Axes object to plot on. If None, a new figure and axes will be created. - SPECT : numpy.ndarray - SPECT data array. - vmax : int, optional - Maximum value for display, by default 300000. - figsize : tuple, optional - Figure size (width, height) in inches, by default (10, 5). - Only used if ax is None. - - Returns - ------- - fig : matplotlib.figure.Figure - Figure object. - ax : matplotlib.axes.Axes - Axes object with the plot. - """ - if ax is None: - fig, ax = plt.subplots(figsize=figsize) - else: - fig = ax.get_figure() - - plt.sca(ax) - plt.imshow( - SPECT.max(axis=0).T, cmap="Greys", interpolation="Gaussian", vmax=vmax, vmin=0 - ) - plt.xlim(30, 100) - plt.ylim(0, 234) - - plt.axis("off") - - plt.xticks([]) - plt.yticks([]) - - return fig, ax diff --git a/pytheranostics/plots/plots.py b/pytheranostics/plots/plots.py index 1d9f1ec..4d9307b 100644 --- a/pytheranostics/plots/plots.py +++ b/pytheranostics/plots/plots.py @@ -86,7 +86,7 @@ def plot_tac_residuals( else: weights = None # Generate x-values for plotting the fitted model starting from x=0 - x_fit = numpy.linspace(0, x_data[-1] * 3, 500) + x_fit = numpy.linspace(0, x_data.max() * 3, 500) y_fit = result.eval(x=x_fit) # First subplot: Linear scale plot @@ -96,7 +96,7 @@ def plot_tac_residuals( # Plot fitted model ax1.plot(x_fit, y_fit, color="red") ax1.set_xlim(left=0) # Start x-axis from zero - ax1.set_xlim(right=x_data[-1] * 2) # Start y-axis from zero + ax1.set_xlim(right=x_data.max() * 2) # Start y-axis from zero ax1.set_ylim(bottom=0) # Start y-axis from zero ax1.set_title(region) ax1.set_xlabel(x_label) @@ -119,7 +119,7 @@ def plot_tac_residuals( # Plot fitted model ax2.plot(x_fit, y_fit, color="red") ax2.set_xlim(left=0) # Start x-axis from zero - ax2.set_xlim(right=x_data[-1] * 2) # Start y-axis from zero + ax2.set_xlim(right=x_data.max() * 2) # Start y-axis from zero ax2.set_yscale("log") ax2.set_title(title_text) ax2.set_xlabel(x_label) @@ -147,3 +147,78 @@ def plot_tac_residuals( plt.show() return None + + +def plot_MIP_with_mask_outlines(ax, SPECT, masks=None, vmax=300000, label=None): + """Plot Maximum Intensity Projection (MIP) of SPECT data with masks outlines. + + Parameters + ---------- + ax : _type_ + _description_ + SPECT : _type_ + _description_ + masks : _type_, optional + _description_, by default None + vmax : int, optional + _description_, by default 300000 + """ + plt.sca(ax) + spect_mip = SPECT.max(axis=0) + plt.imshow(spect_mip.T, cmap="Greys", interpolation="Gaussian", vmax=vmax, vmin=0) + + if masks is not None: + for organ, mask in masks.items(): + organ_lower = organ.lower() + print(organ_lower) + if "peak" in organ_lower: + continue + else: + if "kidney" in organ_lower: + color = "lime" + elif "parotid" in organ_lower: + color = "red" + elif "submandibular" in organ_lower: + color = "red" + elif "lesion" in organ_lower: + color = "m" + else: + continue + + mip_mask = mask.max(axis=0) + if mip_mask.shape != spect_mip.shape: + mip_mask = mip_mask.T + + plt.contour( + numpy.transpose(mip_mask, (1, 0)), + levels=[0.5], + colors=[color], + linewidths=1.5, + alpha=0.5, + ) + + # --- Add label at mask center --- + if label is True: + ys, xs = numpy.where(mip_mask > 0) + + if len(xs) > 0: + # Corrected for transpose in contour + x_center = ys.mean() - 0.2 * ys.mean() # was xs + y_center = xs.mean() # was ys + + plt.text( + x_center, + y_center, + organ, + color=color, + fontsize=8, + ha="center", + va="center", + alpha=0.7, + ) + + plt.xlim(30, 100) + plt.ylim(0, 234) + plt.axis("off") + plt.xticks([]) + plt.yticks([])