Marksnb commited on
Commit
12d60da
Β·
verified Β·
1 Parent(s): 9621a55

Upload 13 files

Browse files
Files changed (13) hide show
  1. __init__.py +0 -0
  2. api.py +391 -0
  3. app.py +83 -0
  4. classifier_model.py +286 -0
  5. config.py +168 -0
  6. database.py +185 -0
  7. explainability.py +80 -0
  8. fusion_blocks.py +37 -0
  9. gemini_client.py +153 -0
  10. main.py +407 -0
  11. precheck_model.py +65 -0
  12. preprocess.py +296 -0
  13. prompts.py +56 -0
__init__.py ADDED
File without changes
api.py ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import io
3
+ import base64
4
+ import shutil
5
+ import datetime
6
+
7
+ import torch
8
+ import torch.nn.functional as F
9
+ from PIL import Image
10
+ from fastapi import APIRouter, UploadFile, File, HTTPException, Form
11
+ from fastapi.responses import StreamingResponse
12
+ from pydantic import BaseModel
13
+ from fpdf import FPDF
14
+
15
+ from src.config import CLASSES, OUTPUT_DIR, CHECKPOINT_DIR, download_model_from_hf
16
+ from src.preprocess import val_transforms, precheck_transforms
17
+ from src.models.precheck_model import BrainPreCheckModel
18
+ from src.models.classifier_model import BrainHybridModel
19
+ from src.gemini_client import generate_radiology_report
20
+ from src.explainability import generate_attention_heatmap
21
+ from src.database import upsert_patient, get_patient, add_scan_record, get_patient_history
22
+
23
+ router = APIRouter()
24
+
25
+ # ─────────────────────────────────────────────────────────────
26
+ # Util tanggal Indonesia (dipakai di laporan PDF)
27
+ # ─────────────────────────────────────────────────────────────
28
+ _HARI_ID = ["Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Minggu"]
29
+ _BULAN_ID = ["", "Januari", "Februari", "Maret", "April", "Mei", "Juni",
30
+ "Juli", "Agustus", "September", "Oktober", "November", "Desember"]
31
+
32
+
33
+ def tanggal_indonesia_sekarang() -> str:
34
+ """Format tanggal sekarang, misal: 'Selasa, 7 Juli 2026' β€” bukan tanggal tetap."""
35
+ now = datetime.datetime.now()
36
+ return f"{_HARI_ID[now.weekday()]}, {now.day} {_BULAN_ID[now.month]} {now.year}"
37
+
38
+
39
+ # ─────────────────────────────────────────────────────────────
40
+ # Muat model-model AI secara global, SEKALI, saat modul ini diimpor
41
+ # (yaitu saat app.py memanggil `from src.api import router`)
42
+ # ─────────────────────────────────────────────────────────────
43
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
44
+
45
+ precheck_model = None
46
+ hybrid_model = None
47
+
48
+ try:
49
+ print("Memuat model Precheck...")
50
+ precheck_model = BrainPreCheckModel().to(device)
51
+ precheck_checkpoint = download_model_from_hf("best_precheck_model.pth") or os.path.join(CHECKPOINT_DIR, "best_precheck_model.pth")
52
+ if os.path.exists(precheck_checkpoint):
53
+ try:
54
+ precheck_model.load_state_dict(torch.load(precheck_checkpoint, map_location=device))
55
+ print(f"Sukses memuat bobot model Precheck dari {precheck_checkpoint}")
56
+ except RuntimeError:
57
+ print("Warning: Checkpoint precheck tidak kompatibel dengan arsitektur EfficientNet-B0 baru, menggunakan bobot pretrained bawaan.")
58
+ else:
59
+ print("Warning: best_precheck_model.pth tidak ditemukan, menggunakan bobot pretrained bawaan.")
60
+ precheck_model.eval()
61
+
62
+ print("Memuat model Utama Hybrid...")
63
+ hybrid_model = BrainHybridModel().to(device)
64
+ hybrid_checkpoint = download_model_from_hf("hybrid_vit_efficientnet_brain_best.pth") or os.path.join(CHECKPOINT_DIR, "best_hybrid_model.pth")
65
+ if os.path.exists(hybrid_checkpoint):
66
+ try:
67
+ ckpt = torch.load(hybrid_checkpoint, map_location=device)
68
+ if isinstance(ckpt, dict) and "model_state_dict" in ckpt:
69
+ hybrid_model.load_state_dict(ckpt["model_state_dict"])
70
+ else:
71
+ hybrid_model.load_state_dict(ckpt)
72
+ print(f"πŸ’Ύ Sukses memuat bobot model Classifier utama dari {hybrid_checkpoint}")
73
+ except RuntimeError as e:
74
+ print(f"Warning: Checkpoint classifier tidak kompatibel dengan arsitektur baru: {str(e)}. Menggunakan bobot pretrained bawaan.")
75
+ else:
76
+ print("Warning: best_hybrid_model.pth tidak ditemukan, menggunakan bobot pretrained bawaan.")
77
+ hybrid_model.eval()
78
+ print("✨ Seluruh model AI berhasil dimuat.")
79
+ except Exception as e:
80
+ print(f"Gagal memuat model AI: {str(e)}")
81
+
82
+
83
+ # ─────────────────────────────────────────────────────────────
84
+ # Pydantic models
85
+ # ─────────────────────────────────────────────────────────────
86
+ class PatientCreate(BaseModel):
87
+ nik: str
88
+ name: str
89
+ age: int = None
90
+ birth_date: str = None
91
+ gender: str = None
92
+ address: str = None
93
+ phone: str = None
94
+
95
+
96
+ class PDFDownloadRequest(BaseModel):
97
+ patient_name: str
98
+ patient_age: str
99
+ patient_gender: str
100
+ patient_nik: str
101
+ patient_birth_date: str = ""
102
+ patient_address: str = ""
103
+ patient_phone: str = ""
104
+ report_text: str
105
+
106
+
107
+ # ─────────────────────────────────────────────────────────────
108
+ # Endpoint: status server
109
+ # ─────────────────────────────────────────────────────────────
110
+ @router.get("/api/status")
111
+ def get_status():
112
+ """Mengecek status online server dan ketersediaan model AI"""
113
+ return {
114
+ "status": "Online",
115
+ "precheck_model_loaded": precheck_model is not None,
116
+ "classifier_model_loaded": hybrid_model is not None,
117
+ "device": str(device),
118
+ }
119
+
120
+
121
+ # ─────────────────────────────────────────────────────────────
122
+ # Endpoint: data pasien
123
+ # ─────────────────────────────────────────────────────────────
124
+ @router.post("/api/patients/")
125
+ def register_patient(patient: PatientCreate):
126
+ """Menyimpan atau memperbarui data profil pasien"""
127
+ try:
128
+ upsert_patient(
129
+ nik=patient.nik,
130
+ name=patient.name,
131
+ age=patient.age,
132
+ birth_date=patient.birth_date,
133
+ gender=patient.gender,
134
+ address=patient.address,
135
+ phone=patient.phone,
136
+ )
137
+ return {"status": "Success", "message": "Data pasien berhasil disimpan."}
138
+ except Exception as e:
139
+ raise HTTPException(status_code=500, detail=f"Gagal menyimpan data pasien: {str(e)}")
140
+
141
+
142
+ @router.get("/api/patients/{nik}")
143
+ def get_patient_info(nik: str):
144
+ """Mengambil data pasien berdasarkan NIK"""
145
+ patient = get_patient(nik)
146
+ if not patient:
147
+ raise HTTPException(status_code=404, detail="Pasien tidak ditemukan.")
148
+ return {"status": "Success", "patient": patient}
149
+
150
+
151
+ @router.get("/api/patients/{nik}/history")
152
+ def get_patient_scans_history(nik: str):
153
+ """Mengambil riwayat scan pasien berdasarkan NIK"""
154
+ try:
155
+ history = get_patient_history(nik)
156
+ return {"status": "Success", "history": history}
157
+ except Exception as e:
158
+ raise HTTPException(status_code=500, detail=f"Gagal mengambil riwayat scan: {str(e)}")
159
+
160
+
161
+ # ─────────────────────────────────────────────────────────────
162
+ # Endpoint utama: analisis gambar scan otak
163
+ # ─────────────────────────────────────────────────────────────
164
+ @router.post("/api/analyze/")
165
+ async def analyze_brain_image(file: UploadFile = File(...), patient_nik: str = Form(None)):
166
+ """
167
+ Endpoint utama untuk mengunggah gambar scan otak, menjalankan pre-check,
168
+ menjalankan klasifikasi penyakit, memvisualisasikan atensi model (XAI),
169
+ dan menghasilkan laporan radiologi AI.
170
+ """
171
+ # 1. Validasi Ekstensi File
172
+ if not file.filename.lower().endswith(('.png', '.jpg', '.jpeg')):
173
+ raise HTTPException(status_code=400, detail="Format file harus berupa gambar (PNG, JPG, JPEG).")
174
+
175
+ try:
176
+ # 2. Simpan file unggahan sementara untuk visualisasi heatmap
177
+ temp_file_path = os.path.join("temp_uploads", file.filename)
178
+ with open(temp_file_path, "wb") as buffer:
179
+ shutil.copyfileobj(file.file, buffer)
180
+
181
+ # 3. Baca gambar untuk pemrosesan tensor PyTorch
182
+ image = Image.open(temp_file_path).convert("RGB")
183
+ # Dua tensor terpisah: precheck pakai normalisasi [0.5,0.5,0.5]
184
+ # (sesuai cara dia dilatih), hybrid pakai normalisasi ImageNet
185
+ # (sesuai cara model utama dilatih di notebook)
186
+ precheck_tensor = precheck_transforms(image).unsqueeze(0).to(device)
187
+ tensor_image = val_transforms(image).unsqueeze(0).to(device)
188
+
189
+ # 4. TAHAP 1: Precheck (Menyaring Gambar Valid Brain Scan vs Gambar Noise/Invalid)
190
+ is_valid = True
191
+ precheck_prob_val = 0.99
192
+ if precheck_model is not None:
193
+ with torch.no_grad():
194
+ precheck_outputs = precheck_model(precheck_tensor)
195
+ precheck_prob = F.softmax(precheck_outputs, dim=1)
196
+ is_valid_idx = torch.argmax(precheck_prob, dim=1).item()
197
+ precheck_prob_val = precheck_prob[0][is_valid_idx].item()
198
+ # Indeks 1: Valid, Indeks 0: Invalid (Sesuai dengan dataset latihan precheck)
199
+ is_valid = (is_valid_idx == 1)
200
+
201
+ # Jika gambar dinyatakan invalid, hentikan proses analisis awal
202
+ if not is_valid:
203
+ if os.path.exists(temp_file_path):
204
+ os.remove(temp_file_path)
205
+ return {
206
+ "status": "Invalid",
207
+ "filename": file.filename,
208
+ "message": "Gambar tidak dikenali sebagai scan otak yang valid (CT-Scan/MRI). Hubungi Administrator.",
209
+ "precheck_confidence": f"{precheck_prob_val * 100:.2f}%",
210
+ }
211
+
212
+ # 5. TAHAP 2: Klasifikasi Utama (5 Kelas Penyakit Otak)
213
+ if hybrid_model is None:
214
+ raise HTTPException(status_code=500, detail="Model utama klasifikasi tidak termuat di server.")
215
+
216
+ with torch.no_grad():
217
+ hybrid_outputs = hybrid_model(tensor_image)
218
+ hybrid_prob = F.softmax(hybrid_outputs, dim=1)
219
+ confidence, predicted_idx = torch.max(hybrid_prob, dim=1)
220
+
221
+ confidence_score = confidence.item() * 100
222
+ predicted_class = CLASSES[predicted_idx.item()]
223
+
224
+ # 6. TAHAP 3: Eksplanabilitas AI (XAI) - Hasilkan Peta Atensi Heatmap
225
+ heatmap_filename = f"heatmap_{os.path.splitext(file.filename)[0]}.png"
226
+ generate_attention_heatmap(temp_file_path, save_name=heatmap_filename)
227
+
228
+ # 7. TAHAP 4: Kirim Hasil Ke Gemini / Laporan Lokal
229
+ modality = "CT" if "ct" in file.filename.lower() else "MRI"
230
+ report_text = generate_radiology_report(predicted_idx.item(), confidence_score, modality)
231
+
232
+ # 8. Encode gambar visualisasi heatmap dan gambar asli menjadi base64 untuk dikirim langsung ke frontend
233
+ heatmap_path = os.path.join(OUTPUT_DIR, "figures", heatmap_filename)
234
+
235
+ with open(heatmap_path, "rb") as img_file:
236
+ heatmap_base64 = base64.b64encode(img_file.read()).decode('utf-8')
237
+
238
+ with open(temp_file_path, "rb") as img_file:
239
+ original_base64 = base64.b64encode(img_file.read()).decode('utf-8')
240
+
241
+ if os.path.exists(temp_file_path):
242
+ os.remove(temp_file_path)
243
+
244
+ # Simpan ke database jika patient_nik tersedia
245
+ if patient_nik:
246
+ try:
247
+ add_scan_record(
248
+ patient_nik=patient_nik,
249
+ filename=file.filename,
250
+ modality=modality,
251
+ predicted_class=predicted_class,
252
+ confidence=confidence_score,
253
+ report_text=report_text,
254
+ original_b64=f"data:image/png;base64,{original_base64}",
255
+ heatmap_b64=f"data:image/png;base64,{heatmap_base64}",
256
+ )
257
+ except Exception as db_err:
258
+ print(f" Gagal menyimpan riwayat scan ke database: {str(db_err)}")
259
+
260
+ # 9. Kembalikan respons akhir dalam format JSON
261
+ return {
262
+ "status": "Valid",
263
+ "filename": file.filename,
264
+ "modality_detected": modality,
265
+ "prediction": {
266
+ "class_name": predicted_class,
267
+ "class_index": predicted_idx.item(),
268
+ "confidence": f"{confidence_score:.2f}%",
269
+ },
270
+ "radiology_report": report_text,
271
+ "original_image_b64": f"data:image/png;base64,{original_base64}",
272
+ "heatmap_image_b64": f"data:image/png;base64,{heatmap_base64}",
273
+ }
274
+
275
+ except Exception as e:
276
+ if 'temp_file_path' in locals() and os.path.exists(temp_file_path):
277
+ os.remove(temp_file_path)
278
+ raise HTTPException(status_code=500, detail=f"Terjadi kesalahan internal analisis: {str(e)}")
279
+
280
+
281
+ # ─────────────────────────────────────────────────────────────
282
+ # Endpoint: download laporan PDF
283
+ # ─────────────────────────────────────────────────────────────
284
+ @router.post("/api/download-pdf/")
285
+ def download_pdf(data: PDFDownloadRequest):
286
+ try:
287
+ pdf = FPDF()
288
+ pdf.add_page()
289
+ pdf.set_font("helvetica", size=10)
290
+
291
+ # 1. Header (Kop Surat)
292
+ pdf.set_font("helvetica", "B", 14)
293
+ pdf.cell(0, 8, "PUSAT RADIOLOGI DIGITAL & DIAGNOSTIK AI", new_x="LMARGIN", new_y="NEXT", align="C")
294
+ pdf.set_font("helvetica", size=9)
295
+ pdf.cell(0, 5, "Jl. Semilasari Barat No. 88, Sektor Kecerdasan Buatan, Denpasar", new_x="LMARGIN", new_y="NEXT", align="C")
296
+ pdf.cell(0, 5, "Email: support@brainscan.ai | Telp: (021) 555-2026", new_x="LMARGIN", new_y="NEXT", align="C")
297
+
298
+ pdf.ln(3)
299
+ pdf.line(10, pdf.get_y(), 200, pdf.get_y())
300
+ pdf.ln(5)
301
+
302
+ # 2. Document Title
303
+ pdf.set_font("helvetica", "B", 12)
304
+ pdf.cell(0, 7, "DOKUMEN LAPORAN HASIL PEMERIKSAAN RADIOLOGI (OPINI AI)", new_x="LMARGIN", new_y="NEXT", align="C")
305
+ pdf.ln(4)
306
+
307
+ # 3. Patient Details
308
+ pdf.set_font("helvetica", "B", 10)
309
+ pdf.cell(0, 6, "I. IDENTITAS PASIEN & PEMERIKSAAN", new_x="LMARGIN", new_y="NEXT")
310
+ pdf.set_font("helvetica", size=9)
311
+
312
+ details = [
313
+ ("Nama Pasien", data.patient_name, "Jenis Kelamin", data.patient_gender),
314
+ ("Umur", f"{data.patient_age} Tahun", "Tanggal Lahir", data.patient_birth_date),
315
+ ("NIK Pasien", data.patient_nik, "No. Telepon", data.patient_phone),
316
+ ("Alamat", data.patient_address, "Tanggal Analisis", tanggal_indonesia_sekarang()),
317
+ ]
318
+
319
+ col_width = 40
320
+ val_width = 55
321
+ for row in details:
322
+ pdf.set_font("helvetica", "B", 9)
323
+ pdf.cell(col_width, 6, f"{row[0]}:", border=0)
324
+ pdf.set_font("helvetica", "", 9)
325
+ pdf.cell(val_width, 6, str(row[1]), border=0)
326
+
327
+ pdf.set_font("helvetica", "B", 9)
328
+ pdf.cell(col_width, 6, f"{row[2]}:", border=0)
329
+ pdf.set_font("helvetica", "", 9)
330
+ pdf.cell(val_width, 6, str(row[3]), border=0, new_x="LMARGIN", new_y="NEXT")
331
+
332
+ pdf.ln(3)
333
+ pdf.line(10, pdf.get_y(), 200, pdf.get_y())
334
+ pdf.ln(5)
335
+
336
+ # 4. Report Text Content
337
+ pdf.set_font("helvetica", "B", 10)
338
+ pdf.cell(0, 6, "II. LAPORAN PEMERIKSAAN (RADIOLOGY REPORT)", new_x="LMARGIN", new_y="NEXT")
339
+ pdf.ln(2)
340
+
341
+ pdf.set_font("helvetica", "", 9.5)
342
+ lines = data.report_text.split("\n")
343
+ for line in lines:
344
+ stripped = line.strip()
345
+ if stripped.startswith(("1. ", "2. ", "3. ", "4. ")):
346
+ pdf.ln(2)
347
+ pdf.set_font("helvetica", "B", 10)
348
+ pdf.multi_cell(0, 6, line, new_x="LMARGIN", new_y="NEXT")
349
+ pdf.set_font("helvetica", "", 9.5)
350
+ elif stripped.startswith(("* ", "- ")):
351
+ pdf.set_font("helvetica", "", 9.5)
352
+ pdf.set_x(15)
353
+ pdf.multi_cell(0, 5, line, new_x="LMARGIN", new_y="NEXT")
354
+ elif stripped.startswith(("*Catatan:", "Catatan:")):
355
+ pdf.ln(4)
356
+ pdf.set_font("helvetica", "I", 8.5)
357
+ pdf.multi_cell(0, 4.5, line, new_x="LMARGIN", new_y="NEXT")
358
+ else:
359
+ pdf.multi_cell(0, 5, line, new_x="LMARGIN", new_y="NEXT")
360
+
361
+ # 5. Signatures
362
+ pdf.ln(15)
363
+ current_y = pdf.get_y()
364
+
365
+ if current_y > 240:
366
+ pdf.add_page()
367
+ current_y = pdf.get_y()
368
+
369
+ pdf.set_font("helvetica", "", 9.5)
370
+ pdf.set_xy(130, current_y)
371
+ pdf.cell(60, 5, f"Denpasar, {tanggal_indonesia_sekarang()}", new_x="LMARGIN", new_y="NEXT", align="C")
372
+ pdf.set_x(130)
373
+ pdf.cell(60, 5, "Pusat Radiologi Digital & Diagnostik AI", new_x="LMARGIN", new_y="NEXT", align="C")
374
+
375
+ pdf.ln(10)
376
+ pdf.set_x(130)
377
+ pdf.set_font("helvetica", "B", 9.5)
378
+ pdf.cell(60, 5, "dr. _________________________, Sp.Rad", new_x="LMARGIN", new_y="NEXT", align="C")
379
+ pdf.set_x(130)
380
+ pdf.set_font("helvetica", "", 8.5)
381
+ pdf.cell(60, 5, "NIP. ___________________________", new_x="LMARGIN", new_y="NEXT", align="C")
382
+
383
+ pdf_bytes = bytes(pdf.output())
384
+
385
+ return StreamingResponse(
386
+ io.BytesIO(pdf_bytes),
387
+ media_type="application/pdf",
388
+ headers={"Content-Disposition": "attachment; filename=Laporan_Radiologi_BrainScan.pdf"},
389
+ )
390
+ except Exception as e:
391
+ raise HTTPException(status_code=500, detail=f"Gagal memproses PDF: {str(e)}")
app.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py
3
+ ------
4
+ Entry point aplikasi BrainScan AI.
5
+
6
+ Tanggung jawab file ini HANYA:
7
+ 1. Membuat instance FastAPI
8
+ 2. Mengatur CORS
9
+ 3. Membuat folder output yang diperlukan
10
+ 4. Menyertakan (include) seluruh route dari api.py
11
+ 5. Mount static files (frontend) & folder figures (heatmap)
12
+
13
+ Semua logika endpoint (load model, /api/analyze/, dll) ada di api.py β€”
14
+ file ini sengaja dibuat "tipis", cuma perakitan (wiring) doang.
15
+
16
+ Jalankan dari root proyek (folder yang berisi folder src/):
17
+ uvicorn src.app:app --reload
18
+ # atau
19
+ python -m src.app
20
+ """
21
+
22
+ import os
23
+
24
+ from fastapi import FastAPI
25
+ from fastapi.staticfiles import StaticFiles
26
+ from fastapi.middleware.cors import CORSMiddleware
27
+
28
+ from src.config import OUTPUT_DIR
29
+ from src.api import router as api_router
30
+
31
+ # ─────────────────────────────────────────────────────────────
32
+ # 1. Inisialisasi Aplikasi FastAPI
33
+ # ─────────────────────────────────────────────────────────────
34
+ app = FastAPI(
35
+ title="BrainScan AI Framework API",
36
+ description="API untuk analisis otomatis CT-Scan & MRI menggunakan arsitektur Hybrid CNN-Transformer",
37
+ version="1.0",
38
+ )
39
+
40
+ # ─────────────────────────────────────────────────────────────
41
+ # 2. CORS β€” biar frontend (domain/origin manapun) bisa akses API ini
42
+ # ─────────────────────────────────────────────────────────────
43
+ app.add_middleware(
44
+ CORSMiddleware,
45
+ allow_origins=["*"],
46
+ allow_credentials=True,
47
+ allow_methods=["*"],
48
+ allow_headers=["*"],
49
+ )
50
+
51
+ # ─────────────────────────────────────────────────────────────
52
+ # 3. Folder output yang diperlukan saat runtime
53
+ # ─────────────────────────────────────────────────────────────
54
+ os.makedirs(os.path.join(OUTPUT_DIR, "figures"), exist_ok=True)
55
+ os.makedirs("temp_uploads", exist_ok=True)
56
+
57
+ # ─────────────────────────────────────────────────────────────
58
+ # 4. Sambungkan semua endpoint dari api.py
59
+ # (mengimpor src.api otomatis menjalankan proses load model
60
+ # di dalamnya, lihat komentar di bagian atas api.py)
61
+ # ─────────────────────────────────────────────────────────────
62
+ app.include_router(api_router)
63
+
64
+ # ─────────────────────────────────────────────────────────────
65
+ # 5. Mount static files
66
+ # - /outputs/figures -> hasil heatmap Grad-CAM (fallback akses langsung)
67
+ # - / -> frontend (index.html, app.js, index.css)
68
+ # Urutan penting: mount("/") harus PALING TERAKHIR supaya tidak
69
+ # "menutupi" route API yang sudah didaftarkan lewat include_router.
70
+ # ─────────────────────────────────────────────────────────────
71
+ app.mount(
72
+ "/outputs/figures",
73
+ StaticFiles(directory=os.path.join(OUTPUT_DIR, "figures")),
74
+ name="figures",
75
+ )
76
+ app.mount("/", StaticFiles(directory="src/static", html=True), name="static")
77
+
78
+
79
+ if __name__ == "__main__":
80
+ import uvicorn
81
+ # Jalankan dari ROOT proyek (bukan dari dalam folder src/):
82
+ # uvicorn src.app:app --reload
83
+ uvicorn.run("src.app:app", host="127.0.0.1", port=8000, reload=True)
classifier_model.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ classifier_model.py
3
+ -------------------
4
+ Arsitektur PERSIS SAMA dengan yang dipakai saat training di notebook
5
+ (Hybrid_ViT_EfficientNet_Brain_Disease_FIXED.ipynb), supaya checkpoint
6
+ hasil training (hybrid_vit_efficientnet_brain_best.pth) bisa di-load
7
+ tanpa error "Missing/Unexpected key(s)".
8
+
9
+ Arsitektur:
10
+ - EfficientNet-B3 (CNN backbone) -> feature map lokal (1536 dim)
11
+ - Custom Vision Transformer (6 layer) -> dibangun dari nol (bukan
12
+ ViT pretrained HuggingFace), beroperasi di atas feature map CNN
13
+ - Cross-Modal Attention Fusion -> gabungkan fitur CNN + ViT
14
+ - Classifier Head: Linear -> GELU -> BatchNorm1d -> Dropout -> Linear
15
+ """
16
+
17
+ import logging
18
+ import torch
19
+ import torch.nn as nn
20
+ import torch.nn.functional as F
21
+
22
+ try:
23
+ from torchvision.models import efficientnet_b3, EfficientNet_B3_Weights
24
+ HAS_WEIGHTS = True
25
+ except ImportError:
26
+ from torchvision.models import efficientnet_b3
27
+ HAS_WEIGHTS = False
28
+
29
+ from src.config import NUM_CLASSES, IMG_SIZE
30
+
31
+ logger = logging.getLogger("brain_pipeline")
32
+
33
+
34
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
35
+ # SUB-MODULES (identik dengan notebook training)
36
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
37
+
38
+ class PatchEmbedding(nn.Module):
39
+ def __init__(self, in_channels=1536, patch_size=1, embed_dim=768):
40
+ super().__init__()
41
+ self.proj = nn.Conv2d(in_channels, embed_dim,
42
+ kernel_size=patch_size, stride=patch_size)
43
+
44
+ def forward(self, x):
45
+ x = self.proj(x)
46
+ x = x.flatten(2).transpose(1, 2)
47
+ return x
48
+
49
+
50
+ class MultiHeadSelfAttention(nn.Module):
51
+ def __init__(self, embed_dim=768, num_heads=12, dropout=0.1):
52
+ super().__init__()
53
+ assert embed_dim % num_heads == 0
54
+ self.num_heads = num_heads
55
+ self.head_dim = embed_dim // num_heads
56
+ self.scale = self.head_dim ** -0.5
57
+ self.qkv = nn.Linear(embed_dim, embed_dim * 3)
58
+ self.proj = nn.Linear(embed_dim, embed_dim)
59
+ self.drop = nn.Dropout(dropout)
60
+
61
+ def forward(self, x, return_attn: bool = False):
62
+ B, N, C = x.shape
63
+ qkv = (self.qkv(x)
64
+ .reshape(B, N, 3, self.num_heads, self.head_dim)
65
+ .permute(2, 0, 3, 1, 4))
66
+ q, k, v = qkv[0], qkv[1], qkv[2]
67
+ attn = (q @ k.transpose(-2, -1)) * self.scale
68
+ attn = attn.softmax(dim=-1)
69
+ attn = self.drop(attn)
70
+ x = (attn @ v).transpose(1, 2).reshape(B, N, C)
71
+ x = self.proj(x)
72
+ if return_attn:
73
+ return x, attn # attn: [B, num_heads, N, N] -- dipakai buat heatmap
74
+ return x
75
+
76
+
77
+ class TransformerBlock(nn.Module):
78
+ def __init__(self, embed_dim=768, num_heads=12, mlp_ratio=4.0, dropout=0.1):
79
+ super().__init__()
80
+ self.norm1 = nn.LayerNorm(embed_dim)
81
+ self.attn = MultiHeadSelfAttention(embed_dim, num_heads, dropout)
82
+ self.norm2 = nn.LayerNorm(embed_dim)
83
+ hidden = int(embed_dim * mlp_ratio)
84
+ self.mlp = nn.Sequential(
85
+ nn.Linear(embed_dim, hidden),
86
+ nn.GELU(),
87
+ nn.Dropout(dropout),
88
+ nn.Linear(hidden, embed_dim),
89
+ nn.Dropout(dropout),
90
+ )
91
+
92
+ def forward(self, x, return_attn: bool = False):
93
+ if return_attn:
94
+ attn_out, attn_weights = self.attn(self.norm1(x), return_attn=True)
95
+ x = x + attn_out
96
+ x = x + self.mlp(self.norm2(x))
97
+ return x, attn_weights
98
+ x = x + self.attn(self.norm1(x))
99
+ x = x + self.mlp(self.norm2(x))
100
+ return x
101
+
102
+
103
+ class CrossModalAttentionFusion(nn.Module):
104
+ def __init__(self, cnn_dim=1536, vit_dim=768, fusion_dim=512, dropout=0.3):
105
+ super().__init__()
106
+ self.cnn_proj = nn.Linear(cnn_dim, fusion_dim)
107
+ self.vit_proj = nn.Linear(vit_dim, fusion_dim)
108
+ self.attn = nn.Sequential(
109
+ nn.Linear(fusion_dim * 2, fusion_dim),
110
+ nn.ReLU(),
111
+ nn.Linear(fusion_dim, 2),
112
+ nn.Softmax(dim=-1),
113
+ )
114
+ self.norm = nn.LayerNorm(fusion_dim)
115
+ self.drop = nn.Dropout(dropout)
116
+
117
+ def forward(self, cnn_feat, vit_feat):
118
+ c = self.cnn_proj(cnn_feat)
119
+ v = self.vit_proj(vit_feat)
120
+ w = self.attn(torch.cat([c, v], dim=-1))
121
+ fused = w[:, 0:1] * c + w[:, 1:2] * v
122
+ fused = self.norm(fused)
123
+ fused = self.drop(fused)
124
+ return fused
125
+
126
+
127
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
128
+ # MODEL UTAMA
129
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
130
+
131
+ class BrainHybridModel(nn.Module):
132
+ """
133
+ Nama class TETAP 'BrainHybridModel' (biar main.py/explainability.py
134
+ tidak perlu diubah), tapi ISI-nya sekarang identik dengan
135
+ HybridViTEfficientNet di notebook training.
136
+ """
137
+
138
+ def __init__(self, num_classes: int = NUM_CLASSES,
139
+ efficientnet_variant: str = "b3",
140
+ vit_embed_dim: int = 768,
141
+ vit_num_heads: int = 12,
142
+ vit_num_layers: int = 6,
143
+ fusion_dim: int = 512,
144
+ dropout: float = 0.3,
145
+ freeze_backbone: bool = True):
146
+ super().__init__()
147
+
148
+ # 1. CNN backbone (EfficientNet-B3)
149
+ if HAS_WEIGHTS:
150
+ backbone = efficientnet_b3(weights=EfficientNet_B3_Weights.DEFAULT)
151
+ else:
152
+ backbone = efficientnet_b3(pretrained=True)
153
+ self.features = backbone.features
154
+ self.cnn_out = 1536
155
+
156
+ # 2. ViT branch (custom, dibangun dari feature map CNN)
157
+ self.patch_embed = PatchEmbedding(self.cnn_out, patch_size=1,
158
+ embed_dim=vit_embed_dim)
159
+ self.cls_token = nn.Parameter(torch.zeros(1, 1, vit_embed_dim))
160
+ nn.init.trunc_normal_(self.cls_token, std=0.02)
161
+ num_patches = (IMG_SIZE // 32) ** 2
162
+ self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, vit_embed_dim))
163
+ nn.init.trunc_normal_(self.pos_embed, std=0.02)
164
+ self.pos_drop = nn.Dropout(dropout)
165
+ self.blocks = nn.ModuleList([
166
+ TransformerBlock(vit_embed_dim, vit_num_heads, dropout=dropout)
167
+ for _ in range(vit_num_layers)
168
+ ])
169
+ self.vit_norm = nn.LayerNorm(vit_embed_dim)
170
+
171
+ # 3. Fusion
172
+ self.fusion = CrossModalAttentionFusion(
173
+ cnn_dim=self.cnn_out, vit_dim=vit_embed_dim,
174
+ fusion_dim=fusion_dim, dropout=dropout)
175
+
176
+ # 4. Classifier head
177
+ self.classifier = nn.Sequential(
178
+ nn.Linear(fusion_dim, 256),
179
+ nn.GELU(),
180
+ nn.BatchNorm1d(256),
181
+ nn.Dropout(dropout),
182
+ nn.Linear(256, num_classes),
183
+ )
184
+
185
+ # Freeze/unfreeze backbone (opsional, hanya relevan kalau fine-tune ulang)
186
+ if freeze_backbone:
187
+ for param in self.features.parameters():
188
+ param.requires_grad = False
189
+
190
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
191
+ feat_map = self.features(x)
192
+ cnn_feat = F.adaptive_avg_pool2d(feat_map, 1).flatten(1)
193
+ patches = self.patch_embed(feat_map)
194
+ cls = self.cls_token.expand(x.size(0), -1, -1)
195
+ tokens = torch.cat([cls, patches], dim=1)
196
+ tokens = tokens + self.pos_embed
197
+ tokens = self.pos_drop(tokens)
198
+ for blk in self.blocks:
199
+ tokens = blk(tokens)
200
+ tokens = self.vit_norm(tokens)
201
+ vit_feat = tokens[:, 0]
202
+ fused = self.fusion(cnn_feat, vit_feat)
203
+ logits = self.classifier(fused)
204
+ return logits
205
+
206
+ def forward_with_attention(self, x: torch.Tensor):
207
+ """
208
+ Sama seperti forward(), tapi juga mengembalikan attention weights
209
+ dari layer Transformer TERAKHIR -- dipakai untuk bikin heatmap
210
+ 'Peta Atensi Model' di explainability.py.
211
+
212
+ Return:
213
+ logits: [B, num_classes]
214
+ last_attn: [B, num_heads, seq_len, seq_len]
215
+ """
216
+ feat_map = self.features(x)
217
+ cnn_feat = F.adaptive_avg_pool2d(feat_map, 1).flatten(1)
218
+ patches = self.patch_embed(feat_map)
219
+ cls = self.cls_token.expand(x.size(0), -1, -1)
220
+ tokens = torch.cat([cls, patches], dim=1)
221
+ tokens = tokens + self.pos_embed
222
+ tokens = self.pos_drop(tokens)
223
+
224
+ last_attn = None
225
+ for i, blk in enumerate(self.blocks):
226
+ if i == len(self.blocks) - 1:
227
+ tokens, last_attn = blk(tokens, return_attn=True)
228
+ else:
229
+ tokens = blk(tokens)
230
+ tokens = self.vit_norm(tokens)
231
+ vit_feat = tokens[:, 0]
232
+ fused = self.fusion(cnn_feat, vit_feat)
233
+ logits = self.classifier(fused)
234
+ return logits, last_attn
235
+
236
+
237
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
238
+ # UTILITIES
239
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
240
+
241
+ def count_parameters(model: nn.Module):
242
+ total = sum(p.numel() for p in model.parameters())
243
+ trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
244
+ return total, trainable, total - trainable
245
+
246
+
247
+ def print_model_info(model: nn.Module, device: str):
248
+ try:
249
+ from src.config import BATCH_SIZE, LR, EPOCHS, IMG_SIZE
250
+ except ImportError:
251
+ BATCH_SIZE, LR, EPOCHS, IMG_SIZE = 16, 5e-5, 30, 224
252
+
253
+ total, trainable, frozen = count_parameters(model)
254
+ sep70 = "=" * 70
255
+ logger.info("\n" + sep70)
256
+ logger.info(" INFORMASI MODEL")
257
+ logger.info(sep70)
258
+ col = 30
259
+ fields = [
260
+ ("Model Name", "EfficientNet-B3 + Custom Vision Transformer (Hybrid)"),
261
+ ("Architecture", "EfficientNet-B3 Features + Custom ViT (6 layer) -> Cross-Modal Fusion -> MLP Head"),
262
+ ("Jumlah Parameter", f"{total:,}"),
263
+ ("Trainable Parameter", f"{trainable:,}"),
264
+ ("Non-Trainable Parameter", f"{frozen:,}"),
265
+ ("Image Size", f"{IMG_SIZE} x {IMG_SIZE} px"),
266
+ ("Batch Size", str(BATCH_SIZE)),
267
+ ("Learning Rate", str(LR)),
268
+ ("Epoch", str(EPOCHS)),
269
+ ("Device", device.upper()),
270
+ ]
271
+ for k, v in fields:
272
+ logger.info(f" {k:<{col}}: {v}")
273
+ logger.info("")
274
+
275
+
276
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
277
+ # QUICK SANITY CHECK
278
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
279
+
280
+ if __name__ == "__main__":
281
+ model = BrainHybridModel()
282
+ dummy = torch.randn(2, 3, IMG_SIZE, IMG_SIZE)
283
+ out = model(dummy)
284
+ print(f"Hybrid Model OK! Output shape: {out.shape}")
285
+ total, trainable, frozen = count_parameters(model)
286
+ print(f"Total: {total:,} | Trainable: {trainable:,} | Frozen: {frozen:,}")
config.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ src/config.py β€” Modul Konfigurasi Terpusat
3
+ Brain Disease Classification Pipeline
4
+ Vision Transformer (google/vit-base-patch16-224) Pretrained
5
+ """
6
+
7
+ import os
8
+ from pathlib import Path
9
+
10
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
11
+ # HYPERPARAMETER UTAMA & SEED
12
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13
+ SEED = 42
14
+ IMG_SIZE = 224
15
+ BATCH_SIZE = 16
16
+ NUM_CLASSES = 5
17
+ LR = 5e-5
18
+ EPOCHS = 30
19
+
20
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21
+ # GEMINI API KEY β€” WAJIB di-set lewat environment variable, JANGAN ditulis di sini
22
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
23
+ # Jangan pernah taruh API key asli langsung di source code (apalagi yang ikut
24
+ # ter-commit ke git / ter-share ke orang lain) β€” siapa pun yang membaca file
25
+ # ini bisa memakai kuota/API key milik Anda. Set lewat environment variable:
26
+ # export GEMINI_API_KEY="isi-key-anda" (Linux/Mac)
27
+ # setx GEMINI_API_KEY "isi-key-anda" (Windows)
28
+ # Jika tidak di-set, sistem otomatis memakai generator laporan lokal (fallback)
29
+ # di gemini_client.py β€” jadi aplikasi tetap berjalan tanpa Gemini API.
30
+ GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
31
+
32
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
33
+ # KELAS PENYAKIT OTAK (5 KELAS)
34
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
35
+ CLASSES = [
36
+ "Alzheimer",
37
+ "Intracranial_Hemorrhage",
38
+ "Normal",
39
+ "Stroke_Iskemik",
40
+ "Tumor",
41
+ ]
42
+
43
+ CLASS_DISPLAY = {
44
+ "Alzheimer": "Alzheimer",
45
+ "Intracranial_Hemorrhage": "ICH",
46
+ "Normal": "Normal",
47
+ "Stroke_Iskemik": "Ischemic Stroke",
48
+ "Tumor": "Brain Tumor",
49
+ }
50
+
51
+ CLASS_COLORS = ["#4E79A7", "#F28E2B", "#59A14F", "#E15759", "#B07AA1"]
52
+
53
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
54
+ # STRUKTUR DIREKTORI PROYEK
55
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
56
+ # Posisi src/config.py adalah di proyek/src/, maka parent-nya adalah root proyek.
57
+ BASE_DIR = Path(__file__).resolve().parent.parent
58
+
59
+ # ── Data Directories ──────────────────────────────────────────────────────
60
+ DATA_DIR = BASE_DIR / "data"
61
+ RAW_DIR = DATA_DIR / "raw"
62
+ INTERIM_DIR = DATA_DIR / "interim"
63
+ PROCESSED_DIR = DATA_DIR / "processed"
64
+ SPLITS_DIR = DATA_DIR / "splits"
65
+
66
+ # ── Output Directories ────────────────────────────────────────────────────
67
+ OUTPUT_DIR = BASE_DIR / "outputs"
68
+ CHECKPOINT_DIR = OUTPUT_DIR / "checkpoints"
69
+
70
+ # ── Hugging Face Model Repo ────────────────────────────────────────────
71
+ HF_REPO_ID = "Marksnb/brain-hybrid-efficientnet-vit"
72
+
73
+ def download_model_from_hf(filename: str):
74
+ """
75
+ Download file .pth dari Hugging Face Hub kalau belum ada lokal.
76
+ Kalau gagal (file nggak ada di repo, dll) return None supaya
77
+ main.py bisa fallback ke perilaku lama (pakai bobot pretrained).
78
+ """
79
+ from huggingface_hub import hf_hub_download
80
+ local_path = CHECKPOINT_DIR / filename
81
+ if local_path.exists():
82
+ return str(local_path)
83
+ try:
84
+ os.makedirs(CHECKPOINT_DIR, exist_ok=True)
85
+ return hf_hub_download(
86
+ repo_id=HF_REPO_ID,
87
+ filename=filename,
88
+ local_dir=str(CHECKPOINT_DIR),
89
+ )
90
+ except Exception as e:
91
+ print(f"⚠️ Gagal download '{filename}' dari Hugging Face: {e}")
92
+ return None
93
+
94
+ LOGS_DIR = OUTPUT_DIR / "logs"
95
+ FIGURES_DIR = OUTPUT_DIR / "figures"
96
+ TABLES_DIR = OUTPUT_DIR / "tables"
97
+ REPORTS_DIR = OUTPUT_DIR / "reports"
98
+
99
+ # ── File Paths Penting ────────────────────────────────────────────────────
100
+ TRAINING_LOG_FILE = LOGS_DIR / "training.log"
101
+ SPLITS_CSV_FILE = SPLITS_DIR / "train_val_test.csv"
102
+ BEST_MODEL_PATH = CHECKPOINT_DIR / "best_hybrid_model.pth"
103
+
104
+ # ─── Output Figures (nama file sesuai spesifikasi) ────────────────────────
105
+ FIG_TRAINING_PERF = FIGURES_DIR / "training_performance.png"
106
+ FIG_CONFUSION_MATRIX = FIGURES_DIR / "confusion_matrix.png"
107
+ FIG_TRAINING_SUMMARY = FIGURES_DIR / "training_summary.png"
108
+ FIG_CLASS_DIST = FIGURES_DIR / "class_distribution_before.png"
109
+ FIG_AUGMENT_COMP = FIGURES_DIR / "augmentation_comparison.png"
110
+
111
+ # ─── Output Tables & Reports ──────────────────────────────────────────────
112
+ TABLE_CLASSIF_REPORT = TABLES_DIR / "classification_report.csv"
113
+ TABLE_DATASET_DIST = TABLES_DIR / "dataset_distribution.csv"
114
+ TABLE_TRAINING_HISTORY = TABLES_DIR / "training_history.csv"
115
+ TABLE_TEST_EVAL = TABLES_DIR / "test_evaluation_results.csv"
116
+ REPORT_AUDIT_SUMMARY = REPORTS_DIR / "audit_summary.json"
117
+
118
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
119
+ # MODE TRAINING
120
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
121
+ # QUICK_TEST=True -> cuma proses 2 batch/epoch, buat tes cepat pipeline jalan/tidak
122
+ # QUICK_TEST=False -> training penuh pakai seluruh data asli (WAJIB False untuk hasil final)
123
+ QUICK_TEST = False
124
+
125
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
126
+ # TARGET AUGMENTASI (class balancing)
127
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
128
+ # Target jumlah sampel TRAIN per kelas setelah augmentasi offline.
129
+ # Total akhir = AUGMENT_TARGET_PER_CLASS x NUM_CLASSES -- TAPI HANYA kalau
130
+ # semua kelas raw < target ini. Kelas yang raw-nya sudah >= target TIDAK
131
+ # dikurangi/dipotong (augment.py cuma menambah, tidak pernah membuang data).
132
+ AUGMENT_TARGET_PER_CLASS = 19097
133
+
134
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
135
+ # INIT_FOLDERS β€” Buat Semua Direktori Otomatis
136
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
137
+ def init_folders() -> None:
138
+ """
139
+ Membuat seluruh struktur folder proyek yang diperlukan jika belum ada.
140
+ """
141
+ all_dirs = [
142
+ RAW_DIR,
143
+ INTERIM_DIR,
144
+ PROCESSED_DIR,
145
+ SPLITS_DIR,
146
+ CHECKPOINT_DIR,
147
+ LOGS_DIR,
148
+ FIGURES_DIR,
149
+ TABLES_DIR,
150
+ REPORTS_DIR,
151
+ ]
152
+ for d in all_dirs:
153
+ os.makedirs(d, exist_ok=True)
154
+
155
+
156
+ if __name__ == "__main__":
157
+ init_folders()
158
+ print("=" * 60)
159
+ print(" Modul Konfigurasi Terpusat (config.py)")
160
+ print("=" * 60)
161
+ print(f" Root proyek : {BASE_DIR}")
162
+ print(f" SEED : {SEED}")
163
+ print(f" IMG_SIZE : {IMG_SIZE}")
164
+ print(f" BATCH_SIZE : {BATCH_SIZE}")
165
+ print(f" NUM_CLASSES : {NUM_CLASSES}")
166
+ print(f" EPOCHS : {EPOCHS}")
167
+ print(" Struktur folder berhasil diperiksa/dibuat.")
168
+ print("=" * 60)
database.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import os
3
+ from src.config import DATA_DIR
4
+
5
+ DB_PATH = os.path.join(DATA_DIR, "brainscan.db")
6
+
7
+ def get_db_connection():
8
+ """Membuka koneksi ke database SQLite dan mengembalikan objek koneksi"""
9
+ conn = sqlite3.connect(DB_PATH)
10
+ conn.row_factory = sqlite3.Row # Mengembalikan hasil query sebagai dict-like object
11
+ return conn
12
+
13
+ def init_db():
14
+ """Menginisialisasi tabel-tabel di database jika belum ada"""
15
+ # Pastikan direktori database ada
16
+ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
17
+
18
+ conn = get_db_connection()
19
+ cursor = conn.cursor()
20
+
21
+ # 1. Tabel Pasien (Patients)
22
+ cursor.execute("""
23
+ CREATE TABLE IF NOT EXISTS patients (
24
+ nik TEXT PRIMARY KEY,
25
+ name TEXT NOT NULL,
26
+ age INTEGER,
27
+ birth_date TEXT,
28
+ gender TEXT,
29
+ address TEXT,
30
+ phone TEXT,
31
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
32
+ )
33
+ """)
34
+
35
+ # 2. Tabel Riwayat Scan (Scans)
36
+ cursor.execute("""
37
+ CREATE TABLE IF NOT EXISTS scans (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ patient_nik TEXT NOT NULL,
40
+ filename TEXT,
41
+ modality TEXT,
42
+ predicted_class TEXT NOT NULL,
43
+ confidence REAL NOT NULL,
44
+ radiology_report TEXT,
45
+ original_image_b64 TEXT,
46
+ heatmap_image_b64 TEXT,
47
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
48
+ FOREIGN KEY (patient_nik) REFERENCES patients(nik) ON DELETE CASCADE
49
+ )
50
+ """)
51
+
52
+ # 3. Tabel Distribusi Dataset
53
+ cursor.execute("""
54
+ CREATE TABLE IF NOT EXISTS dataset_distribution (
55
+ kelas TEXT PRIMARY KEY,
56
+ sebelum_balancing INTEGER,
57
+ setelah_balancing INTEGER
58
+ )
59
+ """)
60
+
61
+ # 4. Tabel Histori Pelatihan
62
+ cursor.execute("""
63
+ CREATE TABLE IF NOT EXISTS training_history (
64
+ epoch INTEGER PRIMARY KEY,
65
+ train_loss REAL,
66
+ val_loss REAL,
67
+ train_acc REAL,
68
+ val_acc REAL,
69
+ train_f1 REAL,
70
+ val_f1 REAL,
71
+ epoch_time_seconds REAL
72
+ )
73
+ """)
74
+
75
+ # 5. Tabel Hasil Evaluasi Uji (Confusion Matrix)
76
+ cursor.execute("""
77
+ CREATE TABLE IF NOT EXISTS test_evaluation_results (
78
+ actual_class TEXT,
79
+ predicted_class TEXT,
80
+ count INTEGER,
81
+ PRIMARY KEY (actual_class, predicted_class)
82
+ )
83
+ """)
84
+
85
+ conn.commit()
86
+ conn.close()
87
+ print(f"Database berhasil diinisialisasi di: {DB_PATH}")
88
+
89
+ def save_dataset_distribution_to_db(df):
90
+ """Menyimpan data distribusi dataset ke database"""
91
+ conn = get_db_connection()
92
+ cursor = conn.cursor()
93
+ cursor.execute("DELETE FROM dataset_distribution")
94
+ for _, row in df.iterrows():
95
+ cursor.execute("""
96
+ INSERT INTO dataset_distribution (kelas, sebelum_balancing, setelah_balancing)
97
+ VALUES (?, ?, ?)
98
+ """, (row["Kelas"], int(row["Sebelum_Balancing"]), int(row["Setelah_Balancing"])))
99
+ conn.commit()
100
+ conn.close()
101
+
102
+ def save_training_history_to_db(df):
103
+ """Menyimpan data histori training ke database"""
104
+ conn = get_db_connection()
105
+ cursor = conn.cursor()
106
+ cursor.execute("DELETE FROM training_history")
107
+ for _, row in df.iterrows():
108
+ cursor.execute("""
109
+ INSERT INTO training_history (epoch, train_loss, val_loss, train_acc, val_acc, train_f1, val_f1, epoch_time_seconds)
110
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
111
+ """, (int(row["Epoch"]), float(row["Train_Loss"]), float(row["Val_Loss"]), float(row["Train_Acc"]), float(row["Val_Acc"]), float(row["Train_F1"]), float(row["Val_F1"]), float(row["Epoch_Time_Seconds"])))
112
+ conn.commit()
113
+ conn.close()
114
+
115
+ def save_test_evaluation_to_db(cm, classes):
116
+ """Menyimpan data confusion matrix ke database"""
117
+ conn = get_db_connection()
118
+ cursor = conn.cursor()
119
+ cursor.execute("DELETE FROM test_evaluation_results")
120
+ for i, act_cls in enumerate(classes):
121
+ for j, pred_cls in enumerate(classes):
122
+ cursor.execute("""
123
+ INSERT INTO test_evaluation_results (actual_class, predicted_class, count)
124
+ VALUES (?, ?, ?)
125
+ """, (act_cls, pred_cls, int(cm[i, j])))
126
+ conn.commit()
127
+ conn.close()
128
+
129
+ def upsert_patient(nik, name, age=None, birth_date=None, gender=None, address=None, phone=None):
130
+ """Menyisipkan atau memperbarui data pasien berdasarkan NIK"""
131
+ conn = get_db_connection()
132
+ cursor = conn.cursor()
133
+ cursor.execute("""
134
+ INSERT INTO patients (nik, name, age, birth_date, gender, address, phone)
135
+ VALUES (?, ?, ?, ?, ?, ?, ?)
136
+ ON CONFLICT(nik) DO UPDATE SET
137
+ name = excluded.name,
138
+ age = excluded.age,
139
+ birth_date = excluded.birth_date,
140
+ gender = excluded.gender,
141
+ address = excluded.address,
142
+ phone = excluded.phone
143
+ """, (nik, name, age, birth_date, gender, address, phone))
144
+ conn.commit()
145
+ conn.close()
146
+
147
+ def get_patient(nik):
148
+ """Mengambil informasi pasien berdasarkan NIK"""
149
+ conn = get_db_connection()
150
+ cursor = conn.cursor()
151
+ cursor.execute("SELECT * FROM patients WHERE nik = ?", (nik,))
152
+ row = cursor.fetchone()
153
+ conn.close()
154
+ if row:
155
+ return dict(row)
156
+ return None
157
+
158
+ def add_scan_record(patient_nik, filename, modality, predicted_class, confidence, report_text, original_b64=None, heatmap_b64=None):
159
+ """Menyimpan data riwayat pemeriksaan scan otak"""
160
+ conn = get_db_connection()
161
+ cursor = conn.cursor()
162
+ cursor.execute("""
163
+ INSERT INTO scans (patient_nik, filename, modality, predicted_class, confidence, radiology_report, original_image_b64, heatmap_image_b64)
164
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
165
+ """, (patient_nik, filename, modality, predicted_class, confidence, report_text, original_b64, heatmap_b64))
166
+ conn.commit()
167
+ conn.close()
168
+
169
+ def get_patient_history(nik):
170
+ """Mengambil riwayat scan dari pasien tertentu berdasarkan NIK"""
171
+ conn = get_db_connection()
172
+ cursor = conn.cursor()
173
+ cursor.execute("""
174
+ SELECT s.*, p.name, p.age, p.gender, p.birth_date, p.address, p.phone
175
+ FROM scans s
176
+ JOIN patients p ON s.patient_nik = p.nik
177
+ WHERE s.patient_nik = ?
178
+ ORDER BY s.created_at DESC
179
+ """, (nik,))
180
+ rows = cursor.fetchall()
181
+ conn.close()
182
+ return [dict(row) for row in rows]
183
+
184
+ # Inisialisasi DB saat modul di-import pertama kali
185
+ init_db()
explainability.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+ import numpy as np
4
+ import matplotlib.pyplot as plt
5
+ from PIL import Image
6
+ from src.preprocess import val_transforms
7
+ from src.models.classifier_model import BrainHybridModel
8
+ from src.config import OUTPUT_DIR, CHECKPOINT_DIR
9
+
10
+ def generate_attention_heatmap(image_path, save_name="attention_map.png"):
11
+ """Menghasilkan peta panas (heatmap) fokus perhatian model AI pada gambar otak"""
12
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
13
+
14
+ # 1. Muat dan ubah gambar menjadi tensor
15
+ orig_image = Image.open(image_path).convert('RGB')
16
+ tensor_image = val_transforms(orig_image).unsqueeze(0).to(device)
17
+
18
+ # 2. Muat model dan bobot terbaik
19
+ model = BrainHybridModel().to(device)
20
+ checkpoint_path = os.path.join(CHECKPOINT_DIR, "best_hybrid_model.pth")
21
+ if os.path.exists(checkpoint_path):
22
+ try:
23
+ model.load_state_dict(torch.load(checkpoint_path, map_location=device))
24
+ except RuntimeError:
25
+ print("Warning: Checkpoint tidak kompatibel dengan arsitektur ViT baru, menggunakan bobot pretrained bawaan.")
26
+ model.eval()
27
+
28
+ # 3. Ekstraksi attention weights dari ViT (custom Transformer block terakhir)
29
+ with torch.no_grad():
30
+ # forward_with_attention mengembalikan (logits, attn) dari block terakhir
31
+ # attn shape: [B, num_heads, seq_len, seq_len]
32
+ _, attentions = model.forward_with_attention(tensor_image)
33
+
34
+ # Rata-ratakan semua attention heads
35
+ avg_attn = attentions.squeeze(0).mean(dim=0) # [seq_len, seq_len]
36
+
37
+ # Ambil attention dari CLS token (index 0) ke semua patch tokens
38
+ cls_attn = avg_attn[0, 1:] # [num_patches] (buang CLS-to-CLS)
39
+
40
+ # Feature map EfficientNet-B3 di 224x224 -> grid 7x7 = 49 patch tokens
41
+ num_patches = int(cls_attn.shape[0] ** 0.5)
42
+ heatmap = cls_attn.reshape(num_patches, num_patches).cpu().numpy()
43
+
44
+ # Normalisasi peta panas antara nilai 0 hingga 1
45
+ heatmap = np.maximum(heatmap, 0)
46
+ heatmap /= np.max(heatmap) if np.max(heatmap) != 0 else 1.0
47
+
48
+ # 4. Gambar dan gabungkan citra asli dengan peta panas
49
+ fig, axes = plt.subplots(1, 2, figsize=(10, 5))
50
+ axes[0].imshow(orig_image)
51
+ axes[0].set_title("Gambar Medis Asli")
52
+ axes[0].axis('off')
53
+
54
+ # Ubah ukuran peta panas agar pas dengan dimensi gambar asli
55
+ heatmap_resized = np.array(Image.fromarray(heatmap).resize(orig_image.size, Image.Resampling.BILINEAR))
56
+
57
+ axes[1].imshow(orig_image)
58
+ axes[1].imshow(heatmap_resized, cmap='jet', alpha=0.4) # Overlay warna transparan
59
+ axes[1].set_title("Peta Fokus Atensi AI (ViT Attention)")
60
+ axes[1].axis('off')
61
+
62
+ # Simpan visualisasi ke folder outputs/figures/
63
+ figure_dir = os.path.join(OUTPUT_DIR, "figures")
64
+ os.makedirs(figure_dir, exist_ok=True)
65
+ save_path = os.path.join(figure_dir, save_name)
66
+ plt.savefig(save_path, bbox_inches='tight')
67
+ plt.close()
68
+ print(f"Sukses menghasilkan peta eksplanabilitas AI! Tersimpan di: {save_path}")
69
+
70
+ if __name__ == "__main__":
71
+ # Mencari satu contoh gambar acak dari folder normal untuk uji coba modul
72
+ sample_dir = "data/raw/Normal"
73
+ if os.path.exists(sample_dir) and os.listdir(sample_dir):
74
+ first_img = os.listdir(sample_dir)[0]
75
+ full_path = os.path.join(sample_dir, first_img)
76
+ generate_attention_heatmap(full_path)
77
+ else:
78
+ print("Folder data/raw/Normal kosong atau tidak ditemukan untuk pengujian.")
79
+
80
+
fusion_blocks.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+
4
+ class CrossAttentionFusion(nn.Module):
5
+ """
6
+ Fuses features from two different sources (e.g. CNN features and ViT/Transformer features)
7
+ using Cross-Attention mechanism.
8
+ """
9
+ def __init__(self, d_model=1280, nhead=8, dropout=0.1):
10
+ super().__init__()
11
+ self.multihead_attn = nn.MultiheadAttention(embed_dim=d_model, num_heads=nhead, dropout=dropout, batch_first=True)
12
+ self.norm = nn.LayerNorm(d_model)
13
+ self.dropout = nn.Dropout(dropout)
14
+
15
+ def forward(self, query, key, value):
16
+ # query, key, value shape: [batch_size, seq_len, d_model]
17
+ attn_output, _ = self.multihead_attn(query, key, value)
18
+ x = query + self.dropout(attn_output)
19
+ x = self.norm(x)
20
+ return x
21
+
22
+ class SimpleConcatFusion(nn.Module):
23
+ """
24
+ Simple concatenation of features followed by linear projection.
25
+ """
26
+ def __init__(self, in_features1, in_features2, out_features):
27
+ super().__init__()
28
+ self.fc = nn.Linear(in_features1 + in_features2, out_features)
29
+ self.relu = nn.ReLU()
30
+ self.dropout = nn.Dropout(0.3)
31
+
32
+ def forward(self, feat1, feat2):
33
+ x = torch.cat([feat1, feat2], dim=-1)
34
+ x = self.fc(x)
35
+ x = self.relu(x)
36
+ x = self.dropout(x)
37
+ return x
gemini_client.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import google.generativeai as genai
3
+ from src.config import CLASSES, GEMINI_API_KEY as CONFIG_API_KEY
4
+ from src.prompts import build_radiology_prompt
5
+
6
+ # Inisialisasi API Key Gemini
7
+ # Prioritas: 1) config.py GEMINI_API_KEY 2) Environment Variable 3) Fallback lokal
8
+ api_key = CONFIG_API_KEY or os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
9
+ if api_key:
10
+ genai.configure(api_key=api_key)
11
+ print("Gemini API Client berhasil dikonfigurasi menggunakan API Key.")
12
+ else:
13
+ print("Warning: GEMINI_API_KEY tidak ditemukan di config.py maupun environment. Mengaktifkan sistem laporan lokal fallback.")
14
+
15
+ def generate_radiology_report(predicted_idx, confidence, modality="MRI"):
16
+ """
17
+ Menghasilkan laporan radiologi detail.
18
+ Mencoba menggunakan API Gemini terlebih dahulu, dan menggunakan generator lokal jika API tidak tersedia.
19
+ """
20
+ class_name = CLASSES[predicted_idx]
21
+
22
+ # 1. Definisikan detail pola visual dan area atensi untuk prompt
23
+ suspected_patterns = {
24
+ "Alzheimer": "Atrofi kortikal difus bilateral, pelebaran sulkus otak, dan pembesaran sistem ventrikel (ventrikulomegali) kompensatorik.",
25
+ "Intracranial_Hemorrhage": "Lesi hiperdens akut (fokus pendarahan aktif) intraaksial/ekstraaksial dengan potensi efek massa.",
26
+ "Normal": "Arsitektur parenkim otak normal, batas substansia alba-grisea tegas, sistem ventrikel dan sulkus kortikal dalam batas normal.",
27
+ "Stroke_Iskemik": "Area hipodensitas fokal (infark serebri) akut/subakut yang sesuai dengan vaskularisasi arteri serebri tertentu.",
28
+ "Tumor": "Massa soliter/multipel intraaksial dengan edema perifokal (vasogenik) luas serta pendesakan garis tengah (midline shift)."
29
+ }
30
+
31
+ attention_regions = {
32
+ "Alzheimer": "Lobus Temporal Medial, terutama daerah Hipokampus bilateral.",
33
+ "Intracranial_Hemorrhage": "Parenkim serebral (lobus frontal/temporal) atau ruang subdural/epidural.",
34
+ "Normal": "Seluruh parenkim serebri dan serebelum secara simetris.",
35
+ "Stroke_Iskemik": "Korteks serebri atau ganglia basalis (terutama teritori Arteri Serebri Media/MCA).",
36
+ "Tumor": "Lobus frontal/parietal parenkim serebral, atau fosa posterior serebelum."
37
+ }
38
+
39
+ result_data = {
40
+ "predicted_class": class_name,
41
+ "confidence": f"{confidence:.2f}%",
42
+ "visual_summary": {
43
+ "modality": modality,
44
+ "suspected_pattern": suspected_patterns.get(class_name, "Pola visual atipikal."),
45
+ "attention_region": attention_regions.get(class_name, "Area kortikal serebri.")
46
+ }
47
+ }
48
+
49
+ # Jika API key Gemini tersedia, panggil model Gemini
50
+ if api_key:
51
+ try:
52
+ prompt = build_radiology_prompt(result_data)
53
+ # Menggunakan gemini-1.5-flash untuk respon cepat dan andal
54
+ model = genai.GenerativeModel("gemini-1.5-flash")
55
+ response = model.generate_content(prompt)
56
+ if response.text:
57
+ return response.text.strip()
58
+ except Exception as e:
59
+ print(f"Gagal memanggil Gemini API: {str(e)}. Menggunakan laporan medis lokal.")
60
+
61
+ # 2. Generator Laporan Medis Lokal Fallback (Sangat Detail dan Profesional)
62
+ return get_local_fallback_report(class_name, confidence, modality, suspected_patterns[class_name], attention_regions[class_name])
63
+
64
+ def get_local_fallback_report(class_name, confidence, modality, pattern, region):
65
+ """Menghasilkan teks laporan medis radiologi standar yang terstruktur"""
66
+
67
+ indonesian_names = {
68
+ "Alzheimer": "Penyakit Alzheimer / Atrofi Serebral Kronis",
69
+ "Intracranial_Hemorrhage": "Pendarahan Intrakranial (Intracranial Hemorrhage)",
70
+ "Normal": "Kondisi Otak Normal (Normal Brain Scan)",
71
+ "Stroke_Iskemik": "Stroke Iskemik (Infark Serebri)",
72
+ "Tumor": "Neoplasma Otak / Tumor Serebral"
73
+ }
74
+
75
+ display_name = indonesian_names.get(class_name, class_name)
76
+
77
+ report = f"""LAPORAN RADIOLOGI EVALUASI SCAN OTAK
78
+ Modalitas Pemeriksaan : {modality}-Scan Kepala (Brain Imaging)
79
+ Identitas Temuan : Deteksi Otomatis Sistem AI Terintegrasi
80
+ Status AI : Analisis Selesai (Confidence Level: {confidence:.2f}%)
81
+ Dugaan Klinis Utama : {display_name}
82
+
83
+ 1. RINGKASAN TEMUAN (FINDINGS SUMMARY):
84
+ Pemeriksaan penunjang citra {modality} kepala menunjukkan adanya indikasi kelainan terfokus. Peta visual atensi AI (XAI) mendeteksi fokus anomali pada: {region}.
85
+ Pola visual yang dominan diidentifikasi sebagai: '{pattern}'.
86
+
87
+ 2. ANALISIS KLINIS (CLINICAL ANALYSIS):"""
88
+
89
+ if class_name == "Alzheimer":
90
+ report += """
91
+ * Tampak adanya reduksi volume parenkim otak global yang signifikan (Atrofi Serebral).
92
+ * Sulkus kortikal serebri melebar disertai pendalaman girus serebri secara difus, menonjol di lobus temporal dan parietal.
93
+ * Sistem ventrikel lateral kiri dan kanan melebar simetris, konsisten dengan hydrocephalus ex-vacuo sekunder akibat hilangnya jaringan otak.
94
+ * Tidak ditemukan tanda-tanda pendarahan akut maupun space-occupying lesion (massa tumor)."""
95
+
96
+ elif class_name == "Intracranial_Hemorrhage":
97
+ report += """
98
+ * Tampak visualisasi area lesi densitas tinggi (hiperdens) homogen lokal pada jaringan parenkim otak.
99
+ * Lesi disertai visualisasi edema perifokal (ring-like edema) tipis di sekitarnya.
100
+ * Terdeteksi sedikit efek massa lokal berupa kompresi ringan pada sulkus serebri yang bersebelahan.
101
+ * Rekomendasi perhatian ketat terhadap potensi peningkatan tekanan intrakranial (TIK)."""
102
+
103
+ elif class_name == "Normal":
104
+ report += """
105
+ * Parenkim serebri dan serebelum menunjukkan intensitas dan struktur sinyal homogen normal.
106
+ * Batas substansia alba (white matter) dan grisea (grey matter) tegas dan simetris di kedua hemisfer serebri.
107
+ * Sistem ventrikel lateral, ventrikel III, dan IV berada dalam posisi sentral dengan ukuran dan konfigurasi normal.
108
+ * Tidak tampak area hipodensitas lokal (infark serebri), pendarahan intrakranial, maupun massa neoplasma yang dicurigai."""
109
+
110
+ elif class_name == "Stroke_Iskemik":
111
+ report += """
112
+ * Terdeteksi area lesi fokal hipodensitas parenkim serebri yang menunjukkan batas kurang tegas di daerah korteks/subkorteks.
113
+ * Visualisasi lesi selaras dengan batas vaskularisasi suplai darah serebral, menandakan adanya hambatan perfusi arteri (iskemia akut/subakut).
114
+ * Ditemukan edema sitotoksik minimal di area terinfark tanpa pergeseran midline serebri yang signifikan."""
115
+
116
+ elif class_name == "Tumor":
117
+ report += """
118
+ * Tampak massa lesi berbatas tegas dengan kontur ireguler yang menempati ruang serebral (space-occupying lesion).
119
+ * Lesi dikelilingi oleh area edema vasogenik luas yang menekan sulkus kortikal dan jaringan parenkim sekitarnya.
120
+ * Tampak kompresi parsial pada tanduk ventrikel lateral homolateral serta pergeseran minor garis tengah serebral (midline shift)."""
121
+
122
+ report += f"""
123
+
124
+ 3. TINGKAT KEYAKINAN (CONFIDENCE LEVEL ANALYSIS):
125
+ * Model klasifikasi Vision Transformer (Marksnb/brain-hybrid-efficientnet-vit) mendeteksi tanda visual penyakit '{class_name}' dengan tingkat keyakinan {confidence:.2f}%.
126
+ * Keakuratan klasifikasi divalidasi oleh cross-attention map yang secara presisi mengunci koordinat lesi di {region}.
127
+
128
+ 4. REKOMENDASI PEMERIKSAAN LANJUTAN (RECOMMENDATIONS):"""
129
+
130
+ if class_name == "Alzheimer":
131
+ report += """
132
+ * Disarankan melakukan korelasi klinis melalui asesmen kognitif menyeluruh (MMSE, MoCA).
133
+ * Evaluasi lanjutan dengan MRI resolusi tinggi (3T) sekuens volumetri hipokampus untuk menilai tingkat atrofi secara kuantitatif."""
134
+ elif class_name == "Intracranial_Hemorrhage":
135
+ report += """
136
+ * Diperlukan tindakan darurat (cito) konsultasi dengan Dokter Spesialis Bedah Saraf.
137
+ * Direkomendasikan CT-Scan non-kontras evaluasi ulang berkala (serial scan) dalam 12-24 jam untuk memantau perkembangan volume pendarahan."""
138
+ elif class_name == "Normal":
139
+ report += """
140
+ * Tidak diperlukan evaluasi radiologi lanjutan segera jika tidak terdapat kecurigaan gejala klinis baru.
141
+ * Disarankan kontrol berkala sesuai dengan anjuran dokter pengirim."""
142
+ elif class_name == "Stroke_Iskemik":
143
+ report += """
144
+ * Disarankan melakukan CT-Angiografi (CTA) atau MR-Angiografi (MRA) segera untuk menilai patensi pembuluh darah serebral.
145
+ * Pemeriksaan MRI kepala sekuens DWI/ADC (Diffusion-Weighted Imaging) untuk konfirmasi area infark akut (ischemic penumbra)."""
146
+ elif class_name == "Tumor":
147
+ report += """
148
+ * Segera konsultasikan ke Spesialis Bedah Saraf / Onkologi.
149
+ * Direkomendasikan pemeriksaan MRI kepala dengan kontras (gadolinium) sekuens multiplanar untuk detail morfologi neoplasma.
150
+ * Rencanakan biopsi histopatologi untuk penentuan stadium dan jenis sel tumor secara definitif."""
151
+
152
+ report += "\n\n*Catatan: Laporan ini dihasilkan secara otomatis oleh sistem kecerdasan buatan (AI) sebagai opini sekunder radiologi. Hasil akhir harus selalu divalidasi dan ditandatangani oleh Dokter Spesialis Radiologi (Sp.Rad).*"
153
+ return report
main.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+ import datetime
4
+ import torch.nn.functional as F
5
+ from fastapi import FastAPI, UploadFile, File, HTTPException, Form
6
+ from fastapi.staticfiles import StaticFiles
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from PIL import Image
9
+ import io
10
+ import shutil
11
+ import base64
12
+ from pydantic import BaseModel
13
+
14
+ from src.config import CLASSES, OUTPUT_DIR, CHECKPOINT_DIR, download_model_from_hf
15
+ from src.preprocess import val_transforms, precheck_transforms
16
+ from src.models.precheck_model import BrainPreCheckModel
17
+ from src.models.classifier_model import BrainHybridModel
18
+ from src.gemini_client import generate_radiology_report
19
+ from src.explainability import generate_attention_heatmap
20
+ from src.database import upsert_patient, get_patient, add_scan_record, get_patient_history
21
+
22
+ # Inisialisasi Aplikasi FastAPI
23
+ app = FastAPI(
24
+ title="BrainScan AI Framework API",
25
+ description="API untuk analisis otomatis CT-Scan & MRI menggunakan arsitektur Hybrid CNN-Transformer",
26
+ version="1.0"
27
+ )
28
+
29
+ # Model Data Pydantic untuk Input Pasien
30
+ class PatientCreate(BaseModel):
31
+ nik: str
32
+ name: str
33
+ age: int = None
34
+ birth_date: str = None
35
+ gender: str = None
36
+ address: str = None
37
+ phone: str = None
38
+
39
+
40
+ # Aktifkan CORS agar frontend dapat berkomunikasi dengan lancar
41
+ app.add_middleware(
42
+ CORSMiddleware,
43
+ allow_origins=["*"],
44
+ allow_credentials=True,
45
+ allow_methods=["*"],
46
+ allow_headers=["*"],
47
+ )
48
+
49
+ # Atur perangkat keras (GPU jika ada, jika tidak gunakan CPU)
50
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
51
+
52
+ _HARI_ID = ["Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Minggu"]
53
+ _BULAN_ID = ["", "Januari", "Februari", "Maret", "April", "Mei", "Juni",
54
+ "Juli", "Agustus", "September", "Oktober", "November", "Desember"]
55
+
56
+
57
+ def tanggal_indonesia_sekarang() -> str:
58
+ """Format tanggal sekarang, misal: 'Selasa, 7 Juli 2026' β€” bukan tanggal tetap."""
59
+ now = datetime.datetime.now()
60
+ return f"{_HARI_ID[now.weekday()]}, {now.day} {_BULAN_ID[now.month]} {now.year}"
61
+
62
+ # Buat folder output yang diperlukan
63
+ os.makedirs(os.path.join(OUTPUT_DIR, "figures"), exist_ok=True)
64
+ os.makedirs("temp_uploads", exist_ok=True)
65
+
66
+ # Muat model-model AI secara global pada startup server
67
+ precheck_model = None
68
+ hybrid_model = None
69
+
70
+ try:
71
+ print("⏳ Memuat model Precheck...")
72
+ precheck_model = BrainPreCheckModel().to(device)
73
+ precheck_checkpoint = download_model_from_hf("best_precheck_model.pth") or os.path.join(CHECKPOINT_DIR, "best_precheck_model.pth")
74
+ if os.path.exists(precheck_checkpoint):
75
+ try:
76
+ precheck_model.load_state_dict(torch.load(precheck_checkpoint, map_location=device))
77
+ print(f"Sukses memuat bobot model Precheck dari {precheck_checkpoint}")
78
+ except RuntimeError as e:
79
+ print(f"Warning: Checkpoint precheck tidak kompatibel dengan arsitektur EfficientNet-B0 baru, menggunakan bobot pretrained bawaan.")
80
+ else:
81
+ print("Warning: best_precheck_model.pth tidak ditemukan, menggunakan bobot pretrained bawaan.")
82
+ precheck_model.eval()
83
+
84
+ print("Memuat model Utama Hybrid...")
85
+ hybrid_model = BrainHybridModel().to(device)
86
+ hybrid_checkpoint = download_model_from_hf("hybrid_vit_efficientnet_brain_best.pth") or os.path.join(CHECKPOINT_DIR, "best_hybrid_model.pth")
87
+ if os.path.exists(hybrid_checkpoint):
88
+ try:
89
+ ckpt = torch.load(hybrid_checkpoint, map_location=device)
90
+ if isinstance(ckpt, dict) and "model_state_dict" in ckpt:
91
+ hybrid_model.load_state_dict(ckpt["model_state_dict"])
92
+ else:
93
+ hybrid_model.load_state_dict(ckpt)
94
+ print(f"Sukses memuat bobot model Classifier utama dari {hybrid_checkpoint}")
95
+ except RuntimeError as e:
96
+ print(f"Warning: Checkpoint classifier tidak kompatibel dengan arsitektur baru: {str(e)}. Menggunakan bobot pretrained bawaan.")
97
+ else:
98
+ print("Warning: best_hybrid_model.pth tidak ditemukan, menggunakan bobot pretrained bawaan.")
99
+ hybrid_model.eval()
100
+ print("Seluruh model AI berhasil dimuat.")
101
+ except Exception as e:
102
+ print(f"Gagal memuat model AI: {str(e)}")
103
+
104
+ @app.get("/api/status")
105
+ def get_status():
106
+ """Mengecek status online server dan ketersediaan model AI"""
107
+ return {
108
+ "status": "Online",
109
+ "precheck_model_loaded": precheck_model is not None,
110
+ "classifier_model_loaded": hybrid_model is not None,
111
+ "device": str(device)
112
+ }
113
+
114
+ @app.post("/api/patients/")
115
+ def register_patient(patient: PatientCreate):
116
+ """Menyimpan atau memperbarui data profil pasien"""
117
+ try:
118
+ upsert_patient(
119
+ nik=patient.nik,
120
+ name=patient.name,
121
+ age=patient.age,
122
+ birth_date=patient.birth_date,
123
+ gender=patient.gender,
124
+ address=patient.address,
125
+ phone=patient.phone
126
+ )
127
+ return {"status": "Success", "message": "Data pasien berhasil disimpan."}
128
+ except Exception as e:
129
+ raise HTTPException(status_code=500, detail=f"Gagal menyimpan data pasien: {str(e)}")
130
+
131
+ @app.get("/api/patients/{nik}")
132
+ def get_patient_info(nik: str):
133
+ """Mengambil data pasien berdasarkan NIK"""
134
+ patient = get_patient(nik)
135
+ if not patient:
136
+ raise HTTPException(status_code=404, detail="Pasien tidak ditemukan.")
137
+ return {"status": "Success", "patient": patient}
138
+
139
+ @app.get("/api/patients/{nik}/history")
140
+ def get_patient_scans_history(nik: str):
141
+ """Mengambil riwayat scan pasien berdasarkan NIK"""
142
+ try:
143
+ history = get_patient_history(nik)
144
+ return {"status": "Success", "history": history}
145
+ except Exception as e:
146
+ raise HTTPException(status_code=500, detail=f"Gagal mengambil riwayat scan: {str(e)}")
147
+
148
+ @app.post("/api/analyze/")
149
+ async def analyze_brain_image(file: UploadFile = File(...), patient_nik: str = Form(None)):
150
+ """
151
+ Endpoint utama untuk mengunggah gambar scan otak, menjalankan pre-check,
152
+ menjalankan klasifikasi penyakit, memvisualisasikan atensi model (XAI),
153
+ dan menghasilkan laporan radiologi AI.
154
+ """
155
+ # 1. Validasi Ekstensi File
156
+ if not file.filename.lower().endswith(('.png', '.jpg', '.jpeg')):
157
+
158
+ raise HTTPException(status_code=400, detail="Format file harus berupa gambar (PNG, JPG, JPEG).")
159
+
160
+ try:
161
+ # 2. Simpan file unggahan sementara untuk visualisasi heatmap
162
+ temp_file_path = os.path.join("temp_uploads", file.filename)
163
+ with open(temp_file_path, "wb") as buffer:
164
+ shutil.copyfileobj(file.file, buffer)
165
+
166
+ # 3. Baca gambar untuk pemrosesan tensor PyTorch
167
+ image = Image.open(temp_file_path).convert("RGB")
168
+ # Dua tensor terpisah: precheck pakai normalisasi [0.5,0.5,0.5]
169
+ # (sesuai cara dia dilatih), hybrid pakai normalisasi ImageNet
170
+ # (sesuai cara model utama dilatih di notebook)
171
+ precheck_tensor = precheck_transforms(image).unsqueeze(0).to(device)
172
+ tensor_image = val_transforms(image).unsqueeze(0).to(device)
173
+
174
+ # 4. TAHAP 1: Precheck (Menyaring Gambar Valid Brain Scan vs Gambar Noise/Invalid)
175
+ is_valid = True
176
+ precheck_prob_val = 0.99
177
+ if precheck_model is not None:
178
+ with torch.no_grad():
179
+ precheck_outputs = precheck_model(precheck_tensor)
180
+ precheck_prob = F.softmax(precheck_outputs, dim=1)
181
+ is_valid_idx = torch.argmax(precheck_prob, dim=1).item()
182
+ precheck_prob_val = precheck_prob[0][is_valid_idx].item()
183
+ # Indeks 1: Valid, Indeks 0: Invalid (Sesuai dengan dataset latihan precheck)
184
+ is_valid = (is_valid_idx == 1)
185
+
186
+ # Jika gambar dinyatakan invalid, hentikan proses analisis awal
187
+ if not is_valid:
188
+ # Hapus file sementara
189
+ if os.path.exists(temp_file_path):
190
+ os.remove(temp_file_path)
191
+ return {
192
+ "status": "Invalid",
193
+ "filename": file.filename,
194
+ "message": "Gambar tidak dikenali sebagai scan otak yang valid (CT-Scan/MRI). Hubungi Administrator.",
195
+ "precheck_confidence": f"{precheck_prob_val * 100:.2f}%"
196
+ }
197
+
198
+ # 5. TAHAP 2: Klasifikasi Utama (5 Kelas Penyakit Otak)
199
+ if hybrid_model is None:
200
+ raise HTTPException(status_code=500, detail="Model utama klasifikasi tidak termuat di server.")
201
+
202
+ with torch.no_grad():
203
+ hybrid_outputs = hybrid_model(tensor_image)
204
+ hybrid_prob = F.softmax(hybrid_outputs, dim=1)
205
+ confidence, predicted_idx = torch.max(hybrid_prob, dim=1)
206
+
207
+ confidence_score = confidence.item() * 100
208
+ predicted_class = CLASSES[predicted_idx.item()]
209
+
210
+ # 6. TAHAP 3: Eksplanabilitas AI (XAI) - Hasilkan Peta Atensi Heatmap
211
+ heatmap_filename = f"heatmap_{os.path.splitext(file.filename)[0]}.png"
212
+ generate_attention_heatmap(temp_file_path, save_name=heatmap_filename)
213
+
214
+ # 7. TAHAP 4: Kirim Hasil Ke Gemini / Laporan Lokal
215
+ modality = "CT" if "ct" in file.filename.lower() else "MRI"
216
+ report_text = generate_radiology_report(predicted_idx.item(), confidence_score, modality)
217
+
218
+ # 8. Encode gambar visualisasi heatmap dan gambar asli menjadi base64 untuk dikirim langsung ke frontend
219
+ # Ini mencegah isu caching browser pada pemuatan statis
220
+ heatmap_path = os.path.join(OUTPUT_DIR, "figures", heatmap_filename)
221
+
222
+ # Baca visualisasi heatmap
223
+ with open(heatmap_path, "rb") as img_file:
224
+ heatmap_base64 = base64.b64encode(img_file.read()).decode('utf-8')
225
+
226
+ # Baca gambar asli
227
+ with open(temp_file_path, "rb") as img_file:
228
+ original_base64 = base64.b64encode(img_file.read()).decode('utf-8')
229
+
230
+ # Hapus file sementara setelah diproses
231
+ if os.path.exists(temp_file_path):
232
+ os.remove(temp_file_path)
233
+
234
+ # Simpan ke database jika patient_nik tersedia
235
+ if patient_nik:
236
+ try:
237
+ add_scan_record(
238
+ patient_nik=patient_nik,
239
+ filename=file.filename,
240
+ modality=modality,
241
+ predicted_class=predicted_class,
242
+ confidence=confidence_score,
243
+ report_text=report_text,
244
+ original_b64=f"data:image/png;base64,{original_base64}",
245
+ heatmap_b64=f"data:image/png;base64,{heatmap_base64}"
246
+ )
247
+ except Exception as db_err:
248
+ print(f"Gagal menyimpan riwayat scan ke database: {str(db_err)}")
249
+
250
+ # 9. Kembalikan respons akhir dalam format JSON
251
+ return {
252
+ "status": "Valid",
253
+ "filename": file.filename,
254
+ "modality_detected": modality,
255
+ "prediction": {
256
+ "class_name": predicted_class,
257
+ "class_index": predicted_idx.item(),
258
+ "confidence": f"{confidence_score:.2f}%"
259
+ },
260
+ "radiology_report": report_text,
261
+ "original_image_b64": f"data:image/png;base64,{original_base64}",
262
+ "heatmap_image_b64": f"data:image/png;base64,{heatmap_base64}"
263
+ }
264
+
265
+ except Exception as e:
266
+ # Bersihkan jika ada file sementara tersisa
267
+ if 'temp_file_path' in locals() and os.path.exists(temp_file_path):
268
+ os.remove(temp_file_path)
269
+ raise HTTPException(status_code=500, detail=f"Terjadi kesalahan internal analisis: {str(e)}")
270
+
271
+
272
+ from fastapi.responses import StreamingResponse
273
+ from fpdf import FPDF
274
+
275
+ class PDFDownloadRequest(BaseModel):
276
+ patient_name: str
277
+ patient_age: str
278
+ patient_gender: str
279
+ patient_nik: str
280
+ patient_birth_date: str = ""
281
+ patient_address: str = ""
282
+ patient_phone: str = ""
283
+ report_text: str
284
+
285
+ @app.post("/api/download-pdf/")
286
+ def download_pdf(data: PDFDownloadRequest):
287
+ try:
288
+ pdf = FPDF()
289
+ pdf.add_page()
290
+ pdf.set_font("helvetica", size=10)
291
+
292
+ # 1. Header (Kop Surat)
293
+ pdf.set_font("helvetica", "B", 14)
294
+ pdf.cell(0, 8, "PUSAT RADIOLOGI DIGITAL & DIAGNOSTIK AI", new_x="LMARGIN", new_y="NEXT", align="C")
295
+ pdf.set_font("helvetica", size=9)
296
+ pdf.cell(0, 5, "Jl. Semilasari Barat No. 88, Sektor Kecerdasan Buatan, Denpasar", new_x="LMARGIN", new_y="NEXT", align="C")
297
+ pdf.cell(0, 5, "Email: support@brainscan.ai | Telp: (021) 555-2026", new_x="LMARGIN", new_y="NEXT", align="C")
298
+
299
+ # Line divider
300
+ pdf.ln(3)
301
+ pdf.line(10, pdf.get_y(), 200, pdf.get_y())
302
+ pdf.ln(5)
303
+
304
+ # 2. Document Title
305
+ pdf.set_font("helvetica", "B", 12)
306
+ pdf.cell(0, 7, "DOKUMEN LAPORAN HASIL PEMERIKSAAN RADIOLOGI (OPINI AI)", new_x="LMARGIN", new_y="NEXT", align="C")
307
+ pdf.ln(4)
308
+
309
+ # 3. Patient Details
310
+ pdf.set_font("helvetica", "B", 10)
311
+ pdf.cell(0, 6, "I. IDENTITAS PASIEN & PEMERIKSAAN", new_x="LMARGIN", new_y="NEXT")
312
+ pdf.set_font("helvetica", size=9)
313
+
314
+ # Create key-value table
315
+ details = [
316
+ ("Nama Pasien", data.patient_name, "Jenis Kelamin", data.patient_gender),
317
+ ("Umur", f"{data.patient_age} Tahun", "Tanggal Lahir", data.patient_birth_date),
318
+ ("NIK Pasien", data.patient_nik, "No. Telepon", data.patient_phone),
319
+ ("Alamat", data.patient_address, "Tanggal Analisis", tanggal_indonesia_sekarang())
320
+ ]
321
+
322
+ col_width = 40
323
+ val_width = 55
324
+ for row in details:
325
+ pdf.set_font("helvetica", "B", 9)
326
+ pdf.cell(col_width, 6, f"{row[0]}:", border=0)
327
+ pdf.set_font("helvetica", "", 9)
328
+ pdf.cell(val_width, 6, str(row[1]), border=0)
329
+
330
+ pdf.set_font("helvetica", "B", 9)
331
+ pdf.cell(col_width, 6, f"{row[2]}:", border=0)
332
+ pdf.set_font("helvetica", "", 9)
333
+ pdf.cell(val_width, 6, str(row[3]), border=0, new_x="LMARGIN", new_y="NEXT")
334
+
335
+ pdf.ln(3)
336
+ pdf.line(10, pdf.get_y(), 200, pdf.get_y())
337
+ pdf.ln(5)
338
+
339
+ # 4. Report Text Content
340
+ pdf.set_font("helvetica", "B", 10)
341
+ pdf.cell(0, 6, "II. LAPORAN PEMERIKSAAN (RADIOLOGY REPORT)", new_x="LMARGIN", new_y="NEXT")
342
+ pdf.ln(2)
343
+
344
+ pdf.set_font("helvetica", "", 9.5)
345
+ lines = data.report_text.split("\n")
346
+ for line in lines:
347
+ stripped = line.strip()
348
+ if stripped.startswith("1. ") or stripped.startswith("2. ") or stripped.startswith("3. ") or stripped.startswith("4. "):
349
+ pdf.ln(2)
350
+ pdf.set_font("helvetica", "B", 10)
351
+ pdf.multi_cell(0, 6, line, new_x="LMARGIN", new_y="NEXT")
352
+ pdf.set_font("helvetica", "", 9.5)
353
+ elif stripped.startswith("* ") or stripped.startswith("- "):
354
+ pdf.set_font("helvetica", "", 9.5)
355
+ pdf.set_x(15)
356
+ pdf.multi_cell(0, 5, line, new_x="LMARGIN", new_y="NEXT")
357
+ elif stripped.startswith("*Catatan:") or stripped.startswith("Catatan:"):
358
+ pdf.ln(4)
359
+ pdf.set_font("helvetica", "I", 8.5)
360
+ pdf.multi_cell(0, 4.5, line, new_x="LMARGIN", new_y="NEXT")
361
+ else:
362
+ pdf.multi_cell(0, 5, line, new_x="LMARGIN", new_y="NEXT")
363
+
364
+ # 5. Signatures
365
+ pdf.ln(15)
366
+ current_y = pdf.get_y()
367
+
368
+ if current_y > 240:
369
+ pdf.add_page()
370
+ current_y = pdf.get_y()
371
+
372
+ pdf.set_font("helvetica", "", 9.5)
373
+ pdf.set_xy(130, current_y)
374
+ pdf.cell(60, 5, f"Denpasar, {tanggal_indonesia_sekarang()}", new_x="LMARGIN", new_y="NEXT", align="C")
375
+ pdf.set_x(130)
376
+ pdf.cell(60, 5, "Pusat Radiologi Digital & Diagnostik AI", new_x="LMARGIN", new_y="NEXT", align="C")
377
+
378
+ pdf.ln(10)
379
+ pdf.set_x(130)
380
+ pdf.set_font("helvetica", "B", 9.5)
381
+ pdf.cell(60, 5, "dr. _________________________, Sp.Rad", new_x="LMARGIN", new_y="NEXT", align="C")
382
+ pdf.set_x(130)
383
+ pdf.set_font("helvetica", "", 8.5)
384
+ pdf.cell(60, 5, "NIP. ___________________________", new_x="LMARGIN", new_y="NEXT", align="C")
385
+
386
+ pdf_bytes = bytes(pdf.output())
387
+
388
+ return StreamingResponse(
389
+ io.BytesIO(pdf_bytes),
390
+ media_type="application/pdf",
391
+ headers={"Content-Disposition": "attachment; filename=Laporan_Radiologi_BrainScan.pdf"}
392
+ )
393
+ except Exception as e:
394
+ raise HTTPException(status_code=500, detail=f"Gagal memproses PDF: {str(e)}")
395
+
396
+ # Mount folder figures sebagai static files agar bisa diakses (opsional fallback)
397
+ app.mount("/outputs/figures", StaticFiles(directory=os.path.join(OUTPUT_DIR, "figures")), name="figures")
398
+
399
+ # Serve file static frontend secara langsung
400
+ # html=True akan menyajikan index.html secara default jika rute / dipanggil
401
+ app.mount("/", StaticFiles(directory="src/static", html=True), name="static")
402
+
403
+ if __name__ == "__main__":
404
+ import uvicorn
405
+ # Jalankan server (jalankan dari root proyek: `python -m src.main`
406
+ # atau `uvicorn src.main:app --reload` dari folder root, BUKAN dari dalam folder src/)
407
+ uvicorn.run("src.main:app", host="127.0.0.1", port=8000, reload=True)
precheck_model.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ precheck_model.py
3
+ ------------------
4
+ Model Pre-Check (gatekeeper) berbasis EfficientNet-B0.
5
+
6
+ Sesuai proposal, salah satu kebaruan utama proyek ini adalah modul pre-check
7
+ BERBASIS EfficientNet-B0 (bukan Vision Transformer) untuk memverifikasi bahwa
8
+ citra yang diunggah benar-benar CT-Scan/MRI otak yang valid, sebelum diteruskan
9
+ ke model klasifikasi utama (BrainHybridModel). Tujuannya mengurangi risiko
10
+ "halusinasi" model saat menerima citra non-otak atau modalitas yang tidak sesuai.
11
+
12
+ Klasifikasi biner: indeks 0 = Invalid, indeks 1 = Valid.
13
+ """
14
+
15
+ import torch
16
+ import torch.nn as nn
17
+
18
+ try:
19
+ from torchvision.models import efficientnet_b0, EfficientNet_B0_Weights
20
+ HAS_WEIGHTS = True
21
+ except ImportError:
22
+ from torchvision.models import efficientnet_b0
23
+ HAS_WEIGHTS = False
24
+
25
+
26
+ class BrainPreCheckModel(nn.Module):
27
+ """
28
+ Pre-Check Model berbasis EfficientNet-B0 (pretrained ImageNet) sebagai
29
+ backbone ekstraksi fitur, dengan head klasifikasi biner (Valid vs Invalid).
30
+
31
+ Nama atribut `backbone` dan `classifier` sengaja dipertahankan (sama seperti
32
+ versi sebelumnya) agar kompatibel dengan train_precheck.py, yang membekukan
33
+ `model.backbone` dan hanya melatih `model.classifier`.
34
+ """
35
+
36
+ def __init__(self):
37
+ super(BrainPreCheckModel, self).__init__()
38
+
39
+ # Backbone EfficientNet-B0 (pretrained ImageNet)
40
+ if HAS_WEIGHTS:
41
+ self.backbone = efficientnet_b0(weights=EfficientNet_B0_Weights.DEFAULT)
42
+ else:
43
+ self.backbone = efficientnet_b0(pretrained=True)
44
+
45
+ # EfficientNet-B0 classifier bawaan: Sequential(Dropout, Linear(1280, 1000))
46
+ in_features = self.backbone.classifier[1].in_features # 1280
47
+ self.backbone.classifier = nn.Identity()
48
+
49
+ # Head klasifikasi biner: Valid (1) vs Invalid (0)
50
+ self.classifier = nn.Sequential(
51
+ nn.LayerNorm(in_features),
52
+ nn.Linear(in_features, 2),
53
+ )
54
+
55
+ def forward(self, x):
56
+ feats = self.backbone(x) # [B, 1280]
57
+ return self.classifier(feats) # [B, 2]
58
+
59
+
60
+ if __name__ == "__main__":
61
+ # Uji coba apakah arsitektur model berhasil dimuat tanpa error
62
+ model = BrainPreCheckModel()
63
+ dummy_input = torch.randn(1, 3, 224, 224) # Simulasi 1 gambar ukuran 224x224
64
+ output = model(dummy_input)
65
+ print(f"✨ Model Pre-Check (EfficientNet-B0) Sukses Dibuat! Ukuran Output: {output.shape}")
preprocess.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ preprocess.py
3
+ -------------
4
+ Berisi:
5
+ - train_transforms / val_transforms : objek T.Compose siap pakai
6
+ - get_transforms() : mengembalikan (train_tf, val_tf)
7
+ - print_preprocessing_info() : cetak ringkasan ke log
8
+ - plot_preprocessing_distribution() : grafik before/after balancing
9
+ """
10
+
11
+ import os
12
+ import cv2
13
+ import logging
14
+ import warnings
15
+ import pandas as pd
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+ import matplotlib
20
+ matplotlib.use("Agg")
21
+ import matplotlib.pyplot as plt
22
+ import torchvision.transforms as T
23
+
24
+ from src.config import (
25
+ IMG_SIZE, CLASSES, CLASS_DISPLAY, CLASS_COLORS, FIGURES_DIR,
26
+ DATA_DIR, SPLITS_DIR,
27
+ )
28
+
29
+ warnings.filterwarnings("ignore")
30
+ logger = logging.getLogger("brain_pipeline")
31
+
32
+
33
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
34
+ # TRANSFORMS
35
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36
+
37
+ # Statistik normalisasi HARUS SAMA dengan yang dipakai saat training
38
+ # (lihat notebook: IMAGENET_MEAN / IMAGENET_STD), karena backbone
39
+ # EfficientNet-B3 di-pretrain pakai statistik ImageNet ini.
40
+ IMAGENET_MEAN = [0.485, 0.456, 0.406]
41
+ IMAGENET_STD = [0.229, 0.224, 0.225]
42
+
43
+ # Transformasi untuk data latihan (dengan augmentasi spesifik citra medis)
44
+ train_transforms = T.Compose([
45
+ T.Resize((IMG_SIZE, IMG_SIZE)),
46
+ T.RandomHorizontalFlip(p=0.5),
47
+ T.RandomRotation(degrees=8), # Rotasi kecil spesifik medis
48
+ T.ColorJitter(brightness=0.15, contrast=0.15, saturation=0.0, hue=0.0), # Citra medis grayscale tidak memerlukan warna/hue/sat
49
+ T.RandomAffine(degrees=0, translate=(0.03, 0.03)), # Pergeseran kecil saja
50
+ T.GaussianBlur(kernel_size=(3, 3), sigma=(0.1, 0.5)), # Gaussian Blur halus
51
+ T.ToTensor(),
52
+ T.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
53
+ T.RandomErasing(p=0.1, scale=(0.01, 0.05)), # Erasing diperkecil agar tidak menutupi patologi penting
54
+ ])
55
+
56
+ # Transformasi untuk data validasi & pengujian (tanpa augmentasi)
57
+ val_transforms = T.Compose([
58
+ T.Resize((IMG_SIZE, IMG_SIZE)),
59
+ T.ToTensor(),
60
+ T.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
61
+ ])
62
+
63
+ # Transform khusus untuk model Precheck -- model ini dilatih terpisah
64
+ # (train_precheck.py) memakai normalisasi [0.5,0.5,0.5], BUKAN statistik
65
+ # ImageNet, jadi harus dipakai transform yang beda dari val_transforms
66
+ # di atas supaya konsisten dengan cara precheck model dilatih.
67
+ precheck_transforms = T.Compose([
68
+ T.Resize((IMG_SIZE, IMG_SIZE)),
69
+ T.ToTensor(),
70
+ T.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]),
71
+ ])
72
+
73
+
74
+ def get_transforms():
75
+ """Mengembalikan (train_transforms, val_transforms)."""
76
+ return train_transforms, val_transforms
77
+
78
+
79
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
80
+ # PREPROCESSING INFO & PLOTS
81
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
82
+
83
+ def print_preprocessing_info():
84
+ """Tampilkan ringkasan preprocessing & augmentasi ke log/terminal."""
85
+ from src.config import IMG_SIZE, BATCH_SIZE, LR, EPOCHS
86
+
87
+ sep70 = "=" * 70
88
+ logger.info("\n" + sep70)
89
+ logger.info(" PREPROCESSING ANALYSIS")
90
+ logger.info(sep70)
91
+
92
+ info = {
93
+ "Resize" : f"{IMG_SIZE} x {IMG_SIZE} px",
94
+ "Normalisasi" : "mean=[0.5,0.5,0.5] std=[0.5,0.5,0.5] (range -> [-1,1])",
95
+ "Augmentasi (train)" : (
96
+ "RandomHorizontalFlip(p=0.5) | RandomRotation(+-15) | "
97
+ "ColorJitter(brightness=0.2, contrast=0.2) | "
98
+ "RandomAffine(translate=5%) | RandomErasing(p=0.2)"
99
+ ),
100
+ "Augmentasi (val/test)": "Resize + ToTensor + Normalize (tanpa augmentasi)",
101
+ "Balancing" : "WeightedRandomSampler (per-class inverse-frequency weights)",
102
+ }
103
+ col = 25
104
+ for k, v in info.items():
105
+ logger.info(f" {k:<{col}}: {v}")
106
+
107
+
108
+ def plot_preprocessing_distribution(train_df_before, train_df_after):
109
+ """
110
+ Grafik distribusi kelas SEBELUM augmentasi (train_df_before, hasil scan
111
+ data/raw/ asli) vs SESUDAH augmentasi offline sungguhan (train_df_after,
112
+ hasil nyata dari augment.run_augmentation() -- bukan proyeksi/estimasi).
113
+ """
114
+ COLOR_CT = "#E15759"
115
+ COLOR_MRI = "#4E79A7"
116
+
117
+ before_mods = {
118
+ "Alzheimer": "MRI", "Intracranial_Hemorrhage": "CT",
119
+ "Normal": "MRI", "Stroke_Iskemik": "CT", "Tumor": "CT"
120
+ }
121
+
122
+ before_counts_s = train_df_before["label"].value_counts().reindex(CLASSES, fill_value=0)
123
+ after_counts_s = train_df_after["label"].value_counts().reindex(CLASSES, fill_value=0)
124
+ before = before_counts_s.tolist()
125
+ after = after_counts_s.tolist()
126
+ total_before = int(sum(before))
127
+ total_after = int(sum(after))
128
+
129
+ labels = [f"{CLASS_DISPLAY.get(c, c)}\n({before_mods.get(c, 'MRI')})" for c in CLASSES]
130
+ colors = [COLOR_CT if before_mods.get(c, 'MRI') == 'CT' else COLOR_MRI for c in CLASSES]
131
+
132
+ x = np.arange(len(labels))
133
+
134
+ fig, axes = plt.subplots(1, 2, figsize=(14, 6))
135
+
136
+ # Kiri: Sebelum Augmentasi (Distribusi Asli Train)
137
+ bars_b = axes[0].bar(x, before, width=0.55, color=colors,
138
+ edgecolor="white", linewidth=1.2)
139
+ for bar, val in zip(bars_b, before):
140
+ axes[0].text(bar.get_x() + bar.get_width() / 2,
141
+ bar.get_height() + max(before, default=1) * 0.01,
142
+ f"{val:,}", ha="center", va="bottom", fontsize=9, fontweight="bold")
143
+ axes[0].set_title("Sebelum Augmentasi (Distribusi Asli Data Train)", fontsize=12, fontweight="bold")
144
+ axes[0].set_xticks(x)
145
+ axes[0].set_xticklabels(labels, rotation=15, ha="right")
146
+ axes[0].set_ylabel("Jumlah Sampel")
147
+ axes[0].grid(axis="y", alpha=0.3)
148
+ axes[0].set_ylim(0, max(before + after, default=1) * 1.15)
149
+
150
+ from matplotlib.patches import Patch
151
+ legend_elements = [
152
+ Patch(facecolor=COLOR_CT, edgecolor='white', label='CT Scan'),
153
+ Patch(facecolor=COLOR_MRI, edgecolor='white', label='MRI Otak')
154
+ ]
155
+ axes[0].legend(handles=legend_elements, loc="upper right")
156
+
157
+ # Kanan: Sesudah Augmentasi Offline (Hasil NYATA, bukan estimasi)
158
+ bars_a = axes[1].bar(x, after, width=0.55, color=colors,
159
+ edgecolor="white", linewidth=1.2, alpha=0.85)
160
+ for bar, val in zip(bars_a, after):
161
+ axes[1].text(bar.get_x() + bar.get_width() / 2,
162
+ bar.get_height() + max(before + after, default=1) * 0.01,
163
+ f"{val:,}", ha="center", va="bottom", fontsize=9, fontweight="bold")
164
+ axes[1].set_title("Sesudah Augmentasi Offline (Hasil Nyata)", fontsize=12, fontweight="bold")
165
+ axes[1].set_xticks(x)
166
+ axes[1].set_xticklabels(labels, rotation=15, ha="right")
167
+ axes[1].set_ylabel("Jumlah Sampel")
168
+ axes[1].grid(axis="y", alpha=0.3)
169
+ axes[1].set_ylim(0, max(before + after, default=1) * 1.15)
170
+ axes[1].legend(handles=legend_elements, loc="upper right")
171
+
172
+ axes[1].text(0.5, 0.90, f"Total Data Train Setelah Augmentasi = {total_after:,} data",
173
+ transform=axes[1].transAxes, ha="center", va="center",
174
+ color="#D62728", fontsize=11, fontweight="bold",
175
+ bbox=dict(facecolor='white', alpha=0.8, edgecolor='#D62728', boxstyle='round,pad=0.5'))
176
+
177
+ fig.suptitle(f"Distribusi Data CT Scan & MRI Sebelum ({total_before:,}) dan Sesudah ({total_after:,}) Augmentasi",
178
+ fontsize=14, fontweight="bold", y=1.02)
179
+ fig.tight_layout()
180
+
181
+ out = Path(FIGURES_DIR) / "preprocessing_distribution.png"
182
+ fig.savefig(out, dpi=150, bbox_inches="tight")
183
+ plt.close(fig)
184
+ logger.info(f" Grafik preprocessing disimpan -> {out}")
185
+
186
+
187
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
188
+ # BRAIN SCAN CROP CONTOUR
189
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
190
+
191
+ def crop_brain_contour(img):
192
+ """
193
+ Mendeteksi area scan otak sirkular di dalam gambar dan memotong (crop)
194
+ area luar seperti taskbar desktop, bingkai window, atau ruang hitam berlebih.
195
+ """
196
+ if img is None:
197
+ return None
198
+
199
+ h, w, c = img.shape
200
+
201
+ # Convert to grayscale
202
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
203
+
204
+ # Threshold to binary (threshold 20, max 255)
205
+ _, thresh = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)
206
+
207
+ # Find contours
208
+ contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
209
+ if not contours:
210
+ return img
211
+
212
+ # Sort contours by area descending and find the largest
213
+ contours = sorted(contours, key=cv2.contourArea, reverse=True)
214
+
215
+ # Get bounding box of the largest contour (the brain scan area)
216
+ x, y, cw, ch = cv2.boundingRect(contours[0])
217
+
218
+ # Check if the bounding box is valid and reasonably large
219
+ # (e.g. at least 30% of original width/height to avoid tiny text contours)
220
+ if cw > w * 0.3 and ch > h * 0.3:
221
+ # Add a padding of 5 pixels to avoid clipping the edges
222
+ pad = 5
223
+ x = max(0, x - pad)
224
+ y = max(0, y - pad)
225
+ cw = min(w - x, cw + 2 * pad)
226
+ ch = min(h - y, ch + 2 * pad)
227
+
228
+ # Crop
229
+ cropped = img[y:y+ch, x:x+cw]
230
+ return cropped
231
+ return img
232
+
233
+
234
+ def run_cropping_pipeline():
235
+ processed_dir = DATA_DIR / "processed"
236
+ os.makedirs(processed_dir, exist_ok=True)
237
+
238
+ logger.info("Memulai proses pemotongan (cropping) citra otomatis untuk membersihkan desktop/window borders...")
239
+
240
+ # 1. Proses dataset splits
241
+ for csv_name in ["train.csv", "val.csv", "test.csv"]:
242
+ csv_path = os.path.join(SPLITS_DIR, csv_name)
243
+ if not os.path.exists(csv_path):
244
+ continue
245
+
246
+ logger.info(f"Memproses {csv_name}...")
247
+ df = pd.read_csv(csv_path)
248
+ new_paths = []
249
+
250
+ for idx, row in df.iterrows():
251
+ orig_path = row['image_path']
252
+ label = row['label']
253
+
254
+ # Tentukan output path di folder processed
255
+ filename = os.path.basename(orig_path)
256
+ out_label_dir = os.path.join(processed_dir, label)
257
+ os.makedirs(out_label_dir, exist_ok=True)
258
+ out_path = os.path.join(out_label_dir, filename)
259
+
260
+ # Jika file sudah di-crop sebelumnya, lewati
261
+ if os.path.exists(out_path):
262
+ new_paths.append(out_path)
263
+ continue
264
+
265
+ # Load, crop, dan simpan
266
+ img = cv2.imread(orig_path)
267
+ if img is not None:
268
+ cropped_img = crop_brain_contour(img)
269
+ cv2.imwrite(out_path, cropped_img)
270
+ new_paths.append(out_path)
271
+ else:
272
+ # Fallback ke path asli jika gagal load
273
+ new_paths.append(orig_path)
274
+
275
+ df['image_path'] = new_paths
276
+ df.to_csv(csv_path, index=False)
277
+ logger.info(f"Selesai memproses {csv_name}. Berkas disimpan kembali dengan path gambar terpotong.")
278
+
279
+ # 2. Proses folder samples agar berkas tes juga terpotong bersih
280
+ samples_dir = "samples"
281
+ if os.path.exists(samples_dir):
282
+ logger.info("Memproses folder samples...")
283
+ for filename in os.listdir(samples_dir):
284
+ if filename.lower().endswith(('.png', '.jpg', '.jpeg')):
285
+ filepath = os.path.join(samples_dir, filename)
286
+ img = cv2.imread(filepath)
287
+ if img is not None:
288
+ cropped_img = crop_brain_contour(img)
289
+ cv2.imwrite(filepath, cropped_img)
290
+ logger.info("Selesai memproses folder samples.")
291
+
292
+
293
+ if __name__ == "__main__":
294
+ # Setup basic logging to stdout when run directly
295
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
296
+ run_cropping_pipeline()
prompts.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def build_radiology_prompt(result: dict) -> str:
2
+ """
3
+ Membangun prompt untuk Gemini API dalam menyusun laporan radiologi
4
+ berdasarkan hasil klasifikasi model.
5
+
6
+ Args:
7
+ result: dict berisi predicted_class, confidence, dan visual_summary
8
+ (modality, suspected_pattern, attention_region)
9
+
10
+ Returns:
11
+ String prompt siap dikirim ke Gemini API.
12
+ """
13
+ predicted_class = result.get("predicted_class", "Tidak diketahui")
14
+ confidence = result.get("confidence", "N/A")
15
+ visual = result.get("visual_summary", {})
16
+ modality = visual.get("modality", "MRI")
17
+ pattern = visual.get("suspected_pattern", "Tidak tersedia")
18
+ region = visual.get("attention_region", "Tidak tersedia")
19
+
20
+ return f"""Anda adalah asisten AI radiologi yang membantu menyusun draf laporan
21
+ untuk ditinjau oleh Dokter Spesialis Radiologi (Sp.Rad). Anda BUKAN pengganti
22
+ diagnosis dokter β€” tugas Anda hanya menyusun draf awal berdasarkan data yang
23
+ diberikan sistem.
24
+
25
+ DATA HASIL ANALISIS SISTEM:
26
+ - Prediksi Penyakit : {predicted_class}
27
+ - Tingkat Keyakinan : {confidence}
28
+ - Modalitas Pemeriksaan: {modality}
29
+ - Pola Visual Terdeteksi: {pattern}
30
+ - Area Perhatian (AI) : {region}
31
+
32
+ TUGAS:
33
+ Susun draf laporan radiologi dengan struktur berikut:
34
+
35
+ 1. Definisi Penyakit β€” jelaskan singkat (3-5 kalimat) apa itu {predicted_class}
36
+ secara umum: definisi medis, penyebab umum, dan siapa yang berisiko
37
+ 2. Ringkasan Temuan β€” deskripsikan temuan utama secara objektif berdasarkan data di atas (2-4 kalimat)
38
+ 3. Analisis Klinis β€” jelaskan makna klinis dari pola visual dan area yang terdeteksi (2-4 kalimat)
39
+ 4. Tingkat Keyakinan β€” interpretasikan confidence score, sebutkan bahwa ini hasil sistem AI (2-3 kalimat)
40
+ 5. Tatalaksana Umum β€” jelaskan pendekatan penanganan/tatalaksana yang UMUM dilakukan
41
+ untuk kondisi ini sesuai pedoman klinis (bukan resep dosis obat spesifik,
42
+ karena itu wewenang dokter yang memeriksa langsung), 3-5 kalimat
43
+ 6. Rekomendasi Pemeriksaan Lanjutan β€” sarankan pemeriksaan penunjang yang relevan (2-3 kalimat)
44
+
45
+ ATURAN:
46
+ - Gunakan bahasa medis formal dan profesional (Bahasa Indonesia)
47
+ - Jangan menyatakan diagnosis sebagai kepastian mutlak β€” gunakan istilah seperti
48
+ "mengarah pada", "konsisten dengan", "perlu konfirmasi lebih lanjut"
49
+ - Bagian Tatalaksana Umum harus bersifat edukatif/informatif umum, BUKAN instruksi
50
+ pengobatan personal untuk pasien tertentu (tidak ada dosis, tidak ada resep spesifik)
51
+ - Wajib tutup laporan dengan catatan bahwa hasil ini adalah opini sekunder AI
52
+ dan harus divalidasi oleh Dokter Spesialis Radiologi
53
+ - Jangan menambahkan informasi pasien (nama, usia, dll) karena tidak diberikan
54
+ """
55
+
56
+