{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceType":"competition","sourceId":24800,"datasetId":1042002,"databundleVersionId":1831594},{"sourceType":"datasetVersion","sourceId":14615672,"datasetId":9280667,"databundleVersionId":15452516}],"dockerImageVersionId":31260,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# ===== PREPROCESSING FILE DICOM -> PNG(1024) =====\n\nimport pydicom\nimport cv2\nimport numpy as np\n\n# --- CẤU HÌNH ---\n# Chỉ cần set kích thước ảnh, các đường dẫn folder sẽ xử lý ở vòng lặp chính\nTARGET_SIZE = 1024 \n\ndef read_process_dicom(dicom_path, target_size=TARGET_SIZE, fix_monochrome=True):\n    \"\"\"\n    Hàm đọc DICOM và tiền xử lý để ném vào Model\n    Input: Đường dẫn file .dicom\n    Output: Ảnh Numpy Array (H, W, 3) chuẩn RGB\n    \"\"\"\n    try:\n        ds = pydicom.dcmread(dicom_path)\n        img = ds.pixel_array.astype(np.float32)\n\n        # 1. Fix lỗi âm bản (Monochrome Fix)\n        if fix_monochrome and hasattr(ds, \"PhotometricInterpretation\"):\n            if ds.PhotometricInterpretation == \"MONOCHROME1\":\n                img = np.amax(img) - img\n        \n        # 2. Robust Scaling (Cắt bỏ 1% điểm ảnh nhiễu - Giữ nguyên logic của bạn)\n        p_low = np.percentile(img, 1)\n        p_high = np.percentile(img, 99)\n        img = np.clip(img, p_low, p_high)\n        \n        # Chuẩn hóa Min-Max về 0-1\n        if p_high > p_low:\n            img = (img - p_low) / (p_high - p_low)\n        else:\n            img = np.zeros_like(img) # Ảnh lỗi (đen xì)\n\n        # Đưa về 0-255\n        img = (img * 255.0).astype(np.uint8)\n\n        # 3. Resize + Padding (Letterbox) - Giữ tỉ lệ ảnh gốc\n        h, w = img.shape\n        scale = min(target_size/h, target_size/w)\n        nh, nw = int(h*scale), int(w*scale)\n        img_resized = cv2.resize(img, (nw, nh))\n        \n        # Tạo nền đen (Padding)\n        final_img = np.zeros((target_size, target_size), dtype=np.uint8)\n        dy = (target_size - nh) // 2\n        dx = (target_size - nw) // 2\n        final_img[dy:dy+nh, dx:dx+nw] = img_resized\n        \n        # 4. [QUAN TRỌNG] Chuyển sang 3 kênh màu (RGB)\n        # Model của bạn (YOLO/EffNet) đều yêu cầu input shape là (C, H, W) hoặc (H, W, 3)\n        final_img_rgb = cv2.cvtColor(final_img, cv2.COLOR_GRAY2RGB)\n        \n        return final_img_rgb\n\n    except Exception as e:\n        print(f\"⚠️ Lỗi đọc file {dicom_path}: {e}\")\n        # Trả về ảnh đen nếu lỗi để code không bị crash\n        return np.zeros((target_size, target_size, 3), dtype=np.uint8)\n\nprint(\"✅ Đã khởi tạo hàm xử lý ảnh DICOM (Sẵn sàng cho Inference)!\")","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-01-25T20:16:21.385348Z","iopub.execute_input":"2026-01-25T20:16:21.385988Z","iopub.status.idle":"2026-01-25T20:16:22.251703Z","shell.execute_reply.started":"2026-01-25T20:16:21.385961Z","shell.execute_reply":"2026-01-25T20:16:22.250972Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ===== CẤU HÌNH CHO EFICIENTNET =====\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nIMG_SIZE = 1024 \n\ndef get_test_transforms():\n    \"\"\"\n    Chỉ Resize và Chuẩn hóa (Normalize) để đưa vào Model\n    \"\"\"\n    return A.Compose([\n        A.Resize(IMG_SIZE, IMG_SIZE),\n        \n        A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),\n        \n        ToTensorV2(),\n    ])\n\nprint(f\"✅ Đã khởi tạo Transform (IMG_SIZE: {IMG_SIZE})\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-25T20:16:22.253247Z","iopub.execute_input":"2026-01-25T20:16:22.253512Z","iopub.status.idle":"2026-01-25T20:16:27.416361Z","shell.execute_reply.started":"2026-01-25T20:16:22.253492Z","shell.execute_reply":"2026-01-25T20:16:27.415761Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ===== CẤU HÌNH CHO EFICIENTNET 2 =====\n\nimport torch\nimport timm\n\nDEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'\n\n# 1. Tạo khung model (Giống hệt lúc train: đúng tên model, đúng số class)\ncls_model = timm.create_model('tf_efficientnet_b0_ns', pretrained=False, num_classes=14)\n\n# 2. Load trọng số (File best.pt bạn tải về rồi upload lên Kaggle Dataset)\nweight_path = '/kaggle/input/trained-models-vindr-xray/EffNet-b0_balanced/best.pt' # Sửa đường dẫn này\ncls_model.load_state_dict(torch.load(weight_path, map_location=DEVICE))\n\n# 3. Chuyển sang chế độ thi đấu (Eval)\ncls_model.to(DEVICE)\ncls_model.eval()\nprint(\"✅ Đã load xong EfficientNet!\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-25T20:16:27.417236Z","iopub.execute_input":"2026-01-25T20:16:27.417644Z","iopub.status.idle":"2026-01-25T20:16:35.137333Z","shell.execute_reply.started":"2026-01-25T20:16:27.417619Z","shell.execute_reply":"2026-01-25T20:16:35.136649Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Fusion func v1\ndef calculate_fusion_score(yolo_conf, cls_conf, cls_id):\n    \"\"\"\n    Input:\n        yolo_conf: Độ tự tin của YOLO (0.0 - 1.0)\n        cls_conf:  Độ tự tin của EfficientNet (0.0 - 1.0)\n        cls_id:    ID của bệnh (để áp dụng chiến thuật riêng cho từng bệnh)\n    Output:\n        Final Score\n    \"\"\"\n    # Chiến thuật: Class mạnh thì Nhân, Class yếu thì Căn bậc 2\n    STRONG_CLASSES = [0, 3, 11] # Aortic enlargement, Cardiomegaly, Pleural thickening\n    \n    if cls_id in STRONG_CLASSES:\n        # Công thức 1: Nhân chặt chẽ\n        return yolo_conf * cls_conf\n    else:\n        # Công thức 2: Geometric Mean (Cứu vớt class yếu)\n        return (yolo_conf * cls_conf) ** 0.5\nprint(f\"✅ Đã khởi tạo FUSION\")","metadata":{"execution":{"iopub.status.busy":"2026-01-18T03:54:55.550864Z","iopub.execute_input":"2026-01-18T03:54:55.551103Z","iopub.status.idle":"2026-01-18T03:54:55.560822Z","shell.execute_reply.started":"2026-01-18T03:54:55.551076Z","shell.execute_reply":"2026-01-18T03:54:55.560007Z"}}},{"cell_type":"code","source":"# Fusion func v2\ndef calculate_fusion_score(yolo_conf, cls_conf, cls_id):\n    \"\"\"\n    Input:\n        yolo_conf: Độ tự tin của YOLO\n        cls_conf:  Độ tự tin của EfficientNet\n        cls_id:    ID của bệnh\n    Output:\n        Final Score (Geometric Mean cho TẤT CẢ các lớp)\n    \"\"\"\n    # Thay vì chia trường hợp, ta áp dụng chung 1 công thức cho tất cả\n    # Logic: Căn bậc 2 của tích giúp \"kéo\" điểm số về mức trung bình,\n    # tránh việc 1 thằng quá thấp kéo tụt thằng kia xuống quá sâu (như phép nhân).\n    return (yolo_conf * cls_conf) ** 0.5","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-25T20:16:35.138731Z","iopub.execute_input":"2026-01-25T20:16:35.138982Z","iopub.status.idle":"2026-01-25T20:16:35.142877Z","shell.execute_reply.started":"2026-01-25T20:16:35.138959Z","shell.execute_reply":"2026-01-25T20:16:35.142185Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install ultralytics","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-25T20:16:35.144461Z","iopub.execute_input":"2026-01-25T20:16:35.144695Z","iopub.status.idle":"2026-01-25T20:16:40.644873Z","shell.execute_reply.started":"2026-01-25T20:16:35.144667Z","shell.execute_reply":"2026-01-25T20:16:40.644126Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ===== MOCK TESTING WHITH RANDOM DATA FROM TRAIN =====\n\nimport pandas as pd\nimport os\nimport shutil\nimport numpy as np\nfrom tqdm.notebook import tqdm\n\n# --- CẤU HÌNH ---\n# 1. Đường dẫn gốc của VinBigData\nINPUT_CSV_ORIGIN = \"/kaggle/input/vinbigdata-chest-xray-abnormalities-detection/train.csv\"\nINPUT_DICOM_DIR = \"/kaggle/input/vinbigdata-chest-xray-abnormalities-detection/train\"\n\n# 2. Đường dẫn đầu ra (Nơi chứa dữ liệu test giả lập)\nOUTPUT_TEST_DIR = \"/kaggle/working/test_dicom\"\nOUTPUT_TEST_CSV = \"/kaggle/working/test_ground_truth.csv\"\n\nRANDOM_STATE = 36 # Theo yêu cầu\nN_SAMPLES = 600  # 1000 ảnh mỗi loại\n\n# --- XỬ LÝ ---\nprint(\"🚀 Đang khởi tạo tập Test giả lập (DICOM)...\")\n\n# 1. Đọc CSV gốc\ndf = pd.read_csv(INPUT_CSV_ORIGIN)\n\n# 2. Phân loại ID\n# Lấy ID ảnh có bệnh (class_id != 14)\nsick_ids = df[df['class_id'] != 14]['image_id'].unique()\n# Lấy ID ảnh sạch (tất cả trừ đi ảnh bệnh)\nall_ids = df['image_id'].unique()\nhealthy_ids = list(set(all_ids) - set(sick_ids))\n\nprint(f\"   - Nguồn: {len(sick_ids)} sick | {len(healthy_ids)} healthy\")\n\n# 3. Sampling (Random State 36)\nnp.random.seed(RANDOM_STATE)\n\n# Lấy mẫu (Nếu không đủ 1000 thì lấy tối đa có thể)\nn_sick = min(len(sick_ids), N_SAMPLES)\nn_healthy = min(len(healthy_ids), N_SAMPLES)\n\ntest_sick_ids = np.random.choice(sick_ids, n_sick, replace=False)\ntest_healthy_ids = np.random.choice(healthy_ids, n_healthy, replace=False)\n\n# Gộp lại\nfinal_test_ids = np.concatenate([test_sick_ids, test_healthy_ids])\nnp.random.shuffle(final_test_ids) # Trộn đều\n\nprint(f\"   -> Đã chọn: {len(test_sick_ids)} Bệnh + {len(test_healthy_ids)} Sạch = {len(final_test_ids)} ảnh.\")\n\n# 4. Tạo file CSV Ground Truth mới\n# Chỉ giữ lại các dòng thuộc về các ID đã chọn\ndf_test = df[df['image_id'].isin(final_test_ids)].copy()\ndf_test.to_csv(OUTPUT_TEST_CSV, index=False)\nprint(f\"   -> Đã lưu file: {OUTPUT_TEST_CSV}\")\n\n# 5. Copy file DICOM\nif os.path.exists(OUTPUT_TEST_DIR):\n    shutil.rmtree(OUTPUT_TEST_DIR) # Xóa cũ tạo mới cho sạch\nos.makedirs(OUTPUT_TEST_DIR, exist_ok=True)\n\nprint(f\"📦 Đang copy {len(final_test_ids)} file DICOM (Có thể mất 2-3 phút)...\")\nfor img_id in tqdm(final_test_ids):\n    src_path = os.path.join(INPUT_DICOM_DIR, f\"{img_id}.dicom\")\n    dst_path = os.path.join(OUTPUT_TEST_DIR, f\"{img_id}.dicom\")\n    \n    # Copy file\n    if os.path.exists(src_path):\n        shutil.copy(src_path, dst_path)\n\nprint(\"\\n✅ DONE! Dữ liệu test đã sẵn sàng.\")","metadata":{"execution":{"iopub.status.busy":"2026-01-25T20:16:40.646108Z","iopub.execute_input":"2026-01-25T20:16:40.646443Z","iopub.status.idle":"2026-01-25T20:20:05.825335Z","shell.execute_reply.started":"2026-01-25T20:16:40.646413Z","shell.execute_reply":"2026-01-25T20:20:05.824553Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# ===== LOAD YOLO + CHẠY TEST + FUSION + LƯU CSV SUBMISSION CHO KAGGLE =====\n\nfrom ultralytics import YOLO\nimport os\nfrom tqdm.notebook import tqdm\n\n# 1. Load YOLO (YOLO tự load architecture nên dễ hơn EffNet)\nyolo_model = YOLO('/kaggle/input/trained-models-vindr-xray/VinDr_Project/yolov8m_balanced_final/weights/best.pt') # Sửa đường dẫn\n\n# 2. Setup thư mục Test\nTEST_DIR = '/kaggle/working/test_dicom'\ntest_files = os.listdir(TEST_DIR) # Danh sách file dicom\n\nresults = [] # Lưu kết quả để ghi CSV\n\n# 3. CHẠY VÒNG LẶP\nprint(f\"🚀 Bắt đầu chạy trên {len(test_files)} ảnh test...\")\n\nfor file_name in tqdm(test_files[:100]): # Test thử 100 ảnh trước, khi nộp thật thì bỏ [:100]\n    img_id = file_name.replace('.dicom', '')\n    file_path = os.path.join(TEST_DIR, file_name)\n    \n    # --- BƯỚC A: ĐỌC ẢNH ---\n    try:\n        img_array = read_xray(file_path) # Hàm ở Mảnh 1\n    except:\n        continue # Bỏ qua nếu lỗi file\n        \n    # --- BƯỚC B: CLASSIFICATION (EfficientNet) ---\n    # Transform ảnh cho đúng chuẩn EffNet\n    aug = get_valid_transforms()(image=img_array) # Hàm ở Mảnh 2\n    img_tensor = aug['image'].unsqueeze(0).to(DEVICE) # Thêm batch dimension [1, 3, 1024, 1024]\n    \n    with torch.no_grad():\n        cls_logits = cls_model(img_tensor)\n        cls_probs = torch.sigmoid(cls_logits).cpu().numpy()[0] # Ra mảng 14 số xác suất [0.01, 0.9, ...]\n\n    # --- BƯỚC C: DETECTION (YOLO) ---\n    # YOLO tự resize ảnh bên trong nên ném thẳng img_array vào\n    yolo_out = yolo_model(img_array, conf=0.001, iou=0.4, verbose=False)[0]\n    boxes = yolo_out.boxes.data.cpu().numpy() # [x1, y1, x2, y2, conf, cls]\n    \n    # --- BƯỚC D: FUSION (KẾT HỢP) ---\n    prediction_strings = []\n    \n    # Nếu EfficientNet bảo \"Không có bệnh gì hết\" (tất cả < 0.05) -> No Finding (Class 14)\n    # Logic này tùy bạn chọn, hoặc cứ chạy fusion bình thường\n    \n    for box in boxes:\n        x1, y1, x2, y2, conf_yolo, cls_id = box\n        cls_id = int(cls_id)\n        \n        # Lấy điểm từ EfficientNet tương ứng với class đó\n        conf_cls = cls_probs[cls_id]\n        \n        # === CÔNG THỨC FUSION ===\n        # Chiến thuật Hybrid: Class yếu thì Căn bậc 2, Class mạnh thì Nhân\n        if cls_id in [0, 3, 11]: # Ví dụ class mạnh\n            final_conf = conf_yolo * conf_cls\n        else:\n            final_conf = (conf_yolo * conf_cls) ** 0.5\n            \n        # Ngưỡng lọc cuối cùng (Threshold)\n        if final_conf > 0.15: # Chỉ giữ lại nếu > 15%\n            # Format chuẩn Kaggle: \"class_id conf x1 y1 x2 y2\"\n            res_str = f\"{cls_id} {final_conf:.4f} {int(x1)} {int(y1)} {int(x2)} {int(y2)}\"\n            prediction_strings.append(res_str)\n            \n    # --- BƯỚC E: LƯU KẾT QUẢ ---\n    # Nếu không tìm thấy gì -> Class 14 (No Finding) 1.0 0 0 1 1\n    if len(prediction_strings) == 0:\n        PredictionString = \"14 1.0 0 0 1 1\"\n    else:\n        PredictionString = \" \".join(prediction_strings)\n        \n    results.append({'image_id': img_id, 'PredictionString': PredictionString})\n\n# 4. Xuất file CSV\nimport pandas as pd\ndf_sub = pd.DataFrame(results)\ndf_sub.to_csv('submission.csv', index=False)\nprint(\"✅ Xong! File submission.csv đã sẵn sàng.\")","metadata":{"execution":{"iopub.status.busy":"2026-01-25T20:24:30.443978Z","iopub.execute_input":"2026-01-25T20:24:30.444277Z","iopub.status.idle":"2026-01-25T20:24:30.534971Z","shell.execute_reply.started":"2026-01-25T20:24:30.44425Z","shell.execute_reply":"2026-01-25T20:24:30.53419Z"}}},{"cell_type":"code","source":"# ===== BỘ CÔNG CỤ VISUALIZE (MỚI) =====\nfrom sklearn.metrics import classification_report, confusion_matrix\nimport seaborn as sns\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport cv2\n\n# 1. Hàm vẽ 5 ca sai nặng nhất (Visualize Failures)\ndef visualize_failures(df_details, class_names, top_n=5):\n    # Lọc các ca sai\n    wrong_df = df_details[df_details['true_cls'] != df_details['pred_cls']]\n    wrong_df = wrong_df.sort_values(by='max_score', ascending=False).head(top_n)\n    \n    print(f\"\\n🔴 TOP {len(wrong_df)} CA SAI NGHIÊM TRỌNG NHẤT:\")\n    SPECIAL_IDS = [0, 3, 11, 13, 14] # Các class cần vẽ tách riêng\n\n    for _, row in wrong_df.iterrows():\n        # Đọc ảnh dùng lại hàm cũ của bạn\n        img = read_process_dicom(row['img_path'], target_size=1024)\n        viz_img = img.copy()\n        \n        # Vẽ Box Thật (Xanh lá)\n        for box in row['gt_boxes']:\n            x1, y1, x2, y2, cls = box\n            cv2.rectangle(viz_img, (x1, y1), (x2, y2), (0, 255, 0), 2)\n            cv2.putText(viz_img, f\"GT:{cls}\", (x1, y1-5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)\n            \n        # Vẽ Box Dự đoán (Đỏ)\n        for box in row['pred_boxes']:\n            x1, y1, x2, y2, cls, conf = box\n            cv2.rectangle(viz_img, (x1, y1), (x2, y2), (255, 0, 0), 2)\n            cv2.putText(viz_img, f\"P:{cls} {conf:.2f}\", (x1, y2+15), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0), 2)\n            \n        plt.figure(figsize=(8, 8))\n        plt.imshow(viz_img)\n        t_name = class_names.get(row['true_cls'], str(row['true_cls']))\n        p_name = class_names.get(row['pred_cls'], str(row['pred_cls']))\n        plt.title(f\"TRUE: {t_name} | PRED: {p_name} (Score: {row['max_score']:.2f})\", color='red')\n        plt.axis('off')\n        plt.show()\n\n# 2. Hàm vẽ Confusion Matrix (Full & Special)\ndef plot_cms(y_true, y_pred, class_names):\n    cm = confusion_matrix(y_true, y_pred, labels=list(range(15)))\n    labels = list(class_names.values())\n    \n    plt.figure(figsize=(20, 8))\n    \n    # Full Matrix\n    cm_norm = cm.astype('float') / (cm.sum(axis=1)[:, np.newaxis] + 1e-9)\n    plt.subplot(1, 2, 1)\n    sns.heatmap(cm_norm, annot=True, fmt='.2f', cmap='Greens', xticklabels=labels, yticklabels=labels)\n    plt.title('Normalized Confusion Matrix (ALL)')\n\n    # --- VẼ SPECIAL MATRIX ---\n    spec_ids = [0, 3, 11, 13, 14]\n    \n    # Thay vì cắt cm (raw) rồi tính lại, ta cắt trực tiếp từ cm_norm (đã tính ở trên)\n    # cm_norm là cái ma trận Full đã chia phần trăm xong xuôi\n    cm_spec_norm = cm_norm[np.ix_(spec_ids, spec_ids)]\n    \n    spec_labels = [class_names[i] for i in spec_ids]\n    \n    plt.subplot(1, 2, 2)\n    # Vẽ luôn, không cần chia lại nữa\n    sns.heatmap(cm_spec_norm, annot=True, fmt='.2f', cmap='Oranges', \n                xticklabels=spec_labels, yticklabels=spec_labels)\n    plt.title('Normalized CM (SPECIAL CLASSES - ZOOM VIEW)')\n    plt.show()\n\n# 3. Hàm vẽ Error Analysis (Bar Chart)\ndef plot_error_analysis(y_true, y_pred, class_names):\n    cm = confusion_matrix(y_true, y_pred, labels=list(range(15)))\n    fp = cm.sum(axis=0) - np.diag(cm)\n    fn = cm.sum(axis=1) - np.diag(cm)\n    x = np.arange(15)\n    \n    plt.figure(figsize=(15, 6))\n    plt.bar(x - 0.2, fp, 0.4, label='False Positives (False Positive)', color='red')\n    plt.bar(x + 0.2, fn, 0.4, label='False Negatives (False Negative)', color='orange')\n    plt.xticks(x, list(class_names.values()), rotation=45, ha='right')\n    plt.title('Error Analysis')\n    plt.legend()\n    plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-25T21:07:17.847634Z","iopub.execute_input":"2026-01-25T21:07:17.848413Z","iopub.status.idle":"2026-01-25T21:07:17.86227Z","shell.execute_reply.started":"2026-01-25T21:07:17.848378Z","shell.execute_reply":"2026-01-25T21:07:17.861266Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ===== CELL CUỐI CÙNG: CHẠY TEST + FUSION + VISUALIZE (FIXED) =====\nimport pydicom  # Cần import để đọc header lấy kích thước gốc\n\n# 1. CẤU HÌNH\nTEST_DIR = \"/kaggle/working/test_dicom\"\nTEST_CSV = \"/kaggle/working/test_ground_truth.csv\"\nCONF_THRESHOLD = 0.15\nIOU_THRESHOLD = 0.4\n\n# Map tên class\nclass_names = {\n    0: 'Aortic enlargement', 1: 'Atelectasis', 2: 'Calcification', 3: 'Cardiomegaly',\n    4: 'Consolidation', 5: 'ILD', 6: 'Infiltration', 7: 'Lung Opacity',\n    8: 'Nodule/Mass', 9: 'Other lesion', 10: 'Pleural effusion',\n    11: 'Pleural thickening', 12: 'Pneumothorax', 13: 'Pulmonary fibrosis', 14: 'No Finding'\n}\n\n# Load dữ liệu\ndf_gt = pd.read_csv(TEST_CSV)\ntest_files = [f for f in os.listdir(TEST_DIR) if f.endswith('.dicom')]\n\nprint(f\"🚀 Đang chạy đánh giá trên {len(test_files)} ảnh test (Fix Width Error)...\")\n\ny_true = []\ny_pred = []\nresults_data = [] \n\nfor file_name in tqdm(test_files):\n    img_id = file_name.replace('.dicom', '')\n    file_path = os.path.join(TEST_DIR, file_name)\n    \n    # A. Lấy Ground Truth\n    gt_rows = df_gt[df_gt['image_id'] == img_id]\n    if gt_rows.empty:\n        # Nếu không có trong CSV -> Coi như Class 14\n        true_cls = 14\n        gt_rows = pd.DataFrame() # Empty\n    elif 14 in gt_rows['class_id'].values:\n        true_cls = 14\n    else:\n        true_cls = gt_rows['class_id'].values[0]\n    \n    # [FIX QUAN TRỌNG] Lấy Box thật & Scale (Đọc size từ DICOM thay vì CSV)\n    gt_boxes = []\n    if true_cls != 14:\n        try:\n            # Đọc Header DICOM để lấy kích thước gốc (Nhanh, không đọc pixel)\n            ds_header = pydicom.dcmread(file_path, stop_before_pixels=True)\n            orig_h = ds_header.Rows\n            orig_w = ds_header.Columns\n            \n            # Tính tỉ lệ scale về 1024\n            scale = min(1024/orig_w, 1024/orig_h)\n            pad_w = (1024 - orig_w * scale) / 2\n            pad_h = (1024 - orig_h * scale) / 2\n            \n            for _, row in gt_rows.iterrows():\n                x1 = int(row['x_min'] * scale + pad_w)\n                y1 = int(row['y_min'] * scale + pad_h)\n                x2 = int(row['x_max'] * scale + pad_w)\n                y2 = int(row['y_max'] * scale + pad_h)\n                gt_boxes.append([x1, y1, x2, y2, int(row['class_id'])])\n        except Exception as e:\n            print(f\"Lỗi đọc DICOM Header {img_id}: {e}\")\n\n    y_true.append(true_cls)\n\n    # B. Xử lý ảnh & Predict (Logic cũ)\n    try:\n        img_rgb = read_process_dicom(file_path, target_size=1024) \n    except:\n        y_pred.append(14)\n        continue\n\n    # C. EfficientNet\n    aug = get_test_transforms()(image=img_rgb)\n    img_tensor = aug['image'].unsqueeze(0).to(DEVICE)\n    with torch.no_grad():\n        cls_probs = torch.sigmoid(cls_model(img_tensor)).cpu().numpy()[0]\n\n    prob_no_finding = 1.0 - np.max(cls_probs)\n\n    # D. YOLO\n    yolo_out = yolo_model(img_rgb, conf=0.001, iou=IOU_THRESHOLD, verbose=False)[0]\n    boxes = yolo_out.boxes.data.cpu().numpy()\n    \n    # E. FUSION\n    pred_cls = 14\n    max_score = prob_no_finding\n    final_pred_boxes = []\n\n    if len(boxes) > 0:\n        for box in boxes:\n            *coords, conf_yolo, cls_id = box\n            cls_id = int(cls_id)\n            if cls_id < 14: \n                conf_cls = cls_probs[cls_id]\n                final_conf = calculate_fusion_score(conf_yolo, conf_cls, cls_id)\n                \n                if final_conf > CONF_THRESHOLD:\n                    x1, y1, x2, y2 = map(int, coords)\n                    final_pred_boxes.append([x1, y1, x2, y2, cls_id, final_conf])\n                    if final_conf > max_score:\n                        max_score = final_conf\n                        pred_cls = cls_id\n                \n    y_pred.append(pred_cls)\n    \n    results_data.append({\n        'img_path': file_path,\n        'true_cls': true_cls, 'pred_cls': pred_cls, 'max_score': max_score,\n        'gt_boxes': gt_boxes, 'pred_boxes': final_pred_boxes\n    })\n\n# --- VISUALIZE ---\nprint(\"\\n\" + \"=\"*50)\nprint(\"📊 BÁO CÁO KẾT QUẢ TOÀN DIỆN\")\nprint(\"=\"*50)\n\n# Metrics\nprint(classification_report(y_true, y_pred, target_names=[class_names[i] for i in sorted(list(set(y_true)|set(y_pred)))]))\n\n# Plot\nplot_cms(y_true, y_pred, class_names)\nplot_error_analysis(y_true, y_pred, class_names)\n\n# Failures\ndf_details = pd.DataFrame(results_data)\nvisualize_failures(df_details, class_names, top_n=5)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-25T21:12:58.667318Z","iopub.execute_input":"2026-01-25T21:12:58.668067Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# ===== CHẠY TEST + FUSION + VISUALIZE =====\n\nimport pandas as pd\nimport numpy as np\nimport os\nimport cv2\nimport torch\nfrom tqdm.notebook import tqdm\nfrom sklearn.metrics import accuracy_score, classification_report, confusion_matrix\nimport matplotlib.pyplot as plt\nimport seaborn as sns\n\n# 1. Load YOLO (YOLO tự load architecture nên dễ hơn EffNet)\nyolo_model = YOLO('/kaggle/input/trained-models-vindr-xray/VinDr_Project/yolov8m_balanced_final/weights/best.pt') # Sửa đường dẫn\n\n# --- CẤU HÌNH ---\nTEST_DIR = \"/kaggle/working/test_dicom\"           # Folder vừa tạo\nTEST_CSV = \"/kaggle/working/test_ground_truth.csv\" # CSV vừa tạo\nCONF_THRESHOLD = 0.15   # Ngưỡng lọc cuối cùng\nIOU_THRESHOLD = 0.4     # Ngưỡng NMS YOLO\n\nprint(\"🚀 Đang chạy đánh giá trên tập Test giả lập...\")\n\n# 1. Load Ground Truth (Đáp án)\ndf_gt = pd.read_csv(TEST_CSV)\n\ny_true = []\ny_pred = []\n\n# Map tên class (0-14)\nclass_names = {\n    0: 'Aortic enlargement', 1: 'Atelectasis', 2: 'Calcification', 3: 'Cardiomegaly',\n    4: 'Consolidation', 5: 'ILD', 6: 'Infiltration', 7: 'Lung Opacity',\n    8: 'Nodule/Mass', 9: 'Other lesion', 10: 'Pleural effusion',\n    11: 'Pleural thickening', 12: 'Pneumothorax', 13: 'Pulmonary fibrosis', 14: 'No Finding'\n}\n\n# Lấy danh sách file trong folder test\ntest_files = [f for f in os.listdir(TEST_DIR) if f.endswith('.dicom')]\n\n# --- VÒNG LẶP TEST ---\nfor file_name in tqdm(test_files):\n    img_id = file_name.replace('.dicom', '')\n    file_path = os.path.join(TEST_DIR, file_name)\n    \n    # A. Lấy Nhãn Thực Tế (Ground Truth)\n    # Logic: Nếu ảnh có class 14 -> Nhãn 14. Nếu có bệnh -> Lấy bệnh đầu tiên (để vẽ matrix đơn giản)\n    gt_rows = df_gt[df_gt['image_id'] == img_id]\n    if 14 in gt_rows['class_id'].values:\n        true_cls = 14\n    else:\n        # Lấy bệnh xuất hiện nhiều nhất hoặc bệnh đầu tiên\n        true_cls = gt_rows['class_id'].values[0]\n    y_true.append(true_cls)\n\n    # B. Xử lý ảnh (Dùng hàm Mảnh 1 bạn đã tạo)\n    try:\n        # Hàm này trả về ảnh RGB (1024, 1024, 3)\n        img_rgb = read_process_dicom(file_path, target_size=1024) \n    except:\n        y_pred.append(14) # Lỗi đọc file -> coi như không tìm thấy gì\n        continue\n\n    # C. EfficientNet Predict\n    # Dùng hàm Transform Mảnh 2\n    aug = get_test_transforms()(image=img_rgb)\n    img_tensor = aug['image'].unsqueeze(0).to(DEVICE)\n    \n    with torch.no_grad():\n        cls_logits = cls_model(img_tensor)\n        cls_probs = torch.sigmoid(cls_logits).cpu().numpy()[0]\n\n    # D. YOLO Predict\n    # YOLO tự resize, nhưng ta đưa ảnh đã resize 1024 vào cho đồng bộ\n    results = yolo_model(img_rgb, conf=0.001, iou=IOU_THRESHOLD, verbose=False)[0]\n    boxes = results.boxes.data.cpu().numpy()\n    \n    # E. FUSION LOGIC (Quyết định nhãn cho ảnh)\n    pred_cls = 14 # Mặc định là Sạch\n    max_score = 0\n    \n    if len(boxes) > 0:\n        for box in boxes:\n            *coords, conf_yolo, cls_id = box\n            cls_id = int(cls_id)\n            \n            conf_cls = cls_probs[cls_id]\n            \n            # Công thức Fusion\n            final_conf = calculate_fusion_score(conf_yolo, conf_cls, cls_id)\n            \n            if final_conf > max_score and final_conf > CONF_THRESHOLD:\n                max_score = final_conf\n                pred_cls = cls_id\n                \n    y_pred.append(pred_cls)\n\n# --- BÁO CÁO KẾT QUẢ ---\nprint(\"\\n\" + \"=\"*50)\nprint(f\"📊 KẾT QUẢ TEST TRÊN {len(y_true)} ẢNH\")\nprint(\"=\"*50)\n\n# 1. Tính toán Accuracy\nacc = accuracy_score(y_true, y_pred)\nprint(f\"✅ Accuracy Trung Bình: {acc:.4f}\")\n\n# 2. TÍNH TOÁN CONFUSION MATRIX (Đây là phần bạn bị thiếu)\ncm = confusion_matrix(y_true, y_pred, labels=list(range(15))) # Tính ma trận\nlabels = list(class_names.values()) # Lấy tên các bệnh để gán nhãn\n\n# 3. Tạo khung vẽ (Figure & Axes) (Bạn cũng thiếu phần này để có biến 'ax')\nfig, ax = plt.subplots(1, 2, figsize=(24, 10))\n\n# --- Vẽ Ma trận Số lượng (Raw Counts) ---\nsns.heatmap(cm, annot=True, fmt='d', cmap='Blues', \n            xticklabels=labels, yticklabels=labels, ax=ax[0])\nax[0].set_title('Confusion Matrix (Số lượng)', fontsize=15)\nax[0].set_xlabel('Dự đoán')\nax[0].set_ylabel('Thực tế')\nax[0].tick_params(axis='x', rotation=45)\n\n# --- Vẽ Ma trận Chuẩn hóa (Normalized %) ---\n# Chia mỗi ô cho tổng hàng ngang để ra phần trăm\ncm_norm = cm.astype('float') / (cm.sum(axis=1)[:, np.newaxis] + 1e-9) # +1e-9 để tránh chia cho 0\n# Xử lý trường hợp chia cho 0 (nếu có class không có mẫu nào)\ncm_norm = np.nan_to_num(cm_norm)\n\nsns.heatmap(cm_norm, annot=True, fmt='.2f', cmap='Greens', \n            xticklabels=labels, yticklabels=labels, ax=ax[1])\nax[1].set_title('Normalized Confusion Matrix (Phần trăm)', fontsize=15)\nax[1].set_xlabel('Dự đoán')\nax[1].set_ylabel('Thực tế')\nax[1].tick_params(axis='x', rotation=45)\n\nplt.tight_layout()\nplt.show()\n\n# 4. Báo cáo chi tiết từng bệnh\nprint(\"\\n📝 Chi tiết từng lớp bệnh:\")\n# Sửa cm_labels thành labels (vì biến cm_labels không tồn tại)\nprint(classification_report(y_true, y_pred, target_names=labels, labels=list(range(15))))","metadata":{"execution":{"iopub.status.busy":"2026-01-25T20:26:03.773327Z","iopub.execute_input":"2026-01-25T20:26:03.774407Z","iopub.status.idle":"2026-01-25T20:45:23.321426Z","shell.execute_reply.started":"2026-01-25T20:26:03.774372Z","shell.execute_reply":"2026-01-25T20:45:23.320559Z"}}}]}