Upload 13 files
Browse files- __init__.py +0 -0
- api.py +391 -0
- app.py +83 -0
- classifier_model.py +286 -0
- config.py +168 -0
- database.py +185 -0
- explainability.py +80 -0
- fusion_blocks.py +37 -0
- gemini_client.py +153 -0
- main.py +407 -0
- precheck_model.py +65 -0
- preprocess.py +296 -0
- 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 |
+
|