Spaces:
Runtime error
Runtime error
| """ | |
| Model Manager - Handles multiple CNN models for Alzheimer's (MRI) and | |
| Parkinson's (DaTscan) classification, with graceful fallback when models | |
| are not available. | |
| PD imaging = DaTscan ONLY: | |
| - densenet121_parkinsonsDATSCAN.keras (Keras, 2-class) | |
| - parkinsons_densenet169DATSCAN.keras (Keras, 2-class) | |
| - parkinsons_densenet201DATSCAN.keras (Keras, 2-class) | |
| - parkinsons_3dcnnDATSCAN.pth (PyTorch 3D CNN, 2-class) | |
| AD imaging = MRI: | |
| - alzheimers_densenet121.pth | |
| - alzheimers_densenet169.pth | |
| - alzheimers_densenet201.pth | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from pathlib import Path | |
| from typing import Dict, Optional, Tuple, List | |
| import numpy as np | |
| import torch | |
| import torch.nn as nn | |
| import torchvision.models as tv | |
| from PIL import Image | |
| import torchvision.transforms as transforms | |
| from io import BytesIO | |
| logger = logging.getLogger("app.models.model_manager") | |
| # ββ AD MRI Model configurations βββββββββββββββββββββββββββββββββββββββββββββββ | |
| MODEL_CONFIGS = { | |
| # Alzheimer's MRI models (PyTorch .pth) | |
| "ad_dn121": { | |
| "name": "Alzheimer's DenseNet121 (MRI)", | |
| "condition": "alzheimers", | |
| "imaging_type": "mri", | |
| "architecture": "densenet121", | |
| "framework": "pytorch", | |
| "num_classes": 4, | |
| "filename": "alzheimers_densenet121.pth", | |
| "class_names": ["Mild Demented", "Moderate Demented", "Non Demented", "Very Mild Demented"], | |
| }, | |
| "ad_dn169": { | |
| "name": "Alzheimer's DenseNet169 (MRI)", | |
| "condition": "alzheimers", | |
| "imaging_type": "mri", | |
| "architecture": "densenet169", | |
| "framework": "pytorch", | |
| "num_classes": 4, | |
| "filename": "alzheimers_densenet169.pth", | |
| "class_names": ["Mild Demented", "Moderate Demented", "Non Demented", "Very Mild Demented"], | |
| }, | |
| "ad_dn201": { | |
| "name": "Alzheimer's DenseNet201 (MRI)", | |
| "condition": "alzheimers", | |
| "imaging_type": "mri", | |
| "architecture": "densenet201", | |
| "framework": "pytorch", | |
| "num_classes": 4, | |
| "filename": "alzheimers_densenet201.pth", | |
| "class_names": ["Mild Demented", "Moderate Demented", "Non Demented", "Very Mild Demented"], | |
| }, | |
| # Parkinson's DaTscan models β prefer retrained .pth, fall back to .keras | |
| "pd_datscan_dn121": { | |
| "name": "Parkinson's DaTscan DenseNet121", | |
| "condition": "parkinsons", | |
| "imaging_type": "datscan", | |
| "architecture": "densenet121", | |
| "framework": "pytorch", | |
| "num_classes": 2, | |
| # Retrained .pth takes priority; .keras kept as fallback filename | |
| "filename": "parkinsons_densenet121.pth", | |
| "filename_fallback": "densenet121_parkinsonsDATSCAN.keras", | |
| "class_names": ["No Parkinson's", "Parkinson's Disease"], | |
| }, | |
| "pd_datscan_dn169": { | |
| "name": "Parkinson's DaTscan DenseNet169", | |
| "condition": "parkinsons", | |
| "imaging_type": "datscan", | |
| "architecture": "densenet169", | |
| "framework": "pytorch", | |
| "num_classes": 2, | |
| "filename": "parkinsons_densenet169.pth", | |
| "filename_fallback": "parkinsons_densenet169DATSCAN.keras", | |
| "class_names": ["No Parkinson's", "Parkinson's Disease"], | |
| }, | |
| "pd_datscan_dn201": { | |
| "name": "Parkinson's DaTscan DenseNet201", | |
| "condition": "parkinsons", | |
| "imaging_type": "datscan", | |
| "architecture": "densenet201", | |
| "framework": "pytorch", | |
| "num_classes": 2, | |
| "filename": "parkinsons_densenet201.pth", | |
| "filename_fallback": "parkinsons_densenet201DATSCAN.keras", | |
| "class_names": ["No Parkinson's", "Parkinson's Disease"], | |
| }, | |
| "pd_datscan_3dcnn": { | |
| "name": "Parkinson's DaTscan 3D CNN", | |
| "condition": "parkinsons", | |
| "imaging_type": "datscan", | |
| "architecture": "3dcnn", | |
| "framework": "pytorch", | |
| "num_classes": 2, | |
| "filename": "parkinsons_3dcnnDATSCAN.pth", | |
| "class_names": ["No Parkinson's", "Parkinson's Disease"], | |
| "input_3d": True, | |
| }, | |
| } | |
| # ββ Ensemble configurations ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ENSEMBLE_CONFIGS = { | |
| "ad_homogeneous": { | |
| "name": "Alzheimer's MRI Homogeneous Ensemble (DenseNet 121+169+201)", | |
| "condition": "alzheimers", | |
| "imaging_type": "mri", | |
| "models": ["ad_dn121", "ad_dn169", "ad_dn201"], | |
| "weights": [0.4, 0.3, 0.3], | |
| }, | |
| "pd_datscan_ensemble": { | |
| "name": "Parkinson's DaTscan Ensemble (DenseNet 121+169+201)", | |
| "condition": "parkinsons", | |
| "imaging_type": "datscan", | |
| "models": ["pd_datscan_dn121", "pd_datscan_dn169", "pd_datscan_dn201"], | |
| "weights": [0.4, 0.3, 0.3], | |
| }, | |
| } | |
| # Accepted DaTscan file extensions | |
| DATSCAN_EXTENSIONS = {".nii", ".gz", ".dcm", ".png", ".jpg", ".jpeg"} | |
| class ModelManager: | |
| """Manages MRI (AD) and DaTscan (PD) models with graceful fallback.""" | |
| def __init__(self): | |
| self.models_dir = Path(__file__).resolve().parent.parent.parent / "saved_models" | |
| self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| self.loaded_models: Dict[str, Optional[object]] = {} | |
| self.model_status: Dict[str, str] = {} | |
| self.image_transform = self._get_image_transform() | |
| self._initialize_models() | |
| def _get_image_transform(self): | |
| return transforms.Compose([ | |
| transforms.Resize((224, 224)), | |
| transforms.ToTensor(), | |
| transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]), | |
| ]) | |
| # ββ PyTorch model builder ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _build_pytorch_model(self, config: dict) -> nn.Module: | |
| arch = config["architecture"] | |
| num_classes = config["num_classes"] | |
| if arch == "densenet121": | |
| model = tv.densenet121(weights=None) | |
| in_features = model.classifier.in_features | |
| model.classifier = nn.Sequential( | |
| nn.Linear(in_features, 256), nn.ReLU(), nn.Dropout(0.3), | |
| nn.Linear(256, num_classes), | |
| ) | |
| elif arch == "densenet169": | |
| model = tv.densenet169(weights=None) | |
| in_features = model.classifier.in_features | |
| model.classifier = nn.Sequential( | |
| nn.Linear(in_features, 256), nn.ReLU(), nn.Dropout(0.3), | |
| nn.Linear(256, num_classes), | |
| ) | |
| elif arch == "densenet201": | |
| model = tv.densenet201(weights=None) | |
| in_features = model.classifier.in_features | |
| model.classifier = nn.Sequential( | |
| nn.Linear(in_features, 256), nn.ReLU(), nn.Dropout(0.3), | |
| nn.Linear(256, num_classes), | |
| ) | |
| elif arch == "3dcnn": | |
| model = self._build_3dcnn(num_classes) | |
| else: | |
| raise ValueError(f"Unsupported pytorch architecture: {arch}") | |
| return model | |
| def _build_3dcnn(self, num_classes: int = 2) -> nn.Module: | |
| """Simple 3D CNN for DaTscan volumetric input.""" | |
| class Simple3DCNN(nn.Module): | |
| def __init__(self, n_classes): | |
| super().__init__() | |
| self.features = nn.Sequential( | |
| nn.Conv3d(1, 32, 3, padding=1), nn.BatchNorm3d(32), nn.ReLU(), | |
| nn.MaxPool3d(2), | |
| nn.Conv3d(32, 64, 3, padding=1), nn.BatchNorm3d(64), nn.ReLU(), | |
| nn.MaxPool3d(2), | |
| nn.Conv3d(64, 128, 3, padding=1), nn.BatchNorm3d(128), nn.ReLU(), | |
| nn.AdaptiveAvgPool3d((4, 4, 4)), | |
| ) | |
| self.classifier = nn.Sequential( | |
| nn.Flatten(), | |
| nn.Linear(128 * 4 * 4 * 4, 256), nn.ReLU(), nn.Dropout(0.4), | |
| nn.Linear(256, n_classes), | |
| ) | |
| def forward(self, x): | |
| return self.classifier(self.features(x)) | |
| return Simple3DCNN(num_classes) | |
| # ββ Keras model loader βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _load_keras_model(self, model_key: str) -> Tuple[Optional[object], str]: | |
| config = MODEL_CONFIGS[model_key] | |
| model_path = self.models_dir / config["filename"] | |
| if not model_path.exists(): | |
| logger.warning("Keras model file not found: %s", model_path) | |
| return None, "Model file not found" | |
| try: | |
| import os | |
| os.environ["TF_USE_LEGACY_KERAS"] = "1" | |
| import tensorflow as tf | |
| model = tf.keras.models.load_model(str(model_path), compile=False) | |
| logger.info("Loaded Keras model: %s", config["name"]) | |
| return model, "Active" | |
| except Exception as e: | |
| logger.error("Failed to load Keras model %s: %s", config["name"], e) | |
| return None, f"Load error: {str(e)}" | |
| # ββ PyTorch model loader βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _load_pytorch_model(self, model_key: str) -> Tuple[Optional[nn.Module], str]: | |
| config = MODEL_CONFIGS[model_key] | |
| model_path = self.models_dir / config["filename"] | |
| # If primary .pth not found, try fallback (old .keras β skip, just report missing) | |
| if not model_path.exists(): | |
| fallback = config.get("filename_fallback") | |
| if fallback: | |
| fallback_path = self.models_dir / fallback | |
| if fallback_path.exists() and fallback_path.suffix in (".keras", ".h5"): | |
| # Keras fallback β delegate to keras loader | |
| return self._load_keras_model_from_path(config, fallback_path) | |
| logger.warning("Model file not found: %s", model_path) | |
| return None, "Model file not found" | |
| try: | |
| model = self._build_pytorch_model(config) | |
| state_dict = torch.load(str(model_path), map_location=self.device) | |
| if isinstance(state_dict, dict) and "model_state_dict" in state_dict: | |
| state_dict = state_dict["model_state_dict"] | |
| # strict=False allows loading models whose classifier head differs slightly | |
| model.load_state_dict(state_dict, strict=False) | |
| model.to(self.device) | |
| model.eval() | |
| logger.info("Loaded PyTorch model: %s", config["name"]) | |
| return model, "Active" | |
| except Exception as e: | |
| logger.error("Failed to load PyTorch model %s: %s", config["name"], e) | |
| return None, f"Load error: {str(e)}" | |
| def _load_keras_model_from_path(self, config: dict, model_path: Path) -> Tuple[Optional[object], str]: | |
| try: | |
| import os | |
| os.environ["TF_USE_LEGACY_KERAS"] = "1" | |
| import tensorflow as tf | |
| model = tf.keras.models.load_model(str(model_path), compile=False) | |
| logger.info("Loaded Keras fallback model: %s", config["name"]) | |
| return model, "Active (Keras fallback)" | |
| except Exception as e: | |
| logger.error("Failed to load Keras fallback %s: %s", config["name"], e) | |
| return None, f"Load error: {str(e)}" | |
| def _initialize_models(self): | |
| logger.info("Initializing model manager (AD-MRI + PD-DaTscan)...") | |
| for model_key, config in MODEL_CONFIGS.items(): | |
| # All models now use PyTorch; Keras fallback handled inside _load_pytorch_model | |
| model, status = self._load_pytorch_model(model_key) | |
| self.loaded_models[model_key] = model | |
| self.model_status[model_key] = status | |
| logger.info("Model initialization complete") | |
| # ββ Availability βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_available_models(self, condition: str = None) -> List[dict]: | |
| available = [] | |
| for model_key, config in MODEL_CONFIGS.items(): | |
| if condition and config["condition"] != condition: | |
| continue | |
| if self.model_status.get(model_key) == "Active": | |
| available.append({ | |
| "key": model_key, | |
| "name": config["name"], | |
| "condition": config["condition"], | |
| "imaging_type": config.get("imaging_type", "mri"), | |
| "architecture": config["architecture"], | |
| "framework": config.get("framework", "pytorch"), | |
| "status": "Active", | |
| }) | |
| return available | |
| def get_model_status(self) -> Dict[str, str]: | |
| return self.model_status.copy() | |
| # ββ PyTorch image prediction (AD MRI) βββββββββββββββββββββββββββββββββββββ | |
| def predict_image(self, model_key: str, image_bytes: bytes, filename: str = "") -> dict: | |
| """Make prediction using a PyTorch model on standard image bytes.""" | |
| if model_key not in MODEL_CONFIGS: | |
| return {"error": f"Unknown model: {model_key}"} | |
| config = MODEL_CONFIGS[model_key] | |
| # Validate DaTscan extensions | |
| if config.get("imaging_type") == "datscan" and filename: | |
| ext = Path(filename).suffix.lower() | |
| # .nii.gz has compound suffix | |
| if filename.endswith(".nii.gz"): | |
| ext = ".nii.gz" | |
| if ext not in DATSCAN_EXTENSIONS and ext != ".nii.gz": | |
| return {"error": f"Invalid file type '{ext}' for DaTscan analysis. Accepted: .nii, .nii.gz, .dcm, .png, .jpg"} | |
| # 3D CNN needs special handling | |
| if config.get("input_3d"): | |
| return self.predict_3dcnn(model_key, image_bytes, filename) | |
| model = self.loaded_models.get(model_key) | |
| if model is None: | |
| return {"error": f"Model {model_key} is not available"} | |
| try: | |
| image = Image.open(BytesIO(image_bytes)).convert("RGB") | |
| inputs = self.image_transform(image).unsqueeze(0).to(self.device) | |
| with torch.no_grad(): | |
| outputs = model(inputs) | |
| probs = torch.softmax(outputs, dim=1) | |
| pred_class = torch.argmax(probs, dim=1).item() | |
| confidence = float(probs[0][pred_class].item()) | |
| return { | |
| "model_key": model_key, | |
| "model_name": config["name"], | |
| "condition": config["condition"], | |
| "imaging_type": config.get("imaging_type", "mri"), | |
| "prediction": pred_class, | |
| "confidence": confidence, | |
| "class_name": config["class_names"][pred_class], | |
| "all_probabilities": { | |
| cn: float(p) for cn, p in zip(config["class_names"], probs[0].cpu().numpy()) | |
| }, | |
| "status": "success", | |
| } | |
| except Exception as e: | |
| logger.error("PyTorch prediction failed for %s: %s", model_key, e) | |
| return {"error": f"Prediction failed: {str(e)}"} | |
| # ββ Keras image prediction (PD DaTscan DenseNet) ββββββββββββββββββββββββββ | |
| def predict_keras_image(self, model_key: str, image_bytes: bytes, filename: str = "") -> dict: | |
| """Run a Keras DaTscan model on 2D image/slice bytes.""" | |
| if model_key not in MODEL_CONFIGS: | |
| return {"error": f"Unknown model: {model_key}"} | |
| config = MODEL_CONFIGS[model_key] | |
| # Extension check | |
| if filename: | |
| ext = Path(filename).suffix.lower() | |
| fname_lower = filename.lower() | |
| if fname_lower.endswith(".nii.gz"): | |
| ext = ".nii.gz" | |
| if ext not in DATSCAN_EXTENSIONS: | |
| return {"error": f"Invalid file type '{ext}' for DaTscan. Accepted: .nii, .nii.gz, .dcm, .png, .jpg"} | |
| model = self.loaded_models.get(model_key) | |
| if model is None: | |
| return {"error": f"Keras model {model_key} is not available"} | |
| try: | |
| from app.preprocessing.datscan_preprocessor import DaTscanPreprocessor | |
| preprocessor = DaTscanPreprocessor() | |
| img_array = preprocessor.preprocess_2d(image_bytes, filename) # (224, 224, 3) float32 | |
| import numpy as _np | |
| batch = _np.expand_dims(img_array, 0) # (1, 224, 224, 3) | |
| preds = model.predict(batch, verbose=0) # (1, num_classes) | |
| probs = preds[0] | |
| pred_class = int(_np.argmax(probs)) | |
| confidence = float(probs[pred_class]) | |
| return { | |
| "model_key": model_key, | |
| "model_name": config["name"], | |
| "condition": config["condition"], | |
| "imaging_type": "datscan", | |
| "prediction": pred_class, | |
| "confidence": confidence, | |
| "class_name": config["class_names"][pred_class], | |
| "all_probabilities": { | |
| cn: float(p) for cn, p in zip(config["class_names"], probs) | |
| }, | |
| "status": "success", | |
| } | |
| except Exception as e: | |
| logger.error("Keras DaTscan prediction failed for %s: %s", model_key, e) | |
| return {"error": f"DaTscan prediction failed: {str(e)}"} | |
| # ββ 3D CNN prediction (PD DaTscan volumetric) βββββββββββββββββββββββββββββ | |
| def predict_3dcnn(self, model_key: str, volume_bytes: bytes, filename: str = "") -> dict: | |
| """Run the 3D CNN on a NIfTI volume (.nii or .nii.gz required).""" | |
| if model_key not in MODEL_CONFIGS: | |
| return {"error": f"Unknown model: {model_key}"} | |
| config = MODEL_CONFIGS[model_key] | |
| fname_lower = (filename or "").lower() | |
| if not (fname_lower.endswith(".nii") or fname_lower.endswith(".nii.gz")): | |
| return {"error": "3D CNN requires a NIfTI file (.nii or .nii.gz)."} | |
| model = self.loaded_models.get(model_key) | |
| if model is None: | |
| return {"error": f"3D CNN model {model_key} is not available"} | |
| try: | |
| from app.preprocessing.datscan_preprocessor import DaTscanPreprocessor | |
| preprocessor = DaTscanPreprocessor() | |
| volume_tensor = preprocessor.preprocess_3d(volume_bytes, filename) # (1, 1, D, H, W) | |
| volume_tensor = volume_tensor.to(self.device) | |
| with torch.no_grad(): | |
| outputs = model(volume_tensor) | |
| probs = torch.softmax(outputs, dim=1) | |
| pred_class = int(torch.argmax(probs, dim=1).item()) | |
| confidence = float(probs[0][pred_class].item()) | |
| return { | |
| "model_key": model_key, | |
| "model_name": config["name"], | |
| "condition": config["condition"], | |
| "imaging_type": "datscan", | |
| "prediction": pred_class, | |
| "confidence": confidence, | |
| "class_name": config["class_names"][pred_class], | |
| "all_probabilities": { | |
| cn: float(p) for cn, p in zip(config["class_names"], probs[0].cpu().numpy()) | |
| }, | |
| "status": "success", | |
| } | |
| except Exception as e: | |
| logger.error("3D CNN prediction failed for %s: %s", model_key, e) | |
| return {"error": f"3D CNN prediction failed: {str(e)}"} | |
| # ββ Ensemble prediction ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def predict_ensemble(self, ensemble_key: str, image_bytes: bytes, filename: str = "") -> dict: | |
| if ensemble_key not in ENSEMBLE_CONFIGS: | |
| return {"error": f"Unknown ensemble: {ensemble_key}"} | |
| ensemble_config = ENSEMBLE_CONFIGS[ensemble_key] | |
| model_predictions = [] | |
| weights = ensemble_config["weights"] | |
| for model_key in ensemble_config["models"]: | |
| result = self.predict_image(model_key, image_bytes, filename) | |
| if "error" not in result: | |
| model_predictions.append(result) | |
| if not model_predictions: | |
| return {"error": "No models available in ensemble"} | |
| if len(weights) != len(model_predictions): | |
| weights = [1.0 / len(model_predictions)] * len(model_predictions) | |
| combined_probs: Dict[str, float] = {} | |
| total_weight = 0.0 | |
| for pred, weight in zip(model_predictions, weights): | |
| total_weight += weight | |
| for class_name, prob in pred["all_probabilities"].items(): | |
| combined_probs[class_name] = combined_probs.get(class_name, 0) + prob * weight | |
| for cn in combined_probs: | |
| combined_probs[cn] /= total_weight | |
| final_class = max(combined_probs, key=lambda x: combined_probs[x]) | |
| final_confidence = combined_probs[final_class] | |
| first_config = MODEL_CONFIGS[ensemble_config["models"][0]] | |
| return { | |
| "ensemble_key": ensemble_key, | |
| "ensemble_name": ensemble_config["name"], | |
| "condition": ensemble_config["condition"], | |
| "imaging_type": ensemble_config.get("imaging_type", "mri"), | |
| "prediction": first_config["class_names"].index(final_class), | |
| "confidence": final_confidence, | |
| "class_name": final_class, | |
| "all_probabilities": combined_probs, | |
| "model_contributions": [ | |
| {"model": p["model_name"], "weight": w, "confidence": p["confidence"]} | |
| for p, w in zip(model_predictions, weights) | |
| ], | |
| "status": "success", | |
| } | |
| # ββ Singleton ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _model_manager: Optional[ModelManager] = None | |
| def get_model_manager() -> ModelManager: | |
| global _model_manager | |
| if _model_manager is None: | |
| _model_manager = ModelManager() | |
| return _model_manager |