import gradio as gr import os import shutil import joblib import numpy as np import nibabel as nib import traceback import tempfile import torch from scipy.ndimage import distance_transform_edt # SET ENV VARIABLES *BEFORE* IMPORTING NNUNET os.environ['nnUNet_raw'] = './tmp/nnUNet_raw' os.environ['nnUNet_results'] = './model_weights' os.environ['nnUNet_preprocessed'] = './tmp/nnUNet_preprocessed' os.makedirs('./tmp/nnUNet_raw', exist_ok=True) os.makedirs('./tmp/nnUNet_preprocessed', exist_ok=True) from nnunetv2.inference.predict_from_raw_data import nnUNetPredictor from batchgenerators.utilities.file_and_folder_operations import join # Model Initializations device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') try: predictor = nnUNetPredictor( tile_step_size=0.5, use_gaussian=True, use_mirroring=True, perform_everything_on_device=True, device=device, verbose=False, verbose_preprocessing=False, allow_tqdm=True ) model_folder = './model_weights/Dataset998_PanTSTen/nnUNetTrainer__nnUNetPlans__3d_fullres' predictor.initialize_from_trained_model_folder( model_folder, use_folds=('all',), checkpoint_name='checkpoint_best.pth', ) except Exception as e: predictor = None print(f"Warning: nnU-Net failed to load. {e}") try: risk_model = joblib.load("model.joblib") except FileNotFoundError: risk_model = None # FEATURE EXTRACTION def extract_features(seg_path, high_risk_threshold_mm=5.0): """Calculates metrics directly from a NIfTI mask.""" seg_img = nib.load(seg_path) seg_data = seg_img.get_fdata() spacing = seg_img.header.get_zooms()[:3] voxel_volume_mm3 = spacing[0] * spacing[1] * spacing[2] tumor_mask = (seg_data == 10) artery_mask = np.isin(seg_data, [2, 8]) veins_mask = (seg_data == 9) if not np.any(tumor_mask): return 50.0, 0.0, 0.0, 0.0 ttv_mm3 = np.sum(tumor_mask) * voxel_volume_mm3 dist_artery = distance_transform_edt(~artery_mask, sampling=spacing) if np.any(artery_mask) else np.full(seg_data.shape, 50.0) dist_veins = distance_transform_edt(~veins_mask, sampling=spacing) if np.any(veins_mask) else np.full(seg_data.shape, 50.0) tumor_artery_distances = dist_artery[tumor_mask] tumor_vein_distances = dist_veins[tumor_mask] mac = np.min(tumor_artery_distances) aev_mm3 = np.sum(tumor_artery_distances <= high_risk_threshold_mm) * voxel_volume_mm3 vev_mm3 = np.sum(tumor_vein_distances <= high_risk_threshold_mm) * voxel_volume_mm3 return mac, vev_mm3, aev_mm3, ttv_mm3 def calculate_risk_and_format(mask_path, no_chemo, pd_surgery): """Runs extraction, ML prediction, and formats UI output.""" # 1. Extract raw metrics mac, vev, aev, ttv = extract_features(mask_path) # Convert textboxes safely try: chemo_val = int(no_chemo) pd_val = int(pd_surgery) except ValueError: error_msg = "**Error:** Clinical metadata fields must be numbers (1 or 0)." return (gr.update(value=error_msg, visible=True), "", "", "", "", "") # 2. Hardcode the Means and Stds from your training data # Extracted from your means/stds arrays based on their index positions TTV_MEAN, TTV_STD = 8980.23827, 8281.19811 MAC_MEAN, MAC_STD = 8.53567157, 8.92353600 AEV_MEAN, AEV_STD = 155.784393, 367.152035 VEV_MEAN, VEV_STD = 576.025386, 559.944824 # 3. Apply Z-score Normalization scaled_ttv = (ttv - TTV_MEAN) / TTV_STD scaled_mac = (mac - MAC_MEAN) / MAC_STD scaled_aev = (aev - AEV_MEAN) / AEV_STD scaled_vev = (vev - VEV_MEAN) / VEV_STD # 4. Predict Risk if risk_model is not None: # STRICT ORDER MATCHING your column_order: # ['Chemo_0', 'Surgery_1', 'Total_Tumor_Volume_mm3_arterial', 'Min_Distance_mm_arterial', 'High_Risk_Volume_mm3_5mm_arterial', 'High_Risk_Volume_mm3_5mm_venous'] features = np.array([[ chemo_val, # Chemo_0 pd_val, # Surgery_1 scaled_ttv, # Total_Tumor_Volume_mm3_arterial scaled_mac, # Min_Distance_mm_arterial scaled_aev, # High_Risk_Volume_mm3_5mm_arterial scaled_vev # High_Risk_Volume_mm3_5mm_venous ]]) prob_r1 = risk_model.predict_proba(features)[0][1] else: prob_r1 = 0.72 # Fallback dummy # 5. Format Outputs risk_percentage = prob_r1 risk_color = "#C55A11" if risk_percentage > 0.5 else "#B89036" risk_display = f"

Predicted probability of R+ resection: {risk_percentage:.2f}

