{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.11","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceId":71549,"databundleVersionId":8561470,"sourceType":"competition"},{"sourceId":12063155,"sourceType":"datasetVersion","datasetId":7592885}],"dockerImageVersionId":31041,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"from pathlib import Path\nimport pandas as pd\n\n# ─── 1. Load CSVs ─────────────────────────────────────────────────────────────\nROOT = Path('/kaggle/input/rsna-2024-lumbar-spine-degenerative-classification/')\ntrain = pd.read_csv(ROOT / 'train.csv')\nlabel_df = pd.read_csv(ROOT / 'train_label_coordinates.csv')\nseries_df = pd.read_csv(ROOT / 'train_series_descriptions.csv')\n\n# ─── 2. Melt train.csv → long format ───────────────────────────────────────────\ntrain_long = train.melt(id_vars='study_id', var_name='cond_lvl', value_name='score')\n\n# ─── 3. Extract condition and levels ───────────────────────────────────────────\n# Extract condition prefix\ntrain_long['cond_key'] = train_long['cond_lvl'].str.rsplit('_', n=2).str[0]\n\n# Extract levels, including cases like l5_s1 or l2_l3\ntrain_long['level'] = train_long['cond_lvl'].str.extract(r'_(l\\d+_[ls]\\d+)$')[0]\ntrain_long['level'] = train_long['level'].str.upper().str.replace('_', '/')\n\n# 🔁 Normalize conditions (combine left/right into one for 2 conditions)\ndef map_condition(key):\n    if 'spinal_canal_stenosis' in key:\n        return 'Spinal Canal Stenosis'\n    elif 'neural_foraminal_narrowing' in key:\n        return 'Neural Foraminal Narrowing'\n    elif 'subarticular_stenosis' in key:\n        return 'Subarticular Stenosis'\n    else:\n        return None\n\ntrain_long['condition'] = train_long['cond_key'].map(map_condition)\n\n# Drop rows with unmapped conditions or levels\ntrain_long = train_long.dropna(subset=['condition', 'level'])\n\n# ─── 4. Normalize levels & condition in label_df ───────────────────────────────\nlabel_df['level'] = label_df['level'].str.upper().str.strip()\ntrain_long['level'] = train_long['level'].str.upper().str.strip()\n\nlabel_df['condition'] = label_df['condition'].replace({\n    'Left Neural Foraminal Narrowing': 'Neural Foraminal Narrowing',\n    'Right Neural Foraminal Narrowing': 'Neural Foraminal Narrowing',\n    'Left Subarticular Stenosis': 'Subarticular Stenosis',\n    'Right Subarticular Stenosis': 'Subarticular Stenosis',\n})\n\n# ─── 5. Merge ─────────────────────────────────────────────────────────────────\nmerged = (\n    label_df\n    .merge(train_long[['study_id', 'condition', 'level', 'score']],\n           on=['study_id', 'condition', 'level'], how='left')\n    .merge(series_df[['study_id', 'series_id', 'series_description']],\n           on=['study_id', 'series_id'], how='left')\n)\n\n# ─── 6. Report ────────────────────────────────────────────────────────────────\nprint(\"✅ Merged data shape:\", merged.shape)\nmissing = merged['score'].isna().sum()\nprint(f\"⚠️ Missing scores: {missing} (should now be close to 0)\")\nprint(\"🕵️ Remaining unmatched rows (sample):\")\nprint(merged[merged['score'].isna()][['study_id', 'condition', 'level']].head())\n\n# Optional check\nprint(\"🧪 Unique conditions:\", merged['condition'].unique())\nprint(\"🧪 Unique levels:\", sorted(merged['level'].unique()))\n","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"merged = merged.dropna(subset=['score'])\nprint(merged.head())","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport numpy as np\nimport pandas as pd\nfrom pathlib import Path\nfrom PIL import Image, UnidentifiedImageError\nfrom tqdm import tqdm\nimport pydicom\nfrom pydicom.pixel_data_handlers.util import apply_voi_lut\n\n# ─── 1. Prepare ───────────────────────────────────────────────────────────────\nmerged = merged.dropna(subset=['score'])  # Drop rows with no label\nmerged['stem'] = merged.apply(\n    lambda r: f\"{int(r.study_id)}_{int(r.series_id)}_{int(r.instance_number)}\", axis=1\n)\n\nneeded_stems = set(merged['stem'])\nprint(f\"🔍 Will convert only {len(needed_stems)} images that have an ROI\")\n\n# ─── 2. DICOM to PNG ──────────────────────────────────────────────────────────\nROOT = Path('/kaggle/input/rsna-2024-lumbar-spine-degenerative-classification/')\nIMG_DIR = Path('data/images/train/'); IMG_DIR.mkdir(parents=True, exist_ok=True)\n\nstem2path = {}\nfor dcm_path in ROOT.rglob('*.dcm'):\n    p = Path(dcm_path)\n    rel = p.relative_to(ROOT).with_suffix('').parts\n    stem = \"_\".join(rel[-3:])\n    if stem in needed_stems:\n        stem2path[stem] = dcm_path\n\nprint(f\"🔍 Found {len(stem2path)} DICOMs matching ROI stems\")\n\nconverted = 0\nfor stem, dcm_path in tqdm(stem2path.items(), desc=\"Converting ROI→PNG\"):\n    png_fp = IMG_DIR / f\"{stem}.png\"\n    if png_fp.exists():\n        continue\n    try:\n        ds = pydicom.dcmread(str(dcm_path))\n        arr = apply_voi_lut(ds.pixel_array, ds).astype(np.float32)\n        arr = ((arr - arr.min()) / (arr.max() - arr.min()) * 255).astype(np.uint8)\n        Image.fromarray(arr).save(png_fp)\n        converted += 1\n    except Exception as e:\n        print(f\"❌ Skipped {dcm_path}: {e}\")\nprint(f\"✅ Converted {converted} DICOMs to PNG\")\n\n# ─── 3. Image size lookup ─────────────────────────────────────────────────────\npng_paths = list(IMG_DIR.glob(\"*.png\"))\nimage_shapes = {}\nskipped = []\nfor p in tqdm(png_paths, desc=\"Loading image shapes\"):\n    try:\n        H, W = Image.open(p).size[::-1]\n        image_shapes[p.stem] = (H, W)\n    except (UnidentifiedImageError, OSError):\n        skipped.append(p.name)\nprint(f\"⚠️ Skipped {len(skipped)} unreadable PNGs\")\nprint(f\"✅ Collected shapes for {len(image_shapes)} images\")\n\n# ─── 4. YOLO Label Generation ─────────────────────────────────────────────────\nLABEL_DIR = Path('data/labels/train/'); LABEL_DIR.mkdir(parents=True, exist_ok=True)\nROI_SIZE = 128  # You can change this to match cropping logic\n\ndef get_cls_id(cond_text: str) -> int:\n    if cond_text == 'Spinal Canal Stenosis':\n        return 0\n    elif cond_text == 'Neural Foraminal Narrowing':\n        return 1\n    elif cond_text == 'Subarticular Stenosis':\n        return 2\n    raise ValueError(f\"Unknown condition: {cond_text!r}\")\n\ndef write_yolo(stem, cls_id, x_c, y_c, w_n, h_n):\n    with open(LABEL_DIR / f\"{stem}.txt\", 'a') as f:\n        f.write(f\"{cls_id} {x_c:.6f} {y_c:.6f} {w_n:.6f} {h_n:.6f}\\n\")\n\nfor _, row in tqdm(merged.iterrows(), total=len(merged), desc=\"Writing YOLO labels\"):\n    stem = row.stem\n    if stem not in image_shapes:\n        continue\n    H, W = image_shapes[stem]\n    x_c, y_c = row.x / W, row.y / H\n    w_n = h_n = ROI_SIZE / W\n    cls_id = get_cls_id(row.condition)\n    write_yolo(stem, cls_id, x_c, y_c, w_n, h_n)\n\nprint(f\"✅ YOLO labels saved to {LABEL_DIR}\")\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 2: Build train/val splits & data.yaml for YOLOv8\n\nimport random\nfrom pathlib import Path\nfrom sklearn.model_selection import train_test_split\n\n# 1) Gather all the stems you converted\nIMG_DIR = Path('data/images/train/')\nall_stems = [p.stem for p in IMG_DIR.glob(\"*.png\")]\nprint(f\"🖼️  {len(all_stems)} total images found\")\n\n# 2) Split into train / val\ntrain_stems, val_stems = train_test_split(\n    all_stems,\n    test_size=0.2,\n    random_state=42,\n    shuffle=True\n)\nprint(f\"🚂  {len(train_stems)} train  |  {len(val_stems)} val\")\n\n# 3) Write file lists\nwith open('data/train.txt','w') as f:\n    for s in train_stems:\n        f.write(f\"{IMG_DIR}/{s}.png\\n\")\n\nwith open('data/val.txt','w') as f:\n    for s in val_stems:\n        f.write(f\"{IMG_DIR}/{s}.png\\n\")\n\nprint(\"✅ train.txt & val.txt saved\")\n\n# 4) Create data.yaml\nimport yaml\n\ndata = {\n    'path': '.',            # project root\n    'train': 'data/train.txt',\n    'val':   'data/val.txt',\n    'nc':    3,             # number of classes\n    'names': [\n        'Spinal Canal Stenosis',\n        'Neural Foraminal Narrowing',\n        'Subarticular Stenosis'\n    ]\n}\n\nwith open('data/data.yaml','w') as f:\n    yaml.dump(data, f, sort_keys=False)\n\nprint(\"✅ data/data.yaml created:\")\nprint(yaml.dump(data, sort_keys=False))","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from pathlib import Path\n\nLABEL_DIR = Path(\"data/labels/train/\")\nlog = []\n\nfor label_file in LABEL_DIR.glob(\"*.txt\"):\n    with open(label_file, \"r\") as f:\n        lines = f.readlines()\n\n    unique_lines = sorted(set(line.strip() for line in lines if line.strip()))\n    removed = len(lines) - len(unique_lines)\n\n    # Overwrite the original file with cleaned lines\n    with open(label_file, \"w\") as f:\n        for line in unique_lines:\n            f.write(line + \"\\n\")\n\n    if removed > 0:\n        log.append((label_file.name, removed))\n\n# 📝 Summary\nprint(\"✅ Cleaned YOLO label files (in-place).\")\nif log:\n    print(\"🧾 Duplicate labels removed per file:\")\n    for fname, count in log:\n        print(f\"{fname}: {count} duplicates removed\")\nelse:\n    print(\"🎉 No duplicates found.\")\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 3 (updated): 5-Fold Cross-Validation Training per Condition with per‐epoch checkpoints\n\nimport yaml\nfrom pathlib import Path\nfrom ultralytics import YOLO\nfrom sklearn.model_selection import StratifiedKFold\n\n# Ensure merged from Cell 1 is in scope\nconditions = {\n    'Spinal Canal Stenosis':      'Spinal_Canal_Stenosis',\n    'Neural Foraminal Narrowing': 'Neural_Foraminal_Narrowing',\n    'Subarticular Stenosis':      'Subarticular_Stenosis'\n}\n\nIMG_DIR   = Path('data/images/train/')\nBASE_DATA = Path('data')\n\nfor cond_text, cond_folder in conditions.items():\n    df_cond = merged[merged['condition'].str.contains(cond_text)].copy()\n\n    class_names = sorted(df_cond['level'].unique())\n    cls_map = {lvl: i for i, lvl in enumerate(class_names)}\n    df_cond['class_id'] = df_cond['level'].map(cls_map)\n\n    stems = df_cond['stem'].tolist()\n    labels = df_cond['class_id'].tolist()\n\n    skf = StratifiedKFold(n_splits=2, shuffle=True, random_state=42)\n    for fold, (train_idx, val_idx) in enumerate(skf.split(stems, labels)):\n        fold_dir = BASE_DATA/cond_folder/f'fold_{fold}'\n        fold_dir.mkdir(parents=True, exist_ok=True)\n\n        # write train/val lists\n        train_txt = fold_dir/'train.txt'\n        val_txt   = fold_dir/'val.txt'\n        with open(train_txt, 'w') as f:\n            for i in train_idx:\n                f.write(f\"{IMG_DIR}/{stems[i]}.png\\n\")\n        with open(val_txt, 'w') as f:\n            for i in val_idx:\n                f.write(f\"{IMG_DIR}/{stems[i]}.png\\n\")\n\n        # write data.yaml\n        data_cfg = {\n            'path': '.', \n            'train': str(train_txt), \n            'val':   str(val_txt), \n            'nc':    len(class_names), \n            'names': class_names\n        }\n        data_yaml = fold_dir/'data.yaml'\n        with open(data_yaml, 'w') as f:\n            yaml.dump(data_cfg, f, sort_keys=False)\n\n        # train and save a checkpoint every epoch\n        print(f\"\\n▶️  Training {cond_folder}, fold {fold}\")\n        model = YOLO('yolov8n.pt')\n        model.train(\n            data=str(data_yaml),\n            epochs=1,\n            imgsz=512,\n            batch=16,\n            project='runs/detect',\n            name=f\"{cond_folder}_fold{fold}\",\n            save_period=1,    # save epoch_{i}.pt each epoch\n            verbose=True,\n            plots=True\n        )","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"pip install ultralytics\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-06-04T22:49:27.480594Z","iopub.execute_input":"2025-06-04T22:49:27.480892Z","iopub.status.idle":"2025-06-04T22:49:30.546935Z","shell.execute_reply.started":"2025-06-04T22:49:27.480872Z","shell.execute_reply":"2025-06-04T22:49:30.545982Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 4: Inference & Crop ROIs (clean, per-fold, only split images, deduplicated)\n\nimport os\nimport sys\nimport contextlib\nfrom pathlib import Path\nfrom PIL import Image\nfrom ultralytics import YOLO\nfrom tqdm import tqdm\nimport numpy as np\n\n@contextlib.contextmanager\ndef suppress_output():\n    with open(os.devnull, \"w\") as devnull:\n        old_stdout, old_stderr = sys.stdout, sys.stderr\n        try:\n            sys.stdout = devnull\n            sys.stderr = devnull\n            yield\n        finally:\n            sys.stdout = old_stdout\n            sys.stderr = old_stderr\n\nconditions = {\n    'Spinal_Canal_Stenosis':      'Spinal_Canal_Stenosis',\n    'Neural_Foraminal_Narrowing': 'Neural_Foraminal_Narrowing',\n    'Subarticular_Stenosis':      'Subarticular_Stenosis'\n}\n\n# New paths based on where your data is stored\nWORKING_ROOT = Path('/kaggle/input/processed/kaggle/working/')\nIMG_DIR = WORKING_ROOT / 'data/images/train/'\nCROPS_BASE = Path('/kaggle/working/data/crops/')\n\nCROPS_BASE.mkdir(parents=True, exist_ok=True)\n\nfor cond_folder in conditions.values():\n    fold_base = WORKING_ROOT / f'data/{cond_folder}'\n    if not fold_base.exists():\n        continue\n\n    fold_dirs = sorted(f for f in fold_base.iterdir() if f.name.startswith('fold_'))\n    for fold_dir in fold_dirs:\n        fold_idx = fold_dir.name.split('_')[-1]\n\n        train_path = fold_dir / 'train.txt'\n        val_path   = fold_dir / 'val.txt'\n        if not train_path.exists() or not val_path.exists():\n            print(f\"⚠️ Skipping {fold_dir}: missing train/val split files.\")\n            continue\n\n        base_prefix = WORKING_ROOT  # Adjust relative paths from splits\n        with open(train_path) as f1, open(val_path) as f2:\n            crop_list = [\n                base_prefix / Path(p.strip()) for p in f1.readlines() + f2.readlines()\n                if (base_prefix / Path(p.strip())).exists()\n            ]\n        def is_corrupted(img_path):\n            try:\n                with Image.open(img_path) as img:\n                    img.verify()\n                return False\n            except:\n                return True\n\n        crop_list = [p for p in crop_list if p.exists() and not is_corrupted(p)]    \n\n        yolo_weights = WORKING_ROOT / f'runs/detect/{cond_folder}_fold{fold_idx}/weights'\n        best_ckpt = yolo_weights / 'best.pt'\n        if not best_ckpt.exists():\n            print(f\"⚠️ Skipping {cond_folder}_fold{fold_idx}: no best.pt found.\")\n            continue\n\n        with suppress_output():\n            model = YOLO(str(best_ckpt))\n\n        out_dir = CROPS_BASE / cond_folder / f'fold_{fold_idx}'\n        out_dir.mkdir(parents=True, exist_ok=True)\n\n        for img_fp in tqdm(crop_list, desc=f\"Cropping {cond_folder}_fold{fold_idx}\"):\n            with suppress_output():\n                results = model(img_fp, imgsz=512, conf=0.3, verbose=False)\n\n            boxes = results[0].boxes.xyxy.cpu().numpy()\n            confs = results[0].boxes.conf.cpu().numpy()\n            if boxes.size == 0:\n                continue\n\n            im = Image.open(img_fp)\n            stem = img_fp.stem\n            seen_boxes = []\n\n            for j, (box, conf) in enumerate(sorted(zip(boxes, confs), key=lambda x: -x[1])):\n                x1, y1, x2, y2 = map(int, box)\n\n                # Check for near-duplicate box\n                duplicate = False\n                for px1, py1, px2, py2 in seen_boxes:\n                    if abs(x1 - px1) < 5 and abs(y1 - py1) < 5 and abs(x2 - px2) < 5 and abs(y2 - py2) < 5:\n                        duplicate = True\n                        break\n\n                if duplicate:\n                    continue\n\n                seen_boxes.append((x1, y1, x2, y2))\n                crop = im.crop((x1, y1, x2, y2))\n                crop.save(out_dir / f\"{stem}_roi{j}.png\")\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Cell 5: Train severity classifiers for all 3 conditions from ROI crops\n\nimport torch\nfrom torch import nn, optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import models, transforms\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import classification_report, confusion_matrix\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom pathlib import Path\nfrom PIL import Image\n\n# Settings\nCROP_ROOT = Path('data/crops')\nMERGED = merged  # from Cell 1\nBATCH_SIZE = 32\nEPOCHS = 10\nIMG_SIZE = 224\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n\nLABEL_MAP = {'Normal/Mild': 0, 'Moderate': 1, 'Severe': 2}\nIDX2LABEL = {v: k for k, v in LABEL_MAP.items()}\n\nconditions = {\n    'Spinal_Canal_Stenosis':      'Spinal Canal Stenosis',\n    'Neural_Foraminal_Narrowing': 'Neural Foraminal Narrowing',\n    'Subarticular_Stenosis':      'Subarticular Stenosis'\n}\n\n# Dataset class\nclass CropDataset(Dataset):\n    def __init__(self, df, transform=None):\n        self.df = df.reset_index(drop=True)\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        img = Image.open(row['path']).convert('RGB')\n        if self.transform:\n            img = self.transform(img)\n        return img, torch.tensor(row['label_id'])\n\n# Transform\ntransform = transforms.Compose([\n    transforms.Resize((IMG_SIZE, IMG_SIZE)),\n    transforms.ToTensor(),\n    transforms.Normalize([0.485, 0.456, 0.406],\n                         [0.229, 0.224, 0.225])\n])\n\nfor crop_folder, original_condition in conditions.items():\n    crop_dir = CROP_ROOT / crop_folder\n    if not crop_dir.exists():\n        print(f\"⚠️ Skipping {crop_folder}: no crop directory found.\")\n        continue\n\n    # Step 1: Load (path, label_id) pairs\n    records = []\n    for crop_fp in crop_dir.rglob(\"*.png\"):\n        stem = crop_fp.stem.split('_roi')[0]\n        row = MERGED[(MERGED['stem'] == stem) &\n                     (MERGED['condition'].str.contains(original_condition))]\n\n        if row.empty or pd.isna(row.iloc[0]['score']):\n            continue\n\n        severity = row.iloc[0]['score']\n        if severity not in LABEL_MAP:\n            continue\n\n        records.append({'path': str(crop_fp), 'label_id': LABEL_MAP[severity]})\n\n    df = pd.DataFrame(records)\n    if df.empty:\n        print(f\"⚠️ Skipping {crop_folder}: no labeled crops found.\")\n        continue\n\n    print(f\"\\n🧮 Class distribution for {crop_folder}: {df['label_id'].value_counts().to_dict()}\")\n\n    # Step 2: Train/val split\n    train_df, val_df = train_test_split(\n        df, stratify=df['label_id'], test_size=0.2, random_state=42\n    )\n\n    train_loader = DataLoader(CropDataset(train_df, transform), batch_size=BATCH_SIZE, shuffle=True)\n    val_loader   = DataLoader(CropDataset(val_df, transform), batch_size=BATCH_SIZE)\n\n    # Step 3: Model\n    model = models.resnet18(pretrained=True)\n    model.fc = nn.Linear(model.fc.in_features, len(LABEL_MAP))\n    model = model.to(DEVICE)\n\n    # Step 4: Training setup\n    criterion = nn.CrossEntropyLoss()\n    optimizer = optim.Adam(model.parameters(), lr=1e-4)\n    best_acc = 0.0\n\n    print(f\"\\n🚀 Training classifier for {crop_folder} with {len(df)} samples\")\n\n    for epoch in range(1, EPOCHS + 1):\n        model.train()\n        correct, total, loss_sum = 0, 0, 0.0\n        for imgs, labels in train_loader:\n            imgs, labels = imgs.to(DEVICE), labels.to(DEVICE)\n            optimizer.zero_grad()\n            out = model(imgs)\n            loss = criterion(out, labels)\n            loss.backward()\n            optimizer.step()\n            _, preds = out.max(1)\n            correct += (preds == labels).sum().item()\n            total += labels.size(0)\n            loss_sum += loss.item() * imgs.size(0)\n        acc = correct / total\n        print(f\"Epoch {epoch:02d} - Train Acc: {acc:.4f} | Loss: {loss_sum/total:.4f}\")\n\n        # Validation\n        model.eval()\n        with torch.no_grad():\n            correct, total = 0, 0\n            all_preds = []\n            all_labels = []\n            for imgs, labels in val_loader:\n                imgs, labels = imgs.to(DEVICE), labels.to(DEVICE)\n                out = model(imgs)\n                _, preds = out.max(1)\n                correct += (preds == labels).sum().item()\n                total += labels.size(0)\n                all_preds.extend(preds.cpu().numpy())\n                all_labels.extend(labels.cpu().numpy())\n\n            val_acc = correct / total\n            print(f\"            Val Acc:   {val_acc:.4f}\")\n            if val_acc > best_acc:\n                best_acc = val_acc\n                save_path = Path(f\"runs/classify/{crop_folder}_best.pth\")\n                save_path.parent.mkdir(exist_ok=True, parents=True)\n                torch.save(model.state_dict(), save_path)\n\n    print(f\"\\n✅ Best model saved for {crop_folder}: Val Acc = {best_acc:.4f}\")\n\n    # 🔍 Final Classification Report\n    print(\"\\n📊 Classification Report:\")\n    print(classification_report(all_labels, all_preds, target_names=LABEL_MAP.keys()))\n\n    # 🔍 Final Confusion Matrix\n    cm = confusion_matrix(all_labels, all_preds)\n    sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',\n                xticklabels=LABEL_MAP.keys(), yticklabels=LABEL_MAP.keys())\n    plt.title(f\"Confusion Matrix for {crop_folder}\")\n    plt.xlabel(\"Predicted\")\n    plt.ylabel(\"True\")\n    plt.show()\n","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}