NeuroHealth1 / model_manager.py
Mikecode123's picture
Upload model_manager.py
a1b7ef0 verified
Raw
History Blame Contribute Delete
23.2 kB
"""
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