# --- Standard library imports --- import os import io import base64 import re import time import shutil import traceback import json # --- Third-party library imports --- from flask import Flask, request, jsonify, send_file, render_template import nibabel as nib import numpy as np from PIL import Image from werkzeug.utils import secure_filename import torch from skimage.measure import label, marching_cubes # --- MONAI (Medical Open Network for AI) imports --- from monai.networks.nets import AttentionUnet from monai.transforms import ( Compose, LoadImaged, EnsureChannelFirstd, Spacingd, CropForegroundd, Resized, ScaleIntensityRanged, EnsureTyped, ConvertToMultiChannelBasedOnBratsClassesd ) # --- FLASK APP AND FOLDER SETUP --- app = Flask(__name__) UPLOAD_FOLDER = 'data/uploads' EXAMPLES_FOLDER = 'examples' os.makedirs(UPLOAD_FOLDER, exist_ok=True) app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER # --- Part 1: GLOBAL MODEL AND PIPELINE SETUP --- print("--- Initializing Brain Segmentation Server ---") # FIX: Set device to CPU as requested DEVICE = torch.device("cpu") print(f"--> Using device: {DEVICE}") BEST_MODEL_PATH = 'model/best_model.pth' # --- Model Definition --- model = AttentionUnet( spatial_dims=3, in_channels=4, out_channels=3, channels=(16, 32, 64, 128, 256), strides=(2, 2, 2, 2), ).to(DEVICE) # --- Load Pre-trained Model Weights --- try: if os.path.exists(BEST_MODEL_PATH): checkpoint = torch.load(BEST_MODEL_PATH, map_location=DEVICE) model.load_state_dict(checkpoint['model_state_dict']) model.eval() # No .half() conversion for CPU print(f"--> PyTorch Model loaded successfully from {BEST_MODEL_PATH}") else: print(f"--> WARNING: Model file not found at {BEST_MODEL_PATH}.") except Exception as e: print(f"--> ERROR: Failed to load PyTorch model: {e}") # --- REPLICATED MONAI PREPROCESSING PIPELINE --- MODALITY_KEYS = ['t1c', 't1n', 't2f', 't2w'] SEG_KEY = 'seg' ALL_KEYS = MODALITY_KEYS + [SEG_KEY] SPATIAL_SHAPE = (128, 128, 128) monai_preprocess_pipeline = Compose([ LoadImaged(keys=ALL_KEYS, image_only=True, ensure_channel_first=True, allow_missing_keys=True), ConvertToMultiChannelBasedOnBratsClassesd(keys=SEG_KEY, allow_missing_keys=True), Spacingd(keys=ALL_KEYS, pixdim=(1.0, 1.0, 1.0), mode=["bilinear"] * 4 + ["nearest"], allow_missing_keys=True), ScaleIntensityRanged(keys=MODALITY_KEYS, a_min=0.0, a_max=1400.0, b_min=0.0, b_max=1.0, clip=True, allow_missing_keys=True), CropForegroundd(keys=ALL_KEYS, source_key='t1c', margin=10, allow_missing_keys=True), Resized(keys=ALL_KEYS, spatial_size=SPATIAL_SHAPE, mode=["area"] * 4 + ["nearest"], allow_missing_keys=True), # FIX: Ensure input is float32 for CPU model EnsureTyped(keys=ALL_KEYS, dtype=torch.float32, allow_missing_keys=True), ]) def get_safe_session_path(session_id_from_client): if not session_id_from_client: raise ValueError("Session ID is missing.") safe_folder_name = secure_filename(session_id_from_client) if not safe_folder_name: raise ValueError("Invalid Session ID.") return os.path.join(app.config['UPLOAD_FOLDER'], safe_folder_name) # --- Post-Processing, Analysis, and Mesh Generation --- def remove_small_lesions(pred_mask_np, min_size_map): processed_mask = np.zeros_like(pred_mask_np) for c in range(pred_mask_np.shape[0]): channel_mask = pred_mask_np[c] if np.sum(channel_mask) > 0: labeled_mask = label(channel_mask) min_size = min_size_map.get(c, 50) for region_label in range(1, np.max(labeled_mask) + 1): if np.sum(labeled_mask == region_label) > min_size: processed_mask[c][labeled_mask == region_label] = 1 return processed_mask # FIX: VOLUMETRIC ANALYSIS WITH JSON SERIALIZABLE TYPES def perform_volumetric_analysis(true_mask, pred_mask): results = {"volumetry": [], "dice_scores": []} smooth = 1e-6 labels_map = {1: "Whole Tumor (WT)", 0: "Tumor Core (TC)", 2: "Enhancing Tumor (ET)"} if pred_mask is not None: results["volumetry"].append({ "type": "Predicted", "wt": float(np.sum(pred_mask[1])), "tc": float(np.sum(pred_mask[0])), "et": float(np.sum(pred_mask[2])) }) if true_mask is not None and np.sum(true_mask) > 0: results["volumetry"].append({ "type": "Ground Truth", "wt": float(np.sum(true_mask[1])), "tc": float(np.sum(true_mask[0])), "et": float(np.sum(true_mask[2])) }) if true_mask is not None and pred_mask is not None and np.sum(true_mask) > 0: for i in [1, 0, 2]: true_flat, pred_flat = true_mask[i].flatten(), pred_mask[i].flatten() intersection = np.sum(true_flat * pred_flat) sum_of_sets = np.sum(true_flat) + np.sum(pred_flat) dice_score = (2. * intersection + smooth) / (sum_of_sets + smooth) results["dice_scores"].append({"label": labels_map[i], "score": float(dice_score)}) return results def generate_and_save_meshes(session_folder): """ Generates 3D surface meshes from processed numpy arrays and NIfTI files. It creates meshes for the brain, ground truth segmentations, and predicted segmentations, then saves them all into a single JSON file for the frontend renderer. """ print("--> Generating and saving 3D mesh data...") mesh_data_for_json = {} npz_path = os.path.join(session_folder, 'processed.npz') pred_path = os.path.join(session_folder, 'pred.nii') json_output_path = os.path.join(session_folder, 'meshes.json') def create_mesh_dict(volume_data_3d, level, color, opacity): """Helper function to run marching cubes and format the output.""" try: # Marching cubes algorithm to extract surface mesh from a 3D volume verts, faces, _, _ = marching_cubes(volume_data_3d, level=level) if len(verts) == 0 or len(faces) == 0: return None # No surface found at this level # The frontend expects flattened lists for vertices and faces return { "v": verts.flatten().tolist(), "f": faces.flatten().tolist(), "c": color, "o": opacity } except Exception as e: print(f" - Could not generate mesh: {e}") return None # 1. Process the main data file (processed.npz) if os.path.exists(npz_path): print(f" - Loading data from {npz_path}") with np.load(npz_path) as data: # --- Generate Brain Mesh from T1c channel --- if 'image' in data: print(" - Generating brain mesh from T1c...") t1c_volume = data['image'][0] # T1c is the first channel # We choose a level slightly above the background noise brain_mesh = create_mesh_dict(t1c_volume, level=0.2, color="#CCCCCC", opacity=0.3) if brain_mesh: mesh_data_for_json['Brain'] = brain_mesh print(" ... Brain mesh created.") # --- Generate Ground Truth Meshes from 'mask' --- if 'mask' in data and np.any(data['mask']): print(" - Generating ground truth meshes...") gt_mask = data['mask'] # The level for a binary mask should be between 0 and 1 level = 0.5 # Channel 1: Whole Tumor (WT) gt_wt_mesh = create_mesh_dict(gt_mask[1], level, "#ff4646", 1.0) # Red if gt_wt_mesh: mesh_data_for_json['GT_WT'] = gt_wt_mesh # Channel 0: Tumor Core (TC) gt_tc_mesh = create_mesh_dict(gt_mask[0], level, "#46ff46", 1.0) # Green if gt_tc_mesh: mesh_data_for_json['GT_TC'] = gt_tc_mesh # Channel 2: Enhancing Tumor (ET) gt_et_mesh = create_mesh_dict(gt_mask[2], level, "#ffff46", 1.0) # Yellow if gt_et_mesh: mesh_data_for_json['GT_ET'] = gt_et_mesh print(" ... Ground truth meshes created.") # 2. Process the prediction file (pred.nii) if it exists if os.path.exists(pred_path): print(f" - Loading prediction from {pred_path}") pred_img = nib.load(pred_path) pred_data = pred_img.get_fdata() if np.any(pred_data): print(" - Generating prediction meshes...") level = 0.5 # Level for binary masks # Create binary masks from the multi-class label map # WT = ET (4) + TC (1) + non-enhancing core (2) -> everything non-zero pred_wt_mask = (pred_data > 0).astype(np.float32) # TC = ET (4) + TC (1) pred_tc_mask = np.isin(pred_data, [1, 4]).astype(np.float32) # ET = ET (4) pred_et_mask = (pred_data == 4).astype(np.float32) # --- Generate Prediction Meshes --- pred_wt_mesh = create_mesh_dict(pred_wt_mask, level, "#ff4646", 0.8) # Red if pred_wt_mesh: mesh_data_for_json['Pred_WT'] = pred_wt_mesh pred_tc_mesh = create_mesh_dict(pred_tc_mask, level, "#46ff46", 0.9) # Green if pred_tc_mesh: mesh_data_for_json['Pred_TC'] = pred_tc_mesh pred_et_mesh = create_mesh_dict(pred_et_mask, level, "#4646ff", 1.0) # Blue if pred_et_mesh: mesh_data_for_json['Pred_ET'] = pred_et_mesh print(" ... Prediction meshes created.") # 3. Save the final dictionary to a JSON file if mesh_data_for_json: try: with open(json_output_path, 'w') as f: json.dump(mesh_data_for_json, f) print(f"--> Successfully saved mesh data to {json_output_path}") except Exception as e: print(f"--> ERROR: Failed to save meshes.json: {e}") else: print("--> No mesh data was generated, skipping save.") # --- Part 2: FLASK ENDPOINTS --- @app.route('/') def index(): return render_template('index.html') @app.route('/list_examples', methods=['GET']) def list_examples(): try: return jsonify(sorted([d for d in os.listdir(EXAMPLES_FOLDER) if os.path.isdir(os.path.join(EXAMPLES_FOLDER, d))])) except FileNotFoundError: return jsonify([]) @app.route('/reset_session', methods=['POST']) def reset_session(): try: session_folder = get_safe_session_path(request.json.get('session_id')) if os.path.exists(session_folder): shutil.rmtree(session_folder) return jsonify({"message": "Session reset."}) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route('/delete_file', methods=['POST']) def delete_file(): try: data = request.get_json() session_folder = get_safe_session_path(data.get('session_id')) modality = secure_filename(data.get('modality')) file_path = os.path.join(session_folder, f"{modality}.nii") if os.path.exists(file_path): os.remove(file_path) return jsonify({"message": f"File {modality} deleted."}) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route('/upload', methods=['POST']) def upload_file(): try: session_folder = get_safe_session_path(request.form.get('session_id')) os.makedirs(session_folder, exist_ok=True) file, modality = request.files['file'], request.form.get('modality') file.save(os.path.join(session_folder, f"{modality}.nii")) loaded_modalities = [f.replace('.nii', '') for f in os.listdir(session_folder) if f.endswith('.nii')] return jsonify({"message": f"{modality} uploaded", "loaded_modalities": loaded_modalities}) except Exception as e: return jsonify({"error": f"Upload failed: {e}"}), 500 # FIX: CORRECTED REGEX LOGIC FOR EXAMPLE LOADING @app.route('/load_example', methods=['POST']) def load_example(): data = request.get_json() try: session_folder = get_safe_session_path(data.get('session_id')) if os.path.exists(session_folder): shutil.rmtree(session_folder) os.makedirs(session_folder, exist_ok=True) example_id = secure_filename(data.get('id')) example_dir = os.path.join(EXAMPLES_FOLDER, example_id) modality_suffix_map = {'t1c': '-t1c', 't1n': '-t1n', 't2f': '-t2f', 't2w': '-t2w', 'seg': '-seg'} loaded_modalities = [] for modality, suffix in modality_suffix_map.items(): pattern = re.compile(f".*?{suffix}\.nii(\.gz)?$") found_file = next((f for f in os.listdir(example_dir) if pattern.match(f)), None) if found_file: source_path = os.path.join(example_dir, found_file) dest_path = os.path.join(session_folder, f"{modality}.nii") # Load with nibabel (handles .nii and .nii.gz) and save as .nii img = nib.load(source_path) nib.save(img, dest_path) loaded_modalities.append(modality) return jsonify({"message": "Example files loaded and decompressed", "loaded_modalities": loaded_modalities}) except Exception as e: traceback.print_exc() return jsonify({"error": str(e)}), 500 @app.route('/process', methods=['POST']) def process_files(): try: session_folder = get_safe_session_path(request.json.get('session_id')) print(f"--- Processing files for session: {os.path.basename(session_folder)} ---") for m in MODALITY_KEYS: if not os.path.exists(os.path.join(session_folder, f"{m}.nii")): return jsonify({"error": f"Missing required file: {m.upper()}"}), 400 input_dict = {k: os.path.join(session_folder, f"{k}.nii") for k in ALL_KEYS if os.path.exists(os.path.join(session_folder, f"{k}.nii"))} processed_dict = monai_preprocess_pipeline(input_dict) stacked_image = torch.cat([processed_dict[key] for key in MODALITY_KEYS], dim=0) stacked_mask = processed_dict.get(SEG_KEY, torch.zeros((3, *SPATIAL_SHAPE), dtype=torch.float32)) np.savez_compressed(os.path.join(session_folder, "processed.npz"), image=stacked_image.numpy(), mask=stacked_mask.numpy()) print("--> Saved 'processed.npz'") generate_and_save_meshes(session_folder) return jsonify({"message": "Files processed", "num_slices": SPATIAL_SHAPE[2], "slice_index": SPATIAL_SHAPE[2] // 2}) except Exception as e: traceback.print_exc() return jsonify({"error": f"Processing failed: {str(e)}"}), 500 @app.route('/get_all_slices', methods=['GET']) def get_all_slices(): modality, session_id = request.args.get('modality'), request.args.get('session_id') try: session_folder = get_safe_session_path(session_id) npz_path, pred_path = os.path.join(session_folder, 'processed.npz'), os.path.join(session_folder, 'pred.nii') all_slices = {} if not os.path.exists(npz_path) and modality not in ['pred']: return jsonify({"error": "processed.npz not found"}), 404 if modality in MODALITY_KEYS: with np.load(npz_path) as data: img_data, idx = data['image'], MODALITY_KEYS.index(modality) all_slices[modality] = [array_slice_to_base64(img_data[idx, :, :, i]) for i in range(img_data.shape[3])] elif modality == 'seg': with np.load(npz_path) as data: if 'mask' not in data or not np.any(data['mask']): return jsonify({}), 200 mask = data['mask'] all_slices['gt_wt'] = [array_slice_to_base64(mask[1,:,:,i], is_mask=True, color=[255, 70, 70, 255]) for i in range(mask.shape[3])] all_slices['gt_tc'] = [array_slice_to_base64(mask[0,:,:,i], is_mask=True, color=[70, 255, 70, 255]) for i in range(mask.shape[3])] all_slices['gt_et'] = [array_slice_to_base64(mask[2,:,:,i], is_mask=True, color=[255, 255, 70, 255]) for i in range(mask.shape[3])] elif modality == 'pred': if not os.path.exists(pred_path): return jsonify({"error": "Prediction not found"}), 404 pred_data = nib.load(pred_path).get_fdata() all_slices['wt'] = [array_slice_to_base64(np.isin(pred_data[:,:,i], [1,2,4]), is_mask=True, color=[255, 0, 0, 255]) for i in range(pred_data.shape[2])] all_slices['tc'] = [array_slice_to_base64(np.isin(pred_data[:,:,i], [1,4]), is_mask=True, color=[0, 255, 0, 255]) for i in range(pred_data.shape[2])] all_slices['et'] = [array_slice_to_base64(pred_data[:,:,i] == 4, is_mask=True, color=[0, 0, 255, 255]) for i in range(pred_data.shape[2])] return jsonify(all_slices) except Exception as e: traceback.print_exc() return jsonify({"error": f"Failed to get slices: {str(e)}"}), 500 @app.route('/predict', methods=['POST']) def predict(): start_time = time.time() session_id = request.json.get('session_id') print("\n--- Received Prediction Request ---") try: session_folder = get_safe_session_path(session_id) npz_path = os.path.join(session_folder, 'processed.npz') if not os.path.exists(npz_path): return jsonify({"error": "processed.npz not found"}), 404 with np.load(npz_path) as data: image_data, gt_mask_data = data['image'], data.get('mask') input_tensor = torch.from_numpy(image_data).unsqueeze(0).to(DEVICE) with torch.no_grad(): prediction_logits = model(input_tensor) pred_mask_raw = (torch.sigmoid(prediction_logits).cpu().squeeze(0).float().numpy() > 0.5) pred_mask_postprocessed = remove_small_lesions(pred_mask_raw, {0: 100, 1: 75, 2: 50}) analysis_results = perform_volumetric_analysis(gt_mask_data, pred_mask_postprocessed) label_map = np.zeros(pred_mask_postprocessed[0].shape, dtype=np.uint8) label_map[pred_mask_postprocessed[1] > 0] = 2; label_map[pred_mask_postprocessed[0] > 0] = 1; label_map[pred_mask_postprocessed[2] > 0] = 4 nib.save(nib.Nifti1Image(label_map, np.eye(4)), os.path.join(session_folder, 'pred.nii')) generate_and_save_meshes(session_folder) print(f"--- Prediction finished in {time.time() - start_time:.2f} seconds ---\n") return jsonify({"message": "Prediction successful", "analysis": analysis_results}) except Exception as e: traceback.print_exc() return jsonify({"error": f"Inference failed: {str(e)}"}), 500 @app.route('/get_mesh_json', methods=['GET']) def get_mesh_json(): try: session_folder = get_safe_session_path(request.args.get('session_id')) json_path = os.path.join(session_folder, 'meshes.json') if not os.path.exists(json_path): return jsonify({"error": "meshes.json not found."}), 404 return send_file(json_path, mimetype='application/json') except Exception as e: return jsonify({"error": f"Failed to get mesh data: {str(e)}"}), 500 def array_slice_to_base64(slice_data_2d, is_mask=False, color=[255, 255, 255, 255]): slice_data = np.rot90(slice_data_2d) if is_mask: rgba = np.zeros((*slice_data.shape, 4), dtype=np.uint8) rgba[slice_data > 0] = color pil_img = Image.fromarray(rgba, 'RGBA') else: norm_data = (slice_data / np.max(slice_data) * 255.0) if np.max(slice_data) > 0 else slice_data pil_img = Image.fromarray(norm_data.astype(np.uint8), 'L') buf = io.BytesIO(); pil_img.save(buf, format="PNG") return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode('utf-8') if __name__ == "__main__": port = int(os.environ.get("PORT", 7860)) app.run(host="0.0.0.0", port=port, debug=True)