Files changed (1) hide show
  1. app.py +222 -57
app.py CHANGED
@@ -1,24 +1,34 @@
1
  """
2
- app.py β€” BrainScan AI (Gradio Space, standalone)
3
- =================================================
4
- Aplikasi Gradio mandiri untuk model Hybrid EfficientNet-B3 + Custom ViT
5
- dari repo: Marksnb/brain-hybrid-efficientnet-vit
 
 
 
6
 
7
  Alur:
8
- 1. Download checkpoint (.pth) dari Hugging Face Hub saat startup
9
- 2. Definisikan arsitektur model (identik dengan classifier_model.py asli)
10
- 3. Preprocessing gambar sama seperti saat training (Resize 224 + ImageNet norm)
11
- 4. Inference -> probabilitas 5 kelas penyakit otak
12
- 5. Generate attention heatmap (ViT attention block terakhir) sebagai
13
- visualisasi "area yang difokuskan model" (Explainable AI ringan)
 
 
 
 
 
14
 
15
  Jalankan lokal:
16
- pip install gradio torch torchvision huggingface_hub pillow numpy matplotlib
17
  python app.py
18
 
19
  Deploy ke HF Space:
20
- - README.md di root Space set: sdk: gradio, app_file: app.py
21
- - requirements.txt berisi paket di atas
 
 
22
  """
23
 
24
  import os
@@ -39,7 +49,8 @@ from huggingface_hub import hf_hub_download
39
  # 1. KONFIGURASI
40
  # ─────────────────────────────────────────────────────────────
41
  HF_REPO_ID = "Marksnb/brain-hybrid-efficientnet-vit"
42
- CHECKPOINT_FILENAME = "hybrid_vit_efficientnet_brain_best.pth"
 
43
 
44
  IMG_SIZE = 224
45
  NUM_CLASSES = 5
@@ -69,8 +80,17 @@ val_transforms = T.Compose([
69
  T.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
70
  ])
71
 
 
 
 
 
 
 
 
 
 
72
  try:
73
- import spaces # noqa: F401 (cek awal, detail di bawah)
74
  IS_ZEROGPU = True
75
  except ImportError:
76
  IS_ZEROGPU = False
@@ -86,16 +106,16 @@ else:
86
 
87
 
88
  # ─────────────────────────────────────────────────────────────
89
- # 2. ARSITEKTUR MODEL
90
  # (persis sama dengan classifier_model.py di repo Space asli,
91
  # supaya checkpoint bisa di-load tanpa error missing/unexpected key)
92
  # ─────────────────────────────────────────────────────────────
93
  try:
94
  from torchvision.models import efficientnet_b3, EfficientNet_B3_Weights
95
- HAS_WEIGHTS = True
96
  except ImportError:
97
  from torchvision.models import efficientnet_b3
98
- HAS_WEIGHTS = False
99
 
100
 
101
  class PatchEmbedding(nn.Module):
@@ -193,13 +213,18 @@ class BrainHybridModel(nn.Module):
193
  vit_num_layers: int = 6,
194
  fusion_dim: int = 512,
195
  dropout: float = 0.3,
196
- freeze_backbone: bool = True):
 
197
  super().__init__()
198
 
199
- if HAS_WEIGHTS:
200
- backbone = efficientnet_b3(weights=EfficientNet_B3_Weights.DEFAULT)
 
 
 
201
  else:
202
- backbone = efficientnet_b3(pretrained=True)
 
203
  self.features = backbone.features
204
  self.cnn_out = 1536
205
 
@@ -232,7 +257,7 @@ class BrainHybridModel(nn.Module):
232
  for param in self.features.parameters():
233
  param.requires_grad = False
234
 
235
- def forward(self, x):
236
  feat_map = self.features(x)
237
  cnn_feat = F.adaptive_avg_pool2d(feat_map, 1).flatten(1)
238
  patches = self.patch_embed(feat_map)
@@ -240,6 +265,10 @@ class BrainHybridModel(nn.Module):
240
  tokens = torch.cat([cls, patches], dim=1)
241
  tokens = tokens + self.pos_embed
242
  tokens = self.pos_drop(tokens)
 
 
 
 
243
  for blk in self.blocks:
244
  tokens = blk(tokens)
245
  tokens = self.vit_norm(tokens)
