Files changed (1) hide show
  1. app.py +0 -414
app.py DELETED
@@ -1,414 +0,0 @@
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
25
- import numpy as np
26
- import torch
27
- import torch.nn as nn
28
- import torch.nn.functional as F
29
- import torchvision.transforms as T
30
- from PIL import Image
31
- import matplotlib
32
- matplotlib.use("Agg")
33
- import matplotlib.pyplot as plt
34
-
35
- import gradio as gr
36
- from huggingface_hub import hf_hub_download
37
-
38
- # ─────────────────────────────────────────────────────────────
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
46
-
47
- CLASSES = [
48
- "Alzheimer",
49
- "Intracranial_Hemorrhage",
50
- "Normal",
51
- "Stroke_Iskemik",
52
- "Tumor",
53
- ]
54
-
55
- CLASS_DISPLAY = {
56
- "Alzheimer": "Alzheimer",
57
- "Intracranial_Hemorrhage": "Intracranial Hemorrhage (ICH)",
58
- "Normal": "Normal",
59
- "Stroke_Iskemik": "Ischemic Stroke",
60
- "Tumor": "Brain Tumor",
61
- }
62
-
63
- IMAGENET_MEAN = [0.485, 0.456, 0.406]
64
- IMAGENET_STD = [0.229, 0.224, 0.225]
65
-
66
- val_transforms = T.Compose([
67
- T.Resize((IMG_SIZE, IMG_SIZE)),
68
- T.ToTensor(),
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
77
-
78
- # Di ZeroGPU Space: GPU baru "muncul" saat fungsi ber-@spaces.GPU dipanggil,
79
- # jadi startup HARUS di CPU dulu. Pindah ke cuda dilakukan per-request.
80
- if IS_ZEROGPU:
81
- DEVICE = torch.device("cpu")
82
- RUNTIME_DEVICE = torch.device("cuda")
83
- else:
84
- DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
85
- RUNTIME_DEVICE = DEVICE
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):
102
- def __init__(self, in_channels=1536, patch_size=1, embed_dim=768):
103
- super().__init__()
104
- self.proj = nn.Conv2d(in_channels, embed_dim, kernel_size=patch_size, stride=patch_size)
105
-
106
- def forward(self, x):
107
- x = self.proj(x)
108
- x = x.flatten(2).transpose(1, 2)
109
- return x
110
-
111
-
112
- class MultiHeadSelfAttention(nn.Module):
113
- def __init__(self, embed_dim=768, num_heads=12, dropout=0.1):
114
- super().__init__()
115
- assert embed_dim % num_heads == 0
116
- self.num_heads = num_heads
117
- self.head_dim = embed_dim // num_heads
118
- self.scale = self.head_dim ** -0.5
119
- self.qkv = nn.Linear(embed_dim, embed_dim * 3)
120
- self.proj = nn.Linear(embed_dim, embed_dim)
121
- self.drop = nn.Dropout(dropout)
122
-
123
- def forward(self, x, return_attn: bool = False):
124
- B, N, C = x.shape
125
- qkv = (self.qkv(x)
126
- .reshape(B, N, 3, self.num_heads, self.head_dim)
127
- .permute(2, 0, 3, 1, 4))
128
- q, k, v = qkv[0], qkv[1], qkv[2]
129
- attn = (q @ k.transpose(-2, -1)) * self.scale
130
- attn = attn.softmax(dim=-1)
131
- attn = self.drop(attn)
132
- x = (attn @ v).transpose(1, 2).reshape(B, N, C)
133
- x = self.proj(x)
134
- if return_attn:
135
- return x, attn
136
- return x
137
-
138
-
139
- class TransformerBlock(nn.Module):
140
- def __init__(self, embed_dim=768, num_heads=12, mlp_ratio=4.0, dropout=0.1):
141
- super().__init__()
142
- self.norm1 = nn.LayerNorm(embed_dim)
143
- self.attn = MultiHeadSelfAttention(embed_dim, num_heads, dropout)
144
- self.norm2 = nn.LayerNorm(embed_dim)
145
- hidden = int(embed_dim * mlp_ratio)
146
- self.mlp = nn.Sequential(
147
- nn.Linear(embed_dim, hidden),
148
- nn.GELU(),
149
- nn.Dropout(dropout),
150
- nn.Linear(hidden, embed_dim),
151
- nn.Dropout(dropout),
152
- )
153
-
154
- def forward(self, x, return_attn: bool = False):
155
- if return_attn:
156
- attn_out, attn_weights = self.attn(self.norm1(x), return_attn=True)
157
- x = x + attn_out
158
- x = x + self.mlp(self.norm2(x))
159
- return x, attn_weights
160
- x = x + self.attn(self.norm1(x))
161
- x = x + self.mlp(self.norm2(x))
162
- return x
163
-
164
-
165
- class CrossModalAttentionFusion(nn.Module):
166
- def __init__(self, cnn_dim=1536, vit_dim=768, fusion_dim=512, dropout=0.3):
167
- super().__init__()
168
- self.cnn_proj = nn.Linear(cnn_dim, fusion_dim)
169
- self.vit_proj = nn.Linear(vit_dim, fusion_dim)
170
- self.attn = nn.Sequential(
171
- nn.Linear(fusion_dim * 2, fusion_dim),
172
- nn.ReLU(),
173
- nn.Linear(fusion_dim, 2),
174
- nn.Softmax(dim=-1),
175
- )
176
- self.norm = nn.LayerNorm(fusion_dim)
177
- self.drop = nn.Dropout(dropout)
178
-
179
- def forward(self, cnn_feat, vit_feat):
180
- c = self.cnn_proj(cnn_feat)
181
- v = self.vit_proj(vit_feat)
182
- w = self.attn(torch.cat([c, v], dim=-1))
183
- fused = w[:, 0:1] * c + w[:, 1:2] * v
184
- fused = self.norm(fused)
185
- fused = self.drop(fused)
186
- return fused
187
-
188
-
189
- class BrainHybridModel(nn.Module):
190
- def __init__(self, num_classes: int = NUM_CLASSES,
191
- vit_embed_dim: int = 768,
192
- vit_num_heads: int = 12,
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
-
206
- self.patch_embed = PatchEmbedding(self.cnn_out, patch_size=1, embed_dim=vit_embed_dim)
207
- self.cls_token = nn.Parameter(torch.zeros(1, 1, vit_embed_dim))
208
- nn.init.trunc_normal_(self.cls_token, std=0.02)
209
- num_patches = (IMG_SIZE // 32) ** 2
210
- self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, vit_embed_dim))
211
- nn.init.trunc_normal_(self.pos_embed, std=0.02)
212
- self.pos_drop = nn.Dropout(dropout)
213
- self.blocks = nn.ModuleList([
214
- TransformerBlock(vit_embed_dim, vit_num_heads, dropout=dropout)
215
- for _ in range(vit_num_layers)
216
- ])
217
- self.vit_norm = nn.LayerNorm(vit_embed_dim)
218
-
219
- self.fusion = CrossModalAttentionFusion(
220
- cnn_dim=self.cnn_out, vit_dim=vit_embed_dim,
221
- fusion_dim=fusion_dim, dropout=dropout)
222
-
223
- self.classifier = nn.Sequential(
224
- nn.Linear(fusion_dim, 256),
225
- nn.GELU(),
226
- nn.BatchNorm1d(256),
227
- nn.Dropout(dropout),
228
- nn.Linear(256, num_classes),
229
- )
230
-
231
- if freeze_backbone:
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)
239
- cls = self.cls_token.expand(x.size(0), -1, -1)
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)
246
- vit_feat = tokens[:, 0]
247
- fused = self.fusion(cnn_feat, vit_feat)
248
- logits = self.classifier(fused)
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:
263
- tokens, last_attn = blk(tokens, return_attn=True)
264
- else:
265
- tokens = blk(tokens)
266
- tokens = self.vit_norm(tokens)
267
- vit_feat = tokens[:, 0]
268
- fused = self.fusion(cnn_feat, vit_feat)
269
- logits = self.classifier(fused)
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)
310
-
311
- heatmap_img = Image.fromarray((heatmap * 255).astype(np.uint8))
312
- heatmap_resized = np.array(
313
- heatmap_img.resize(orig_image.size, Image.Resampling.BILINEAR)
314
- ) / 255.0
315
-
316
- fig, ax = plt.subplots(figsize=(5, 5))
317
- ax.imshow(orig_image)
318
- ax.imshow(heatmap_resized, cmap="jet", alpha=0.45)
319
- ax.axis("off")
320
- ax.set_title("Peta Fokus Atensi AI (ViT Attention)")
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()
342
-
343
- pred_idx = int(np.argmax(probs))
344
- pred_class = CLASSES[pred_idx]
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."
357
- )
358
-
359
- return label_scores, overlay_img, summary
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:
367
- def analyze_brain_scan(image: Image.Image):
368
- return _analyze_brain_scan_impl(image)
369
-
370
-
371
- # ─────────────────────────────────────────────────────────────
372
- # 5. UI GRADIO
373
- # ─────────────────────────────────────────────────────────────
374
- with gr.Blocks(title="BrainScan AI — Hybrid EfficientNet-ViT") as demo:
375
- gr.Markdown(
376
- """
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.
384
-
385
- ⚠️ **Disclaimer:** alat ini untuk tujuan riset/edukasi, bukan pengganti
386
- diagnosis medis profesional.
387
- """
388
- )
389
-
390
- with gr.Row():
391
- with gr.Column():
392
- image_input = gr.Image(type="pil", label="Upload CT-Scan / MRI Otak")
393
- analyze_btn = gr.Button("🔍 Analisis", variant="primary")
394
- with gr.Column():
395
- label_output = gr.Label(num_top_classes=5, label="Probabilitas per Kelas")
396
- heatmap_output = gr.Image(label="Peta Fokus Atensi AI (Explainability)")
397
-
398
- summary_output = gr.Markdown()
399
-
400
- analyze_btn.click(
401
- fn=analyze_brain_scan,
402
- inputs=image_input,
403
- outputs=[label_output, heatmap_output, summary_output],
404
- api_name="analyze",
405
- )
406
-
407
- gr.Examples(
408
- examples=[], # tambahkan path gambar contoh di sini kalau ada, mis. "samples/normal_1.jpg"
409
- inputs=image_input,
410
- )
411
-
412
-
413
- if __name__ == "__main__":
414
- demo.launch()