" # Notice we still display the RAW un-normalized values to the user in the UI, # but the model calculates risk using the SCALED values. return ( gr.update(value="", visible=False), f"{mac:.1f}", f"{vev:.1f}", f"{aev:.1f}", f"{ttv:.1f}", risk_display ) # PIPELINE HANDLERS def process_raw_ct(nifti_file, no_chemo, pd_surgery): """Panel 1: Runs heavy nnU-Net segmentation first.""" if nifti_file is None: return gr.update(value="**Error:** Please upload a Raw CT NIfTI file.", visible=True), *[gr.update(value="")]*4, gr.update(value="") try: with tempfile.TemporaryDirectory() as temp_in_dir, tempfile.TemporaryDirectory() as temp_out_dir: original_path = nifti_file.name base_name = os.path.basename(original_path) # Ensure _0000 suffix if not base_name.endswith("_0000.nii.gz") and base_name.endswith(".nii.gz"): new_base = base_name.replace(".nii.gz", "_0000.nii.gz") else: new_base = base_name input_file_path = os.path.join(temp_in_dir, new_base) shutil.copy(original_path, input_file_path) if predictor is None: raise RuntimeError("nnU-Net is not initialized.") # Run Segmentation predictor.predict_from_files( temp_in_dir, temp_out_dir, save_probabilities=False, overwrite=False, num_processes_preprocessing=2, num_processes_segmentation_export=2, folder_with_segs_from_prev_stage=None, num_parts=1, part_id=0 ) output_base = new_base.replace("_0000", "") output_mask_path = os.path.join(temp_out_dir, output_base) if not os.path.exists(output_mask_path): raise FileNotFoundError("nnU-Net failed to generate mask.") return calculate_risk_and_format(output_mask_path, no_chemo, pd_surgery) except Exception as e: error_msg = f"**nnU-Net Pipeline Error:** {str(e)}\n\n```\n{traceback.format_exc()}\n```" return gr.update(value=error_msg, visible=True), *[gr.update(value="")]*4, gr.update(value="") def process_presegmented_mask(mask_file, no_chemo, pd_surgery): """Panel 2: Skips nnU-Net, processes mask directly.""" if mask_file is None: return gr.update(value="**Error:** Please upload a pre-segmented Mask NIfTI file.", visible=True), *[gr.update(value="")]*4, gr.update(value="") try: # Directly pass the uploaded mask to the extraction logic return calculate_risk_and_format(mask_file.name, no_chemo, pd_surgery) except Exception as e: error_msg = f"**Fast Pipeline Error:** {str(e)}\n\n```\n{traceback.format_exc()}\n```" return gr.update(value=error_msg, visible=True), *[gr.update(value="")]*4, gr.update(value="") # GRADIO UI LAYOUT with gr.Blocks() as demo: gr.Markdown("# Preoperative PDAC R+ Resection Risk Predictor") with gr.Row(): # LEFT COLUMN: Inputs (Tabs + Clinical Data) with gr.Column(scale=1): with gr.Tabs(): # TAB 1: Slow / Full Pipeline with gr.TabItem("Raw CT (Runs nnU-Net)"): gr.Markdown("*Note: Segmentation may take >1 hour on CPU.*") file_input_ct = gr.File(label="Upload Raw CT Scan (.nii.gz)", file_count="single") analyze_ct_btn = gr.Button("Segment & Analyze", variant="primary") # TAB 2: Fast Pipeline with gr.TabItem("Pre-segmented Mask (Fast)"): gr.Markdown("*Bypass AI segmentation. Upload your own mask.*") file_input_mask = gr.File(label="Upload Segmentation Mask (.nii.gz)", file_count="single") analyze_mask_btn = gr.Button("Analyze Mask", variant="primary") gr.Markdown("---") gr.Markdown("### Clinical Metadata (Required)") no_chemo = gr.Checkbox(label="Absence of Neoadjuvant Chemo (1/0)") pd_surgery = gr.Checkbox(label="Patient underwent PD (Pancreaticoduodenectomy) (1/0)") # RIGHT COLUMN: Shared Outputs with gr.Column(scale=2): error_box = gr.Markdown(visible=False) gr.Markdown("### Segmentation Metrics") with gr.Row(): mac_out = gr.Textbox(label="Minimum Arterial Clearance (mm)", interactive=False) vev_out = gr.Textbox(label="Venous Encasement Volume (mm³)", interactive=False) aev_out = gr.Textbox(label="Arterial Encasement Volume (mm³)", interactive=False) ttv_out = gr.Textbox(label="Total Tumor Volume (mm³)", interactive=False) gr.Markdown("### Risk Assessment") risk_out = gr.HTML(value="Upload a file and run analysis...") # Connect buttons to their respective functions, but update the SAME output boxes outputs_list = [error_box, mac_out, vev_out, aev_out, ttv_out, risk_out] analyze_ct_btn.click( fn=process_raw_ct, inputs=[file_input_ct, no_chemo, pd_surgery], outputs=outputs_list ) analyze_mask_btn.click( fn=process_presegmented_mask, inputs=[file_input_mask, no_chemo, pd_surgery], outputs=outputs_list ) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860, share=True)