@@ -249,14 +278,7 @@ class BrainHybridModel(nn.Module):
249
  return logits
250
 
251
  def forward_with_attention(self, x):
252
- feat_map = self.features(x)
253
- cnn_feat = F.adaptive_avg_pool2d(feat_map, 1).flatten(1)
254
- patches = self.patch_embed(feat_map)
255
- cls = self.cls_token.expand(x.size(0), -1, -1)
256
- tokens = torch.cat([cls, patches], dim=1)
257
- tokens = tokens + self.pos_embed
258
- tokens = self.pos_drop(tokens)
259
-
260
  last_attn = None
261
  for i, blk in enumerate(self.blocks):
262
  if i == len(self.blocks) - 1:
@@ -270,40 +292,149 @@ class BrainHybridModel(nn.Module):
270
  return logits, last_attn
271
 
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  # ─────────────────────────────────────────────────────────────
274
- # 3. LOAD MODEL (sekali saat startup)
 
 
 
 
 
275
  # ─────────────────────────────────────────────────────────────
276
- print(f"[startup] Downloading checkpoint '{CHECKPOINT_FILENAME}' dari {HF_REPO_ID} ...")
277
- checkpoint_path = hf_hub_download(repo_id=HF_REPO_ID, filename=CHECKPOINT_FILENAME)
278
- print(f"[startup] Checkpoint tersimpan di: {checkpoint_path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
- model = BrainHybridModel().to(DEVICE)
281
 
282
- state_dict = torch.load(checkpoint_path, map_location=DEVICE)
283
- # Beberapa checkpoint training disimpan sebagai dict {"model_state_dict": ...}
284
- if isinstance(state_dict, dict) and "model_state_dict" in state_dict:
285
- state_dict = state_dict["model_state_dict"]
 
 
 
 
 
 
286
 
287
- missing, unexpected = model.load_state_dict(state_dict, strict=False)
 
 
 
288
  if missing:
289
- print(f"[startup] WARNING - missing keys: {missing}")
290
  if unexpected:
291
- print(f"[startup] WARNING - unexpected keys: {unexpected}")
292
-
293
  model.eval()
294
- print(f"[startup] Model siap. Device: {DEVICE}")
 
 
 
 
295
 
296
 
297
  # ─────────────────────────────────────────────────────────────
298
- # 4. FUNGSI INFERENCE + ATTENTION HEATMAP
299
  # ─────────────────────────────────────────────────────────────
300
- def generate_attention_overlay(orig_image: Image.Image, tensor_image: torch.Tensor, attn: torch.Tensor):
301
  """Buat gambar overlay heatmap attention (ViT) di atas gambar asli."""
302
- avg_attn = attn.squeeze(0).mean(dim=0) # [seq_len, seq_len]
303
- cls_attn = avg_attn[0, 1:] # attention CLS -> semua patch
304
 
305
  num_patches = int(cls_attn.shape[0] ** 0.5)
306
- heatmap = cls_attn.reshape(num_patches, num_patches).cpu().numpy()
307
 
308
  heatmap = np.maximum(heatmap, 0)
309
  heatmap = heatmap / (np.max(heatmap) if np.max(heatmap) != 0 else 1.0)
@@ -321,21 +452,54 @@ def generate_attention_overlay(orig_image: Image.Image, tensor_image: torch.Tens
321
  fig.tight_layout()
322
 
323
  fig.canvas.draw()
324
- overlay_img = Image.frombytes("RGB", fig.canvas.get_width_height(), fig.canvas.tostring_rgb())
 
 
 
325
  plt.close(fig)
326
  return overlay_img
327
 
328
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  def _analyze_brain_scan_impl(image: Image.Image):
330
  if image is None:
331
  return None, None, "Silakan upload gambar CT-Scan / MRI otak terlebih dahulu."
332
 
333
  infer_device = RUNTIME_DEVICE if IS_ZEROGPU else DEVICE
334
  model.to(infer_device)
 
 
335
 
336
  orig_image = image.convert("RGB")
337
  tensor_image = val_transforms(orig_image).unsqueeze(0).to(infer_device)
338
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  with torch.no_grad():
340
  logits, attn = model.forward_with_attention(tensor_image)
341
  probs = F.softmax(logits, dim=1).squeeze(0).cpu().numpy()
@@ -345,12 +509,12 @@ def _analyze_brain_scan_impl(image: Image.Image):
345
  pred_label = CLASS_DISPLAY[pred_class]
346
  confidence = float(probs[pred_idx]) * 100
347
 
348
- # Dict untuk gr.Label (semua kelas + probabilitasnya)
349
  label_scores = {CLASS_DISPLAY[c]: float(p) for c, p in zip(CLASSES, probs)}
350
 
351
- overlay_img = generate_attention_overlay(orig_image, tensor_image, attn)
352
 
353
  summary = (
 
354
  f"**Prediksi: {pred_label}** (keyakinan {confidence:.2f}%)\n\n"
355
  f"Catatan: hasil ini adalah output model AI, BUKAN diagnosis medis resmi. "
356
  f"Selalu konsultasikan dengan dokter/radiolog untuk keputusan klinis."
@@ -360,7 +524,7 @@ def _analyze_brain_scan_impl(image: Image.Image):
360
 
361
 
362
  if IS_ZEROGPU:
363
- @spaces.GPU
364
  def analyze_brain_scan(image: Image.Image):
365
  return _analyze_brain_scan_impl(image)
366
  else:
@@ -369,7 +533,7 @@ else:
369
 
370
 
371
  # ─────────────────────────────────────────────────────────────
372
- # 5. UI GRADIO
373
  # ─────────────────────────────────────────────────────────────
374
  with gr.Blocks(title="BrainScan AI β€” Hybrid EfficientNet-ViT") as demo:
375
  gr.Markdown(
@@ -377,7 +541,8 @@ with gr.Blocks(title="BrainScan AI β€” Hybrid EfficientNet-ViT") as demo:
377
  # 🧠 BrainScan AI
378
  Klasifikasi otomatis CT-Scan / MRI otak menggunakan arsitektur
379
  **Hybrid EfficientNet-B3 + Custom Vision Transformer** dengan
380
- Cross-Modal Attention Fusion.
 
381
 
382
  Kelas yang dideteksi: Alzheimer, Intracranial Hemorrhage (ICH),
383
  Normal, Ischemic Stroke, Brain Tumor.
@@ -411,4 +576,4 @@ with gr.Blocks(title="BrainScan AI β€” Hybrid EfficientNet-ViT") as demo:
411
 
412
 
413
  if __name__ == "__main__":
414
- demo.launch()
 
1
  """
2
+ app.py β€” BrainScan AI (Gradio Space, standalone, single-file)
3
+ ================================================================
4
+ Model: Hybrid EfficientNet-B3 + Custom ViT (Cross-Modal Attention Fusion)
5
+ Repo checkpoint : Marksnb/brain-hybrid-efficientnet-vit
6
+ - hybrid_vit_efficientnet_brain_best.pth -> model klasifikasi 5 kelas (utama)
7
+ - best_precheck_model.pth -> model precheck biner
8
+ ("apakah gambar ini CT/MRI otak?")
9
 
10
  Alur:
11
+ 1. Download kedua checkpoint dari Hugging Face Hub saat startup.
12
+ 2. Definisikan arsitektur model utama (Hybrid EfficientNet-B3 + ViT).
13
+ 3. Load model precheck secara ADAPTIF: mencoba beberapa arsitektur backbone
14
+ kandidat dan memilih yang paling cocok dengan checkpoint (lihat catatan
15
+ di bagian PRECHECK MODEL di bawah -- arsitektur aslinya tidak
16
+ didokumentasikan di repo, jadi ini best-effort & auto-degrade jika
17
+ tidak cocok).
18
+ 4. Preprocessing gambar sama seperti saat training (Resize 224 + ImageNet norm).
19
+ 5. Inference -> precheck dulu, baru klasifikasi 5 kelas penyakit otak.
20
+ 6. Generate attention heatmap (ViT attention block terakhir) sebagai
21
+ visualisasi "area yang difokuskan model" (Explainable AI ringan).
22
 
23
  Jalankan lokal:
24
+ pip install -r requirements.txt
25
  python app.py
26
 
27
  Deploy ke HF Space:
28
+ - README.md di root Space (metadata YAML): sdk: gradio, app_file: app.py
29
+ - requirements.txt berisi paket yang dibutuhkan
30
+ - Endpoint REST otomatis tersedia di /gradio_api/call/analyze
31
+ (lihat api_name="analyze" di bagian UI paling bawah)
32
  """
33
 
34
  import os
 
49
  # 1. KONFIGURASI
50
  # ─────────────────────────────────────────────────────────────
51
  HF_REPO_ID = "Marksnb/brain-hybrid-efficientnet-vit"
52
+ MAIN_CHECKPOINT_FILENAME = "hybrid_vit_efficientnet_brain_best.pth"
53
+ PRECHECK_CHECKPOINT_FILENAME = "best_precheck_model.pth"
54
 
55
  IMG_SIZE = 224
56
  NUM_CLASSES = 5
 
80
  T.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
81
  ])
82
 
83
+ # --- Precheck config -------------------------------------------------------
84
+ # CATATAN PENTING: arsitektur & urutan kelas model precheck TIDAK
85
+ # didokumentasikan di repo HF, jadi ini asumsi. Index 0 = bukan brain scan,
86
+ # index 1 = brain scan. Kalau hasil precheck kebalik-balik setelah deploy,
87
+ # tinggal tukar dua string ini.
88
+ PRECHECK_CLASS_NAMES = ["Bukan_Brain_Scan", "Brain_Scan"]
89
+ # Ambang keyakinan minimum supaya precheck menolak gambar (0-1).
90
+ PRECHECK_REJECT_THRESHOLD = 0.65
91
+
92
  try:
93
+ import spaces # noqa: F401
94
  IS_ZEROGPU = True
95
  except ImportError:
96
  IS_ZEROGPU = False
 
106
 
107
 
108
  # ─────────────────────────────────────────────────────────────
109
+ # 2. ARSITEKTUR MODEL UTAMA (Hybrid EfficientNet-B3 + Custom ViT)
110
  # (persis sama dengan classifier_model.py di repo Space asli,
111
  # supaya checkpoint bisa di-load tanpa error missing/unexpected key)
112
  # ─────────────────────────────────────────────────────────────
113
  try:
114
  from torchvision.models import efficientnet_b3, EfficientNet_B3_Weights
115
+ HAS_WEIGHTS_ENUM = True
116
  except ImportError:
117
  from torchvision.models import efficientnet_b3
118
+ HAS_WEIGHTS_ENUM = False
119
 
120
 
121
  class PatchEmbedding(nn.Module):
 
213
  vit_num_layers: int = 6,
214
  fusion_dim: int = 512,
215
  dropout: float = 0.3,
216
+ freeze_backbone: bool = True,
217
+ pretrained_backbone: bool = True):
218
  super().__init__()
219
 
220
+ if pretrained_backbone:
221
+ if HAS_WEIGHTS_ENUM:
222
+ backbone = efficientnet_b3(weights=EfficientNet_B3_Weights.DEFAULT)
223
+ else:
224
+ backbone = efficientnet_b3(pretrained=True)
225
  else:
226
+ backbone = efficientnet_b3(weights=None) if HAS_WEIGHTS_ENUM else efficientnet_b3(pretrained=False)
227
+
228
  self.features = backbone.features
229
  self.cnn_out = 1536
230
 
 
257
  for param in self.features.parameters():
258
  param.requires_grad = False
259
 
260
+ def _encode(self, x):
261
  feat_map = self.features(x)
262
  cnn_feat = F.adaptive_avg_pool2d(feat_map, 1).flatten(1)
263
  patches = self.patch_embed(feat_map)
 
265
  tokens = torch.cat([cls, patches], dim=1)
266
  tokens = tokens + self.pos_embed
267
  tokens = self.pos_drop(tokens)
268
+ return cnn_feat, tokens
269
+
270
+ def forward(self, x):
271
+ cnn_feat, tokens = self._encode(x)
272
  for blk in self.blocks:
273
  tokens = blk(tokens)
274
  tokens = self.vit_norm(tokens)
 
278
  return logits
279
 
280
  def forward_with_attention(self, x):
281
+ cnn_feat, tokens = self._encode(x)
 
 
 
 
 
 
 
282
  last_attn = None
283
  for i, blk in enumerate(self.blocks):
284
  if i == len(self.blocks) - 1:
 
292
  return logits, last_attn
293
 
294
 
295
+ def _unwrap_state_dict(raw):
296
+ """Beberapa checkpoint disimpan sebagai dict {'model_state_dict': ...} atau
297
+ {'state_dict': ...}. Fungsi ini menormalkannya menjadi state_dict polos,
298
+ dan membuang prefix 'module.' (umum kalau training pakai DataParallel)."""
299
+ if isinstance(raw, dict):
300
+ for key in ("model_state_dict", "state_dict", "model"):
301
+ if key in raw and isinstance(raw[key], dict):
302
+ raw = raw[key]
303
+ break
304
+ cleaned = {}
305
+ for k, v in raw.items():
306
+ cleaned[k.replace("module.", "", 1) if k.startswith("module.") else k] = v
307
+ return cleaned
308
+
309
+
310
  # ─────────────────────────────────────────────────────────────
311
+ # 3. MODEL PRECHECK (biner: brain scan vs bukan)
312
+ # ARSITEKTUR ASLINYA TIDAK DIDOKUMENTASIKAN DI REPO -> kita coba beberapa
313
+ # backbone ringan yang umum dipakai untuk precheck/gatekeeper model, dan
314
+ # pilih otomatis yang paling cocok (paling sedikit missing/unexpected key)
315
+ # dengan checkpoint. Kalau tidak ada yang cukup cocok, precheck otomatis
316
+ # dimatikan (app tetap jalan, hanya tanpa langkah precheck).
317
  # ─────────────────────────────────────────────────────────────
318
+ def _build_precheck_candidates(num_out=2):
319
+ """Kembalikan list (nama, model) kandidat arsitektur backbone ringan
320
+ dengan output akhir num_out kelas."""
321
+ import torchvision.models as tvm
322
+ candidates = []
323
+
324
+ def safe(name, fn):
325
+ try:
326
+ candidates.append((name, fn()))
327
+ except Exception as e:
328
+ print(f"[precheck] Lewati kandidat '{name}': {e}")
329
+
330
+ def make_resnet18():
331
+ m = tvm.resnet18(weights=None)
332
+ m.fc = nn.Linear(m.fc.in_features, num_out)
333
+ return m
334
+
335
+ def make_resnet34():
336
+ m = tvm.resnet34(weights=None)
337
+ m.fc = nn.Linear(m.fc.in_features, num_out)
338
+ return m
339
+
340
+ def make_mobilenet_v2():
341
+ m = tvm.mobilenet_v2(weights=None)
342
+ m.classifier[-1] = nn.Linear(m.classifier[-1].in_features, num_out)
343
+ return m
344
+
345
+ def make_efficientnet_b0():
346
+ m = tvm.efficientnet_b0(weights=None)
347
+ m.classifier[-1] = nn.Linear(m.classifier[-1].in_features, num_out)
348
+ return m
349
+
350
+ def make_densenet121():
351
+ m = tvm.densenet121(weights=None)
352
+ m.classifier = nn.Linear(m.classifier.in_features, num_out)
353
+ return m
354
+
355
+ safe("resnet18", make_resnet18)
356
+ safe("resnet34", make_resnet34)
357
+ safe("mobilenet_v2", make_mobilenet_v2)
358
+ safe("efficientnet_b0", make_efficientnet_b0)
359
+ safe("densenet121", make_densenet121)
360
+ return candidates
361
+
362
+
363
+ def load_precheck_model(checkpoint_path, device):
364
+ """Coba beberapa arsitektur kandidat, pilih yang paling cocok dengan
365
+ checkpoint. Return (model_or_None, info_string)."""
366
+ try:
367
+ raw = torch.load(checkpoint_path, map_location="cpu")
368
+ except Exception as e:
369
+ return None, f"Gagal membaca checkpoint precheck: {e}"
370
+
371
+ state_dict = _unwrap_state_dict(raw)
372
+ total_keys = max(len(state_dict), 1)
373
+
374
+ best = None # (score, name, model)
375
+ for name, model in _build_precheck_candidates():
376
+ model_keys = set(model.state_dict().keys())
377
+ missing, unexpected = model.load_state_dict(state_dict, strict=False)
378
+ # `missing`/`unexpected` di sini adalah namedtuple hasil load_state_dict
379
+ n_bad = len(missing) + len(unexpected)
380
+ score = 1.0 - (n_bad / total_keys)
381
+ print(f"[precheck] Kandidat '{name}': score={score:.3f} "
382
+ f"(missing={len(missing)}, unexpected={len(unexpected)})")
383
+ if best is None or score > best[0]:
384
+ best = (score, name, model)
385
+
386
+ if best is None:
387
+ return None, "Tidak ada kandidat arsitektur yang bisa dibangun."
388
+
389
+ score, name, model = best
390
+ if score < 0.9:
391
+ return None, (f"Precheck dinonaktifkan: arsitektur checkpoint tidak "
392
+ f"cocok dengan kandidat manapun (skor terbaik={score:.2f}, "
393
+ f"kandidat={name}). Cek log server untuk detail key yang "
394
+ f"tidak cocok, lalu sesuaikan _build_precheck_candidates().")
395
+
396
+ model.to(device)
397
+ model.eval()
398
+ return model, f"Precheck aktif menggunakan arsitektur '{name}' (skor kecocokan={score:.2f})."
399
 
 
400
 
401
+ # ─────────────────────────────────────────────────────────────
402
+ # 4. LOAD MODEL (sekali saat startup)
403
+ # ─────────────────────────────────────────────────────────────
404
+ print(f"[startup] Downloading '{MAIN_CHECKPOINT_FILENAME}' dari {HF_REPO_ID} ...")
405
+ main_checkpoint_path = hf_hub_download(repo_id=HF_REPO_ID, filename=MAIN_CHECKPOINT_FILENAME)
406
+ print(f"[startup] Checkpoint utama tersimpan di: {main_checkpoint_path}")
407
+
408
+ print(f"[startup] Downloading '{PRECHECK_CHECKPOINT_FILENAME}' dari {HF_REPO_ID} ...")
409
+ precheck_checkpoint_path = hf_hub_download(repo_id=HF_REPO_ID, filename=PRECHECK_CHECKPOINT_FILENAME)
410
+ print(f"[startup] Checkpoint precheck tersimpan di: {precheck_checkpoint_path}")
411
 
412
+ model = BrainHybridModel().to(DEVICE)
413
+ raw_state = torch.load(main_checkpoint_path, map_location=DEVICE)
414
+ main_state_dict = _unwrap_state_dict(raw_state)
415
+ missing, unexpected = model.load_state_dict(main_state_dict, strict=False)
416
  if missing:
417
+ print(f"[startup] WARNING - model utama, missing keys: {missing}")
418
  if unexpected:
419
+ print(f"[startup] WARNING - model utama, unexpected keys: {unexpected}")
 
420
  model.eval()
421
+ print(f"[startup] Model utama siap. Device: {DEVICE}")
422
+
423
+ precheck_model, precheck_info = load_precheck_model(precheck_checkpoint_path, DEVICE)
424
+ PRECHECK_ENABLED = precheck_model is not None
425
+ print(f"[startup] {precheck_info}")
426
 
427
 
428
  # ─────────────────────────────────────────────────────────────
429
+ # 5. FUNGSI INFERENCE + ATTENTION HEATMAP
430
  # ─────────────────────────────────────────────────────────────
431
+ def generate_attention_overlay(orig_image: Image.Image, attn: torch.Tensor):
432
  """Buat gambar overlay heatmap attention (ViT) di atas gambar asli."""
433
+ avg_attn = attn.squeeze(0).mean(dim=0) # [seq_len, seq_len]
434
+ cls_attn = avg_attn[0, 1:] # attention CLS -> semua patch
435
 
436
  num_patches = int(cls_attn.shape[0] ** 0.5)
437
+ heatmap = cls_attn.reshape(num_patches, num_patches).detach().cpu().numpy()
438
 
439
  heatmap = np.maximum(heatmap, 0)
440
  heatmap = heatmap / (np.max(heatmap) if np.max(heatmap) != 0 else 1.0)
 
452
  fig.tight_layout()
453
 
454
  fig.canvas.draw()
455
+ # buffer_rgba() kompatibel dengan matplotlib versi baru (tostring_rgb
456
+ # sudah deprecated/dihapus di beberapa versi terbaru).
457
+ buf = np.asarray(fig.canvas.buffer_rgba())
458
+ overlay_img = Image.fromarray(buf).convert("RGB")
459
  plt.close(fig)
460
  return overlay_img
461
 
462
 
463
+ def _run_precheck(tensor_image: torch.Tensor):
464
+ """Return (is_brain_scan: bool, confidence: float, label_scores: dict)."""
465
+ with torch.no_grad():
466
+ logits = precheck_model(tensor_image)
467
+ probs = F.softmax(logits, dim=1).squeeze(0).cpu().numpy()
468
+ pred_idx = int(np.argmax(probs))
469
+ label_scores = {PRECHECK_CLASS_NAMES[i]: float(p) for i, p in enumerate(probs)}
470
+ is_brain_scan = (pred_idx == 1)
471
+ confidence = float(probs[pred_idx])
472
+ return is_brain_scan, confidence, label_scores
473
+
474
+
475
  def _analyze_brain_scan_impl(image: Image.Image):
476
  if image is None:
477
  return None, None, "Silakan upload gambar CT-Scan / MRI otak terlebih dahulu."
478
 
479
  infer_device = RUNTIME_DEVICE if IS_ZEROGPU else DEVICE
480
  model.to(infer_device)
481
+ if PRECHECK_ENABLED:
482
+ precheck_model.to(infer_device)
483
 
484
  orig_image = image.convert("RGB")
485
  tensor_image = val_transforms(orig_image).unsqueeze(0).to(infer_device)
486
 
487
+ precheck_note = ""
488
+ if PRECHECK_ENABLED:
489
+ is_brain_scan, pc_conf, _ = _run_precheck(tensor_image)
490
+ if (not is_brain_scan) and pc_conf >= PRECHECK_REJECT_THRESHOLD:
491
+ warning = (
492
+ f"⚠️ **Gambar ini kemungkinan BUKAN CT-Scan/MRI otak** "
493
+ f"(keyakinan precheck {pc_conf * 100:.1f}%).\n\n"
494
+ f"Model klasifikasi utama tidak dijalankan karena gambar tidak "
495
+ f"lolos precheck. Silakan upload ulang dengan gambar CT-Scan "
496
+ f"atau MRI otak yang valid."
497
+ )
498
+ return None, None, warning
499
+ precheck_note = f"βœ… Precheck: gambar terdeteksi sebagai brain scan (keyakinan {pc_conf * 100:.1f}%).\n\n"
500
+ else:
501
+ precheck_note = "ℹ️ Precheck dinonaktifkan (lihat log server untuk detail).\n\n"
502
+
503
  with torch.no_grad():
504
  logits, attn = model.forward_with_attention(tensor_image)
505
  probs = F.softmax(logits, dim=1).squeeze(0).cpu().numpy()
 
509
  pred_label = CLASS_DISPLAY[pred_class]
510
  confidence = float(probs[pred_idx]) * 100
511
 
 
512
  label_scores = {CLASS_DISPLAY[c]: float(p) for c, p in zip(CLASSES, probs)}
513
 
514
+ overlay_img = generate_attention_overlay(orig_image, attn)
515
 
516
  summary = (
517
+ f"{precheck_note}"
518
  f"**Prediksi: {pred_label}** (keyakinan {confidence:.2f}%)\n\n"
519
  f"Catatan: hasil ini adalah output model AI, BUKAN diagnosis medis resmi. "
520
  f"Selalu konsultasikan dengan dokter/radiolog untuk keputusan klinis."
 
524
 
525
 
526
  if IS_ZEROGPU:
527
+ @spaces.GPU(duration=60)
528
  def analyze_brain_scan(image: Image.Image):
529
  return _analyze_brain_scan_impl(image)
530
  else:
 
533
 
534
 
535
  # ─────────────────────────────────────────────────────────────
536
+ # 6. UI GRADIO
537
  # ─────────────────────────────────────────────────────────────
538
  with gr.Blocks(title="BrainScan AI β€” Hybrid EfficientNet-ViT") as demo:
539
  gr.Markdown(
 
541
  # 🧠 BrainScan AI
542
  Klasifikasi otomatis CT-Scan / MRI otak menggunakan arsitektur
543
  **Hybrid EfficientNet-B3 + Custom Vision Transformer** dengan
544
+ Cross-Modal Attention Fusion, dilengkapi model **precheck** untuk
545
+ memvalidasi apakah gambar yang diupload benar-benar CT/MRI otak.
546
 
547
  Kelas yang dideteksi: Alzheimer, Intracranial Hemorrhage (ICH),
548
  Normal, Ischemic Stroke, Brain Tumor.
 
576
 
577
 
578
  if __name__ == "__main__":
579
+ demo.launch()