Mikecode123 commited on
Commit
a1b7ef0
Β·
verified Β·
1 Parent(s): 85227c7

Upload model_manager.py

Browse files
Files changed (1) hide show
  1. model_manager.py +512 -0
model_manager.py ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Model Manager - Handles multiple CNN models for Alzheimer's (MRI) and
3
+ Parkinson's (DaTscan) classification, with graceful fallback when models
4
+ are not available.
5
+
6
+ PD imaging = DaTscan ONLY:
7
+ - densenet121_parkinsonsDATSCAN.keras (Keras, 2-class)
8
+ - parkinsons_densenet169DATSCAN.keras (Keras, 2-class)
9
+ - parkinsons_densenet201DATSCAN.keras (Keras, 2-class)
10
+ - parkinsons_3dcnnDATSCAN.pth (PyTorch 3D CNN, 2-class)
11
+
12
+ AD imaging = MRI:
13
+ - alzheimers_densenet121.pth
14
+ - alzheimers_densenet169.pth
15
+ - alzheimers_densenet201.pth
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from pathlib import Path
22
+ from typing import Dict, Optional, Tuple, List
23
+ import numpy as np
24
+ import torch
25
+ import torch.nn as nn
26
+ import torchvision.models as tv
27
+ from PIL import Image
28
+ import torchvision.transforms as transforms
29
+ from io import BytesIO
30
+
31
+ logger = logging.getLogger("app.models.model_manager")
32
+
33
+ # ── AD MRI Model configurations ───────────────────────────────────────────────
34
+ MODEL_CONFIGS = {
35
+ # Alzheimer's MRI models (PyTorch .pth)
36
+ "ad_dn121": {
37
+ "name": "Alzheimer's DenseNet121 (MRI)",
38
+ "condition": "alzheimers",
39
+ "imaging_type": "mri",
40
+ "architecture": "densenet121",
41
+ "framework": "pytorch",
42
+ "num_classes": 4,
43
+ "filename": "alzheimers_densenet121.pth",
44
+ "class_names": ["Mild Demented", "Moderate Demented", "Non Demented", "Very Mild Demented"],
45
+ },
46
+ "ad_dn169": {
47
+ "name": "Alzheimer's DenseNet169 (MRI)",
48
+ "condition": "alzheimers",
49
+ "imaging_type": "mri",
50
+ "architecture": "densenet169",
51
+ "framework": "pytorch",
52
+ "num_classes": 4,
53
+ "filename": "alzheimers_densenet169.pth",
54
+ "class_names": ["Mild Demented", "Moderate Demented", "Non Demented", "Very Mild Demented"],
55
+ },
56
+ "ad_dn201": {
57
+ "name": "Alzheimer's DenseNet201 (MRI)",
58
+ "condition": "alzheimers",
59
+ "imaging_type": "mri",
60
+ "architecture": "densenet201",
61
+ "framework": "pytorch",
62
+ "num_classes": 4,
63
+ "filename": "alzheimers_densenet201.pth",
64
+ "class_names": ["Mild Demented", "Moderate Demented", "Non Demented", "Very Mild Demented"],
65
+ },
66
+
67
+ # Parkinson's DaTscan models β€” prefer retrained .pth, fall back to .keras
68
+ "pd_datscan_dn121": {
69
+ "name": "Parkinson's DaTscan DenseNet121",
70
+ "condition": "parkinsons",
71
+ "imaging_type": "datscan",
72
+ "architecture": "densenet121",
73
+ "framework": "pytorch",
74
+ "num_classes": 2,
75
+ # Retrained .pth takes priority; .keras kept as fallback filename
76
+ "filename": "parkinsons_densenet121.pth",
77
+ "filename_fallback": "densenet121_parkinsonsDATSCAN.keras",
78
+ "class_names": ["No Parkinson's", "Parkinson's Disease"],
79
+ },
80
+ "pd_datscan_dn169": {
81
+ "name": "Parkinson's DaTscan DenseNet169",
82
+ "condition": "parkinsons",
83
+ "imaging_type": "datscan",
84
+ "architecture": "densenet169",
85
+ "framework": "pytorch",
86
+ "num_classes": 2,
87
+ "filename": "parkinsons_densenet169.pth",
88
+ "filename_fallback": "parkinsons_densenet169DATSCAN.keras",
89
+ "class_names": ["No Parkinson's", "Parkinson's Disease"],
90
+ },
91
+ "pd_datscan_dn201": {
92
+ "name": "Parkinson's DaTscan DenseNet201",
93
+ "condition": "parkinsons",
94
+ "imaging_type": "datscan",
95
+ "architecture": "densenet201",
96
+ "framework": "pytorch",
97
+ "num_classes": 2,
98
+ "filename": "parkinsons_densenet201.pth",
99
+ "filename_fallback": "parkinsons_densenet201DATSCAN.keras",
100
+ "class_names": ["No Parkinson's", "Parkinson's Disease"],
101
+ },
102
+ "pd_datscan_3dcnn": {
103
+ "name": "Parkinson's DaTscan 3D CNN",
104
+ "condition": "parkinsons",
105
+ "imaging_type": "datscan",
106
+ "architecture": "3dcnn",
107
+ "framework": "pytorch",
108
+ "num_classes": 2,
109
+ "filename": "parkinsons_3dcnnDATSCAN.pth",
110
+ "class_names": ["No Parkinson's", "Parkinson's Disease"],
111
+ "input_3d": True,
112
+ },
113
+ }
114
+
115
+ # ── Ensemble configurations ────────────────────────────────────────────────────
116
+ ENSEMBLE_CONFIGS = {
117
+ "ad_homogeneous": {
118
+ "name": "Alzheimer's MRI Homogeneous Ensemble (DenseNet 121+169+201)",
119
+ "condition": "alzheimers",
120
+ "imaging_type": "mri",
121
+ "models": ["ad_dn121", "ad_dn169", "ad_dn201"],
122
+ "weights": [0.4, 0.3, 0.3],
123
+ },
124
+ "pd_datscan_ensemble": {
125
+ "name": "Parkinson's DaTscan Ensemble (DenseNet 121+169+201)",
126
+ "condition": "parkinsons",
127
+ "imaging_type": "datscan",
128
+ "models": ["pd_datscan_dn121", "pd_datscan_dn169", "pd_datscan_dn201"],
129
+ "weights": [0.4, 0.3, 0.3],
130
+ },
131
+ }
132
+
133
+ # Accepted DaTscan file extensions
134
+ DATSCAN_EXTENSIONS = {".nii", ".gz", ".dcm", ".png", ".jpg", ".jpeg"}
135
+
136
+
137
+ class ModelManager:
138
+ """Manages MRI (AD) and DaTscan (PD) models with graceful fallback."""
139
+
140
+ def __init__(self):
141
+ self.models_dir = Path(__file__).resolve().parent.parent.parent / "saved_models"
142
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
143
+ self.loaded_models: Dict[str, Optional[object]] = {}
144
+ self.model_status: Dict[str, str] = {}
145
+ self.image_transform = self._get_image_transform()
146
+ self._initialize_models()
147
+
148
+ def _get_image_transform(self):
149
+ return transforms.Compose([
150
+ transforms.Resize((224, 224)),
151
+ transforms.ToTensor(),
152
+ transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]),
153
+ ])
154
+
155
+ # ── PyTorch model builder ──────────────────────────────────────────────────
156
+
157
+ def _build_pytorch_model(self, config: dict) -> nn.Module:
158
+ arch = config["architecture"]
159
+ num_classes = config["num_classes"]
160
+
161
+ if arch == "densenet121":
162
+ model = tv.densenet121(weights=None)
163
+ in_features = model.classifier.in_features
164
+ model.classifier = nn.Sequential(
165
+ nn.Linear(in_features, 256), nn.ReLU(), nn.Dropout(0.3),
166
+ nn.Linear(256, num_classes),
167
+ )
168
+ elif arch == "densenet169":
169
+ model = tv.densenet169(weights=None)
170
+ in_features = model.classifier.in_features
171
+ model.classifier = nn.Sequential(
172
+ nn.Linear(in_features, 256), nn.ReLU(), nn.Dropout(0.3),
173
+ nn.Linear(256, num_classes),
174
+ )
175
+ elif arch == "densenet201":
176
+ model = tv.densenet201(weights=None)
177
+ in_features = model.classifier.in_features
178
+ model.classifier = nn.Sequential(
179
+ nn.Linear(in_features, 256), nn.ReLU(), nn.Dropout(0.3),
180
+ nn.Linear(256, num_classes),
181
+ )
182
+ elif arch == "3dcnn":
183
+ model = self._build_3dcnn(num_classes)
184
+ else:
185
+ raise ValueError(f"Unsupported pytorch architecture: {arch}")
186
+ return model
187
+
188
+ def _build_3dcnn(self, num_classes: int = 2) -> nn.Module:
189
+ """Simple 3D CNN for DaTscan volumetric input."""
190
+ class Simple3DCNN(nn.Module):
191
+ def __init__(self, n_classes):
192
+ super().__init__()
193
+ self.features = nn.Sequential(
194
+ nn.Conv3d(1, 32, 3, padding=1), nn.BatchNorm3d(32), nn.ReLU(),
195
+ nn.MaxPool3d(2),
196
+ nn.Conv3d(32, 64, 3, padding=1), nn.BatchNorm3d(64), nn.ReLU(),
197
+ nn.MaxPool3d(2),
198
+ nn.Conv3d(64, 128, 3, padding=1), nn.BatchNorm3d(128), nn.ReLU(),
199
+ nn.AdaptiveAvgPool3d((4, 4, 4)),
200
+ )
201
+ self.classifier = nn.Sequential(
202
+ nn.Flatten(),
203
+ nn.Linear(128 * 4 * 4 * 4, 256), nn.ReLU(), nn.Dropout(0.4),
204
+ nn.Linear(256, n_classes),
205
+ )
206
+ def forward(self, x):
207
+ return self.classifier(self.features(x))
208
+ return Simple3DCNN(num_classes)
209
+
210
+ # ── Keras model loader ─────────────────────────────────────────────────────
211
+
212
+ def _load_keras_model(self, model_key: str) -> Tuple[Optional[object], str]:
213
+ config = MODEL_CONFIGS[model_key]
214
+ model_path = self.models_dir / config["filename"]
215
+ if not model_path.exists():
216
+ logger.warning("Keras model file not found: %s", model_path)
217
+ return None, "Model file not found"
218
+ try:
219
+ import os
220
+ os.environ["TF_USE_LEGACY_KERAS"] = "1"
221
+ import tensorflow as tf
222
+ model = tf.keras.models.load_model(str(model_path), compile=False)
223
+ logger.info("Loaded Keras model: %s", config["name"])
224
+ return model, "Active"
225
+ except Exception as e:
226
+ logger.error("Failed to load Keras model %s: %s", config["name"], e)
227
+ return None, f"Load error: {str(e)}"
228
+
229
+ # ── PyTorch model loader ───────────────────────────────────────────────────
230
+
231
+ def _load_pytorch_model(self, model_key: str) -> Tuple[Optional[nn.Module], str]:
232
+ config = MODEL_CONFIGS[model_key]
233
+ model_path = self.models_dir / config["filename"]
234
+
235
+ # If primary .pth not found, try fallback (old .keras β†’ skip, just report missing)
236
+ if not model_path.exists():
237
+ fallback = config.get("filename_fallback")
238
+ if fallback:
239
+ fallback_path = self.models_dir / fallback
240
+ if fallback_path.exists() and fallback_path.suffix in (".keras", ".h5"):
241
+ # Keras fallback β€” delegate to keras loader
242
+ return self._load_keras_model_from_path(config, fallback_path)
243
+ logger.warning("Model file not found: %s", model_path)
244
+ return None, "Model file not found"
245
+ try:
246
+ model = self._build_pytorch_model(config)
247
+ state_dict = torch.load(str(model_path), map_location=self.device)
248
+ if isinstance(state_dict, dict) and "model_state_dict" in state_dict:
249
+ state_dict = state_dict["model_state_dict"]
250
+ # strict=False allows loading models whose classifier head differs slightly
251
+ model.load_state_dict(state_dict, strict=False)
252
+ model.to(self.device)
253
+ model.eval()
254
+ logger.info("Loaded PyTorch model: %s", config["name"])
255
+ return model, "Active"
256
+ except Exception as e:
257
+ logger.error("Failed to load PyTorch model %s: %s", config["name"], e)
258
+ return None, f"Load error: {str(e)}"
259
+
260
+ def _load_keras_model_from_path(self, config: dict, model_path: Path) -> Tuple[Optional[object], str]:
261
+ try:
262
+ import os
263
+ os.environ["TF_USE_LEGACY_KERAS"] = "1"
264
+ import tensorflow as tf
265
+ model = tf.keras.models.load_model(str(model_path), compile=False)
266
+ logger.info("Loaded Keras fallback model: %s", config["name"])
267
+ return model, "Active (Keras fallback)"
268
+ except Exception as e:
269
+ logger.error("Failed to load Keras fallback %s: %s", config["name"], e)
270
+ return None, f"Load error: {str(e)}"
271
+
272
+ def _initialize_models(self):
273
+ logger.info("Initializing model manager (AD-MRI + PD-DaTscan)...")
274
+ for model_key, config in MODEL_CONFIGS.items():
275
+ # All models now use PyTorch; Keras fallback handled inside _load_pytorch_model
276
+ model, status = self._load_pytorch_model(model_key)
277
+ self.loaded_models[model_key] = model
278
+ self.model_status[model_key] = status
279
+ logger.info("Model initialization complete")
280
+
281
+ # ── Availability ───────────────────────────────────────────────────────────
282
+
283
+ def get_available_models(self, condition: str = None) -> List[dict]:
284
+ available = []
285
+ for model_key, config in MODEL_CONFIGS.items():
286
+ if condition and config["condition"] != condition:
287
+ continue
288
+ if self.model_status.get(model_key) == "Active":
289
+ available.append({
290
+ "key": model_key,
291
+ "name": config["name"],
292
+ "condition": config["condition"],
293
+ "imaging_type": config.get("imaging_type", "mri"),
294
+ "architecture": config["architecture"],
295
+ "framework": config.get("framework", "pytorch"),
296
+ "status": "Active",
297
+ })
298
+ return available
299
+
300
+ def get_model_status(self) -> Dict[str, str]:
301
+ return self.model_status.copy()
302
+
303
+ # ── PyTorch image prediction (AD MRI) ─────────────────────────────────────
304
+
305
+ def predict_image(self, model_key: str, image_bytes: bytes, filename: str = "") -> dict:
306
+ """Make prediction using a PyTorch model on standard image bytes."""
307
+ if model_key not in MODEL_CONFIGS:
308
+ return {"error": f"Unknown model: {model_key}"}
309
+
310
+ config = MODEL_CONFIGS[model_key]
311
+
312
+ # Validate DaTscan extensions
313
+ if config.get("imaging_type") == "datscan" and filename:
314
+ ext = Path(filename).suffix.lower()
315
+ # .nii.gz has compound suffix
316
+ if filename.endswith(".nii.gz"):
317
+ ext = ".nii.gz"
318
+ if ext not in DATSCAN_EXTENSIONS and ext != ".nii.gz":
319
+ return {"error": f"Invalid file type '{ext}' for DaTscan analysis. Accepted: .nii, .nii.gz, .dcm, .png, .jpg"}
320
+
321
+ # 3D CNN needs special handling
322
+ if config.get("input_3d"):
323
+ return self.predict_3dcnn(model_key, image_bytes, filename)
324
+
325
+ model = self.loaded_models.get(model_key)
326
+ if model is None:
327
+ return {"error": f"Model {model_key} is not available"}
328
+
329
+ try:
330
+ image = Image.open(BytesIO(image_bytes)).convert("RGB")
331
+ inputs = self.image_transform(image).unsqueeze(0).to(self.device)
332
+
333
+ with torch.no_grad():
334
+ outputs = model(inputs)
335
+ probs = torch.softmax(outputs, dim=1)
336
+ pred_class = torch.argmax(probs, dim=1).item()
337
+ confidence = float(probs[0][pred_class].item())
338
+
339
+ return {
340
+ "model_key": model_key,
341
+ "model_name": config["name"],
342
+ "condition": config["condition"],
343
+ "imaging_type": config.get("imaging_type", "mri"),
344
+ "prediction": pred_class,
345
+ "confidence": confidence,
346
+ "class_name": config["class_names"][pred_class],
347
+ "all_probabilities": {
348
+ cn: float(p) for cn, p in zip(config["class_names"], probs[0].cpu().numpy())
349
+ },
350
+ "status": "success",
351
+ }
352
+ except Exception as e:
353
+ logger.error("PyTorch prediction failed for %s: %s", model_key, e)
354
+ return {"error": f"Prediction failed: {str(e)}"}
355
+
356
+ # ── Keras image prediction (PD DaTscan DenseNet) ──────────────────────────
357
+
358
+ def predict_keras_image(self, model_key: str, image_bytes: bytes, filename: str = "") -> dict:
359
+ """Run a Keras DaTscan model on 2D image/slice bytes."""
360
+ if model_key not in MODEL_CONFIGS:
361
+ return {"error": f"Unknown model: {model_key}"}
362
+
363
+ config = MODEL_CONFIGS[model_key]
364
+
365
+ # Extension check
366
+ if filename:
367
+ ext = Path(filename).suffix.lower()
368
+ fname_lower = filename.lower()
369
+ if fname_lower.endswith(".nii.gz"):
370
+ ext = ".nii.gz"
371
+ if ext not in DATSCAN_EXTENSIONS:
372
+ return {"error": f"Invalid file type '{ext}' for DaTscan. Accepted: .nii, .nii.gz, .dcm, .png, .jpg"}
373
+
374
+ model = self.loaded_models.get(model_key)
375
+ if model is None:
376
+ return {"error": f"Keras model {model_key} is not available"}
377
+
378
+ try:
379
+ from app.preprocessing.datscan_preprocessor import DaTscanPreprocessor
380
+ preprocessor = DaTscanPreprocessor()
381
+ img_array = preprocessor.preprocess_2d(image_bytes, filename) # (224, 224, 3) float32
382
+
383
+ import numpy as _np
384
+ batch = _np.expand_dims(img_array, 0) # (1, 224, 224, 3)
385
+ preds = model.predict(batch, verbose=0) # (1, num_classes)
386
+ probs = preds[0]
387
+ pred_class = int(_np.argmax(probs))
388
+ confidence = float(probs[pred_class])
389
+
390
+ return {
391
+ "model_key": model_key,
392
+ "model_name": config["name"],
393
+ "condition": config["condition"],
394
+ "imaging_type": "datscan",
395
+ "prediction": pred_class,
396
+ "confidence": confidence,
397
+ "class_name": config["class_names"][pred_class],
398
+ "all_probabilities": {
399
+ cn: float(p) for cn, p in zip(config["class_names"], probs)
400
+ },
401
+ "status": "success",
402
+ }
403
+ except Exception as e:
404
+ logger.error("Keras DaTscan prediction failed for %s: %s", model_key, e)
405
+ return {"error": f"DaTscan prediction failed: {str(e)}"}
406
+
407
+ # ── 3D CNN prediction (PD DaTscan volumetric) ─────────────────────────────
408
+
409
+ def predict_3dcnn(self, model_key: str, volume_bytes: bytes, filename: str = "") -> dict:
410
+ """Run the 3D CNN on a NIfTI volume (.nii or .nii.gz required)."""
411
+ if model_key not in MODEL_CONFIGS:
412
+ return {"error": f"Unknown model: {model_key}"}
413
+
414
+ config = MODEL_CONFIGS[model_key]
415
+ fname_lower = (filename or "").lower()
416
+ if not (fname_lower.endswith(".nii") or fname_lower.endswith(".nii.gz")):
417
+ return {"error": "3D CNN requires a NIfTI file (.nii or .nii.gz)."}
418
+
419
+ model = self.loaded_models.get(model_key)
420
+ if model is None:
421
+ return {"error": f"3D CNN model {model_key} is not available"}
422
+
423
+ try:
424
+ from app.preprocessing.datscan_preprocessor import DaTscanPreprocessor
425
+ preprocessor = DaTscanPreprocessor()
426
+ volume_tensor = preprocessor.preprocess_3d(volume_bytes, filename) # (1, 1, D, H, W)
427
+ volume_tensor = volume_tensor.to(self.device)
428
+
429
+ with torch.no_grad():
430
+ outputs = model(volume_tensor)
431
+ probs = torch.softmax(outputs, dim=1)
432
+ pred_class = int(torch.argmax(probs, dim=1).item())
433
+ confidence = float(probs[0][pred_class].item())
434
+
435
+ return {
436
+ "model_key": model_key,
437
+ "model_name": config["name"],
438
+ "condition": config["condition"],
439
+ "imaging_type": "datscan",
440
+ "prediction": pred_class,
441
+ "confidence": confidence,
442
+ "class_name": config["class_names"][pred_class],
443
+ "all_probabilities": {
444
+ cn: float(p) for cn, p in zip(config["class_names"], probs[0].cpu().numpy())
445
+ },
446
+ "status": "success",
447
+ }
448
+ except Exception as e:
449
+ logger.error("3D CNN prediction failed for %s: %s", model_key, e)
450
+ return {"error": f"3D CNN prediction failed: {str(e)}"}
451
+
452
+ # ── Ensemble prediction ────────────────────────────────────────────────────
453
+
454
+ def predict_ensemble(self, ensemble_key: str, image_bytes: bytes, filename: str = "") -> dict:
455
+ if ensemble_key not in ENSEMBLE_CONFIGS:
456
+ return {"error": f"Unknown ensemble: {ensemble_key}"}
457
+
458
+ ensemble_config = ENSEMBLE_CONFIGS[ensemble_key]
459
+ model_predictions = []
460
+ weights = ensemble_config["weights"]
461
+
462
+ for model_key in ensemble_config["models"]:
463
+ result = self.predict_image(model_key, image_bytes, filename)
464
+ if "error" not in result:
465
+ model_predictions.append(result)
466
+
467
+ if not model_predictions:
468
+ return {"error": "No models available in ensemble"}
469
+
470
+ if len(weights) != len(model_predictions):
471
+ weights = [1.0 / len(model_predictions)] * len(model_predictions)
472
+
473
+ combined_probs: Dict[str, float] = {}
474
+ total_weight = 0.0
475
+ for pred, weight in zip(model_predictions, weights):
476
+ total_weight += weight
477
+ for class_name, prob in pred["all_probabilities"].items():
478
+ combined_probs[class_name] = combined_probs.get(class_name, 0) + prob * weight
479
+
480
+ for cn in combined_probs:
481
+ combined_probs[cn] /= total_weight
482
+
483
+ final_class = max(combined_probs, key=lambda x: combined_probs[x])
484
+ final_confidence = combined_probs[final_class]
485
+ first_config = MODEL_CONFIGS[ensemble_config["models"][0]]
486
+
487
+ return {
488
+ "ensemble_key": ensemble_key,
489
+ "ensemble_name": ensemble_config["name"],
490
+ "condition": ensemble_config["condition"],
491
+ "imaging_type": ensemble_config.get("imaging_type", "mri"),
492
+ "prediction": first_config["class_names"].index(final_class),
493
+ "confidence": final_confidence,
494
+ "class_name": final_class,
495
+ "all_probabilities": combined_probs,
496
+ "model_contributions": [
497
+ {"model": p["model_name"], "weight": w, "confidence": p["confidence"]}
498
+ for p, w in zip(model_predictions, weights)
499
+ ],
500
+ "status": "success",
501
+ }
502
+
503
+
504
+ # ── Singleton ──────────────────────────────────────────────────────────────────
505
+ _model_manager: Optional[ModelManager] = None
506
+
507
+
508
+ def get_model_manager() -> ModelManager:
509
+ global _model_manager
510
+ if _model_manager is None:
511
+ _model_manager = ModelManager()
512
+ return _model_manager