File size: 10,183 Bytes
03d12e0
 
 
 
 
 
 
 
 
0b1229a
 
 
 
03d12e0
 
 
 
 
 
 
6e8f0b1
 
 
0b1229a
03d12e0
 
 
0b1229a
 
 
03d12e0
 
 
0b1229a
03d12e0
 
 
0b1229a
03d12e0
 
 
 
 
 
 
0b1229a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a17ca8
0b1229a
 
9a17ca8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b1229a
9a17ca8
 
 
 
 
 
 
 
 
 
 
0b1229a
 
 
 
9a17ca8
a8ca08a
5e3e7ba
c963751
0b1229a
9a17ca8
 
 
 
 
 
 
 
 
 
0b1229a
 
 
 
 
 
03d12e0
0b1229a
03d12e0
 
 
 
 
 
0b1229a
03d12e0
 
 
 
 
 
 
 
 
0b1229a
03d12e0
0b1229a
03d12e0
0b1229a
 
 
03d12e0
 
 
 
 
 
0b1229a
03d12e0
0b1229a
03d12e0
0b1229a
 
 
03d12e0
0b1229a
 
 
 
 
 
 
 
03d12e0
0b1229a
03d12e0
 
 
0b1229a
03d12e0
6e8f0b1
a8ca08a
03d12e0
 
0b1229a
03d12e0
 
0b1229a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03d12e0
 
 
0b1229a
03d12e0
 
 
 
 
24ccd6a
 
 
 
03d12e0
 
0b1229a
03d12e0
0b1229a
 
 
 
 
 
 
 
 
 
 
 
 
03d12e0
 
 
0b1229a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
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"<h2 style='color: {risk_color}; margin-top: 0;'>Predicted probability of R+ resection: {risk_percentage:.2f}</h2>"

    # 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="<i>Upload a file and run analysis...</i>")

    # 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)