{"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3","language":"python"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# PANDA (Radboud) — Stage 2: Trích xuất patch 500×500\n\nĐầu vào: `final_case_list_after_visual_qc.csv` (kết quả Stage 1 — đã lọc mask thiếu/rỗng,\nđối chiếu Gleason/ISUP, lọc theo FAM_TARO, và duyệt bằng mắt). Bạn cần upload file này\nlên Kaggle dưới dạng Dataset riêng và \"Add Data\" vào notebook.\n\n**Tham số bám sát bài báo:**\n- Patch size: 500×500\n- Nhãn: benign nếu 100% vùng biểu mô (epithelium) trong patch là benign; Gleason X nếu ≥50%\n  diện tích biểu mô trùng pattern X.\n\n**Tham số bổ sung (đã chốt ở các bước trước):**\n- Level đọc WSI: Level 1\n- Stride: 500 (không chồng lấn ở vùng trong slide)\n- Rìa slide: dịch cửa sổ cuối vào trong (chấp nhận overlap cục bộ ở rìa)\n\n**Lưu trữ (theo lựa chọn của bạn — lưu ảnh patch thật):**\n- Ảnh patch (từ WSI gốc): lưu **JPEG chất lượng 90** để giảm dung lượng.\n- Mask patch: lưu **PNG** (KHÔNG dùng JPEG) — vì JPEG là nén có mất dữ liệu (lossy), sẽ làm\n  sai lệch giá trị pixel (0–5) vốn là **nhãn phân lớp**, không phải ảnh thị giác thông thường.\n  Nén JPEG lên mask sẽ âm thầm làm hỏng nhãn mà không có cảnh báo lỗi nào — do đó bắt buộc\n  phải dùng định dạng lossless cho mask.\n\n**Xử lý giới hạn 20Gi của `/kaggle/working`:**\nNotebook theo dõi tổng dung lượng đã ghi trong lúc chạy. Khi gần chạm ngưỡng an toàn\n(mặc định 18 GiB, có thể chỉnh), notebook dừng lại, in hướng dẫn để bạn đẩy batch patch\nhiện tại lên **Kaggle Dataset riêng** (qua Kaggle API), sau đó xóa patch đã đẩy và\nchạy tiếp phần còn lại — có thể lặp lại nhiều vòng, nhiều phiên làm việc khác nhau\n(resumable, dựa trên file `manifest.csv` để biết case nào đã xử lý xong).\n","metadata":{}},{"cell_type":"markdown","source":"## 0. Cài đặt môi trường đọc WSI (giống Stage 1)","metadata":{}},{"cell_type":"code","source":"import subprocess, sys\n\ndef try_install_openslide():\n    try:\n        subprocess.run([\"apt-get\", \"install\", \"-y\", \"openslide-tools\"],\n                        check=True, capture_output=True)\n        subprocess.run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"openslide-python\"],\n                        check=True, capture_output=True)\n        return True\n    except Exception as e:\n        print(\"Khong cai duoc OpenSlide qua apt/pip:\", e)\n        return False\n\nOPENSLIDE_OK = try_install_openslide()\n\nWSI_BACKEND = None\nif OPENSLIDE_OK:\n    try:\n        import openslide\n        WSI_BACKEND = \"openslide\"\n    except ImportError:\n        pass\nif WSI_BACKEND is None:\n    import skimage.io\n    WSI_BACKEND = \"skimage\"\n\nprint(\"Backend duoc chon:\", WSI_BACKEND)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:30:15.762936Z","iopub.execute_input":"2026-07-28T15:30:15.764021Z","iopub.status.idle":"2026-07-28T15:30:28.621671Z","shell.execute_reply.started":"2026-07-28T15:30:15.763951Z","shell.execute_reply":"2026-07-28T15:30:28.620936Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 1. Config\n\n**Cần sửa `FINAL_CASE_LIST_CSV`** cho đúng với Kaggle Dataset bạn đã upload file\n`final_case_list_after_visual_qc.csv`.\n","metadata":{}},{"cell_type":"code","source":"from pathlib import Path\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\nclass Config:\n    DATA_ROOT = Path(\"/kaggle/input/competitions/prostate-cancer-grade-assessment\")\n    TRAIN_IMAGES_DIR = DATA_ROOT / \"train_images\"\n    TRAIN_MASKS_DIR = DATA_ROOT / \"train_label_masks\"\n\n    # --- SUA DUONG DAN NAY cho dung voi Kaggle Dataset ban da upload ---\n    FINAL_CASE_LIST_CSV = Path(\"/kaggle/input/datasets/nhtnguynad/finalll/final_case_list_after_visual_qc.csv\")\n\n    OUTPUT_ROOT = Path(\"/kaggle/working/panda_patches\")\n    PATCHES_IMAGES_DIR = OUTPUT_ROOT / \"images\"\n    PATCHES_MASKS_DIR = OUTPUT_ROOT / \"masks\"\n    MANIFEST_PATH = OUTPUT_ROOT / \"manifest.csv\"\n    PROCESSED_CASES_PATH = OUTPUT_ROOT / \"processed_cases.csv\"\n\n    # --- Trich xuat patch (theo bai bao + cac lua chon da chot) ---\n    PATCH_SIZE = 500\n    WSI_LEVEL = 0   # doi tu Level 1 -> Level 0, bam sat bai bao hon (khong ha do phan giai)\n    STRIDE = PATCH_SIZE\n\n    # --- Gan nhan patch (theo bai bao) ---\n    BENIGN_FULL_THRESHOLD = 1.0\n    GLEASON_OVERLAP_THRESHOLD = 0.5\n\n    # --- Loc patch theo % dien tich BIEU MO (khong phai stroma) ---\n    # Dung chinh dai luong epithelial_area (benign+gleason 3/4/5) ma quy tac gan\n    # nhan cua bai bao da dua vao, thay vi tieu chi mask != 0 (von tinh ca stroma).\n    MIN_EPITHELIAL_RATIO = 0.20  # >= 20% dien tich patch phai la bieu mo\n\n    # --- Gia tri mask (Radboud) ---\n    MASK_BACKGROUND = 0\n    MASK_STROMA = 1\n    MASK_BENIGN = 2\n    MASK_GLEASON_3 = 3\n    MASK_GLEASON_4 = 4\n    MASK_GLEASON_5 = 5\n\n    # --- Luu tru ---\n    JPEG_QUALITY = 90          # chi ap dung cho ANH, khong ap dung cho MASK\n    MAX_WORKING_DIR_GIB = 18.0  # nguong an toan, du tru duoi gioi han 20Gi cua /kaggle/working\n\n    # --- Gioi han thoi gian phien (Kaggle: toi da 12 gio/phien) ---\n    MAX_RUNTIME_HOURS = 11.0  # tu dung SOM hon gioi han that cua Kaggle, de kip luu an toan\n\n    # --- Tiep noi tu phien truoc (neu co) ---\n    # Neu ban da 'Add Data' output/Dataset cua phien truoc vao notebook nay, dien duong dan\n    # thu muc chua manifest.csv + processed_cases.csv cua phien do vao day de tu dong nap lai.\n    PREVIOUS_OUTPUT_DIR = Path('/kaggle/input/datasets/nhtnguynad/datasss')\n\n    # --- Gop toan bo output cua NHIEU vong lai (chi dung o cell cuoi cung) ---\n    # Sau khi da xu ly xong toan bo 3204 case (qua nhieu vong Save & Run All), 'Add Data'\n    # TAT CA output/Dataset cua tung vong vao 1 notebook cuoi, roi liet ke duong dan vao day.\n    ALL_ROUND_OUTPUT_DIRS = [Path('/kaggle/input/datasets/nhtnguynad/datasss'), Path('/kaggle/working/panda_patches')]\n    FINAL_MERGED_DIR = Path('/kaggle/working/panda_patches_merged')\n\n    # --- Chia fold subject-wise va augmentation can bang lop (theo bai bao) ---\n    N_FOLDS = 5\n    RANDOM_SEED = 42\n    AUGMENTED_IMAGES_DIR = FINAL_MERGED_DIR / 'images_augmented'\n    AUGMENTED_MASKS_DIR = FINAL_MERGED_DIR / 'masks_augmented'\n\ncfg = Config()\ncfg.OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)\ncfg.PATCHES_IMAGES_DIR.mkdir(parents=True, exist_ok=True)\ncfg.PATCHES_MASKS_DIR.mkdir(parents=True, exist_ok=True)\n\nprint(\"FINAL_CASE_LIST_CSV ton tai:\", cfg.FINAL_CASE_LIST_CSV.exists())\nprint(\"DATA_ROOT ton tai:\", cfg.DATA_ROOT.exists())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T19:09:46.812222Z","iopub.execute_input":"2026-07-28T19:09:46.812781Z","iopub.status.idle":"2026-07-28T19:09:46.82706Z","shell.execute_reply.started":"2026-07-28T19:09:46.812748Z","shell.execute_reply":"2026-07-28T19:09:46.826192Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. Đọc danh sách case cần xử lý","metadata":{}},{"cell_type":"code","source":"case_df = pd.read_csv(cfg.FINAL_CASE_LIST_CSV)\ncase_df[\"image_id\"] = case_df[\"image_id\"].astype(str)\nprint(\"Tong so case can trich patch:\", len(case_df))\ncase_df.head()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:30:28.955264Z","iopub.execute_input":"2026-07-28T15:30:28.955701Z","iopub.status.idle":"2026-07-28T15:30:29.003592Z","shell.execute_reply.started":"2026-07-28T15:30:28.955677Z","shell.execute_reply":"2026-07-28T15:30:29.002807Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3. Hàm đọc WSI theo vùng (region) và hàm gán nhãn patch\n\n`read_region_at_level`: đọc đúng 1 vùng ảnh (không phải toàn bộ slide) tại 1 level cụ thể —\nquan trọng vì WSI quá lớn để đọc nguyên slide vào RAM.\n","metadata":{}},{"cell_type":"code","source":"def get_mask_path(image_id):\n    p = cfg.TRAIN_MASKS_DIR / f\"{image_id}_mask.tiff\"\n    return p if p.exists() else None\n\n\ndef open_slide_any(path):\n    \"\"\"Tra ve doi tuong slide (chi dung khi backend la openslide).\"\"\"\n    return openslide.OpenSlide(str(path))\n\n\ndef get_level_dims(slide_or_path, level):\n    if WSI_BACKEND == \"openslide\":\n        return slide_or_path.level_dimensions[level]\n    else:\n        levels = skimage.io.MultiImage(str(slide_or_path))\n        idx = min(level, len(levels) - 1)\n        h, w = levels[idx].shape[:2]\n        return (w, h)\n\n\ndef read_region_at_level(slide_or_path, x, y, level, size):\n    \"\"\"\n    Doc 1 vung (x, y, size, size) tai `level`, voi (x, y) la toa do TAI CHINH LEVEL DO\n    (khong phai toa do level 0).\n    \"\"\"\n    if WSI_BACKEND == \"openslide\":\n        slide = slide_or_path\n        downsample = slide.level_downsamples[level]\n        x0 = int(x * downsample)\n        y0 = int(y * downsample)\n        region = slide.read_region((x0, y0), level, (size, size))\n        return np.array(region)[:, :, :3]\n    else:\n        levels = skimage.io.MultiImage(str(slide_or_path))\n        idx = min(level, len(levels) - 1)\n        arr = np.array(levels[idx])\n        h, w = arr.shape[:2]\n        patch = np.zeros((size, size, 3), dtype=np.uint8)\n        y1, x1 = min(y + size, h), min(x + size, w)\n        ph, pw = y1 - y, x1 - x\n        if ph > 0 and pw > 0:\n            src = arr[y:y1, x:x1]\n            if src.ndim == 2:\n                src = np.stack([src] * 3, axis=-1)\n            patch[:ph, :pw] = src[:, :, :3]\n        return patch\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:30:29.005352Z","iopub.execute_input":"2026-07-28T15:30:29.005716Z","iopub.status.idle":"2026-07-28T15:30:29.016067Z","shell.execute_reply.started":"2026-07-28T15:30:29.005691Z","shell.execute_reply":"2026-07-28T15:30:29.015139Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def compute_class_areas(mask_patch):\n    \"\"\"Tra ve dien tich (so pixel) tung lop bieu mo va tong dien tich bieu mo trong patch.\"\"\"\n    values, counts = np.unique(mask_patch, return_counts=True)\n    area = dict(zip(values.tolist(), counts.tolist()))\n\n    benign_area = area.get(cfg.MASK_BENIGN, 0)\n    g3 = area.get(cfg.MASK_GLEASON_3, 0)\n    g4 = area.get(cfg.MASK_GLEASON_4, 0)\n    g5 = area.get(cfg.MASK_GLEASON_5, 0)\n    epithelial_area = benign_area + g3 + g4 + g5\n    return benign_area, g3, g4, g5, epithelial_area\n\n\ndef label_patch(mask_patch):\n    \"\"\"\n    Ap dung dung quy tac gan nhan cua bai bao:\n    - benign: 100% dien tich bieu mo (epithelium = benign + gleason 3/4/5) la benign\n    - gleason_X: gleason X chiem >= 50% dien tich bieu mo\n    - None: khong dat nguong nao (patch hon hop, khong du dieu kien gan nhan phan loai)\n    \"\"\"\n    benign_area, g3, g4, g5, epithelial_area = compute_class_areas(mask_patch)\n\n    if epithelial_area == 0:\n        return None  # khong co vung bieu mo nao trong patch (chi stroma/nen)\n\n    if benign_area / epithelial_area >= cfg.BENIGN_FULL_THRESHOLD:\n        return \"benign\"\n\n    for pattern, pattern_area in [(3, g3), (4, g4), (5, g5)]:\n        if pattern_area / epithelial_area >= cfg.GLEASON_OVERLAP_THRESHOLD:\n            return f\"gleason_{pattern}\"\n\n    return None  # hon hop, khong dat nguong 50% cho pattern nao\n\n\ndef has_enough_epithelium(mask_patch):\n    \"\"\"\n    Giu patch neu ty le dien tich BIEU MO (benign + gleason 3/4/5) tren tong dien tich\n    patch dat toi thieu cfg.MIN_EPITHELIAL_RATIO. Thay the cho tieu chi cu (mask != 0,\n    von chi can 1 pixel stroma la du, khong lien quan den cach gan nhan).\n    \"\"\"\n    _, _, _, _, epithelial_area = compute_class_areas(mask_patch)\n    total_area = mask_patch.shape[0] * mask_patch.shape[1]\n    return (epithelial_area / total_area) >= cfg.MIN_EPITHELIAL_RATIO\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:30:29.017062Z","iopub.execute_input":"2026-07-28T15:30:29.01738Z","iopub.status.idle":"2026-07-28T15:30:29.031129Z","shell.execute_reply.started":"2026-07-28T15:30:29.017349Z","shell.execute_reply":"2026-07-28T15:30:29.030361Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4. Tính lưới tọa độ patch (xử lý rìa bằng cách dịch cửa sổ vào trong)\n\nÁp dụng đúng lựa chọn đã chốt: stride = patch size ở vùng trong, nhưng patch cuối cùng của\nmỗi hàng/cột được dịch vào trong để không bị hụt kích thước — chấp nhận overlap cục bộ ở rìa.\n","metadata":{}},{"cell_type":"code","source":"def compute_patch_coords(dim_size, patch_size):\n    \"\"\"Tra ve danh sach toa do bat dau (0-indexed) theo 1 chieu, xu ly riа bang cach dich vao trong.\"\"\"\n    if dim_size <= patch_size:\n        return [0]\n    coords = list(range(0, dim_size - patch_size + 1, patch_size))\n    last_covered_end = coords[-1] + patch_size\n    if last_covered_end < dim_size:\n        coords.append(dim_size - patch_size)  # dich cua so cuoi vao trong\n    return coords\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:30:29.032601Z","iopub.execute_input":"2026-07-28T15:30:29.033082Z","iopub.status.idle":"2026-07-28T15:30:29.04738Z","shell.execute_reply.started":"2026-07-28T15:30:29.033025Z","shell.execute_reply":"2026-07-28T15:30:29.046635Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5. Theo dõi dung lượng đã ghi (tự ước lượng, tránh vượt 20Gi)\n\nĐếm dồn số byte đã ghi thực tế (không dựa vào `shutil.disk_usage`, vì con số đó phản ánh\ntoàn bộ phân vùng đĩa dùng chung chứ không riêng hạn mức 20Gi của `/kaggle/working`).\n","metadata":{}},{"cell_type":"code","source":"def get_current_output_size_gib():\n    total_bytes = 0\n    for p in cfg.OUTPUT_ROOT.rglob(\"*\"):\n        if p.is_file():\n            total_bytes += p.stat().st_size\n    return total_bytes / (1024 ** 3)\n\nprint(\"Dung luong hien tai cua output:\", round(get_current_output_size_gib(), 3), \"GiB\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:30:29.048637Z","iopub.execute_input":"2026-07-28T15:30:29.049385Z","iopub.status.idle":"2026-07-28T15:30:29.0654Z","shell.execute_reply.started":"2026-07-28T15:30:29.049347Z","shell.execute_reply":"2026-07-28T15:30:29.064447Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5b. Ước lượng số lượng & dung lượng patch trên toàn bộ dataset\n\nLấy mẫu ngẫu nhiên một số case, tính:\n- Số patch thô (theo lưới sliding-window tại **Level 0**, trước khi lọc).\n- Số patch giữ lại sau khi lọc theo đúng tiêu chí bài báo: mask có mô (`≥20% diện tích biểu mô`).\n- Dung lượng trung bình mỗi patch ảnh (JPEG) và mỗi patch mask (PNG) — đo trực tiếp bằng cách\n  nén thử trong bộ nhớ (không ghi ra đĩa, không tốn dung lượng thật).\n\nTừ đó suy ra ước lượng cho **toàn bộ 3204 case**: tổng số patch, tổng dung lượng, và số \"vòng\"\ncần lặp lại (chạy → gần đầy 18Gi → đẩy Dataset → xóa cục bộ → chạy tiếp) theo mục 6-8.\n\n**Lưu ý quan trọng:** ở Level 0 (độ phân giải gốc, không downsample), số lượng patch trên mỗi\nslide sẽ **lớn hơn đáng kể** so với Level 1 trước đây (khoảng 16 lần nhiều ô lưới hơn, vì mỗi\nchiều tăng ~4 lần). Vì vậy tổng dung lượng ước tính lần này rất có thể **vượt lại ngưỡng 20Gi**\n— cần xem kỹ kết quả bên dưới trước khi chạy toàn bộ mục 6.\n","metadata":{}},{"cell_type":"code","source":"import io\nimport random\n\nN_SAMPLE_CASES = 30  # tang so nay de uoc luong chinh xac hon (danh doi voi thoi gian chay lau hon)\n\nsample_ids = random.sample(list(case_df[\"image_id\"]), k=min(N_SAMPLE_CASES, len(case_df)))\n\nraw_patch_counts = []\nkept_patch_counts = []\nimg_sizes_bytes = []\nmask_sizes_bytes = []\n\nfor image_id in sample_ids:\n    img_path = cfg.TRAIN_IMAGES_DIR / f\"{image_id}.tiff\"\n    mask_path = get_mask_path(image_id)\n    if mask_path is None:\n        continue\n\n    try:\n        if WSI_BACKEND == \"openslide\":\n            slide_img = open_slide_any(img_path)\n            slide_mask = open_slide_any(mask_path)\n            w, h = get_level_dims(slide_img, cfg.WSI_LEVEL)\n        else:\n            slide_img = img_path\n            slide_mask = mask_path\n            w, h = get_level_dims(img_path, cfg.WSI_LEVEL)\n\n        xs = compute_patch_coords(w, cfg.PATCH_SIZE)\n        ys = compute_patch_coords(h, cfg.PATCH_SIZE)\n\n        raw_count = len(xs) * len(ys)\n        kept_count = 0\n\n        for y in ys:\n            for x in xs:\n                mask_patch = read_region_at_level(slide_mask, x, y, cfg.WSI_LEVEL, cfg.PATCH_SIZE)\n                if mask_patch.ndim == 3:\n                    mask_patch = mask_patch[:, :, 0]\n                if not has_enough_epithelium(mask_patch):\n                    continue\n\n                img_patch = read_region_at_level(slide_img, x, y, cfg.WSI_LEVEL, cfg.PATCH_SIZE)\n\n                kept_count += 1\n\n                # Do dung luong thuc te bang cach nen thu trong bo nho (khong ghi ra dia)\n                if len(img_sizes_bytes) < 200:  # gioi han so mau do kich thuoc de khong qua cham\n                    img_buf = io.BytesIO()\n                    Image.fromarray(img_patch).save(img_buf, format=\"JPEG\", quality=cfg.JPEG_QUALITY)\n                    img_sizes_bytes.append(img_buf.tell())\n\n                    mask_buf = io.BytesIO()\n                    Image.fromarray(mask_patch).save(mask_buf, format=\"PNG\")\n                    mask_sizes_bytes.append(mask_buf.tell())\n\n        raw_patch_counts.append(raw_count)\n        kept_patch_counts.append(kept_count)\n\n        if WSI_BACKEND == \"openslide\":\n            slide_img.close()\n            slide_mask.close()\n\n    except Exception as e:\n        print(f\"[Bo qua {image_id} do loi]: {e}\")\n        continue\n\nprint(f\"So case lay mau thanh cong: {len(kept_patch_counts)} / {len(sample_ids)}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:30:29.066512Z","iopub.execute_input":"2026-07-28T15:30:29.06684Z","iopub.status.idle":"2026-07-28T15:35:51.952948Z","shell.execute_reply.started":"2026-07-28T15:30:29.06679Z","shell.execute_reply":"2026-07-28T15:35:51.952071Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"avg_raw = np.mean(raw_patch_counts)\navg_kept = np.mean(kept_patch_counts)\nkeep_ratio = avg_kept / avg_raw if avg_raw > 0 else 0\n\navg_img_kb = np.mean(img_sizes_bytes) / 1024\navg_mask_kb = np.mean(mask_sizes_bytes) / 1024\navg_patch_pair_kb = avg_img_kb + avg_mask_kb\n\ntotal_cases = len(case_df)\nest_total_patches = avg_kept * total_cases\nest_total_size_gib = (est_total_patches * avg_patch_pair_kb * 1024) / (1024 ** 3)\nest_n_rounds = est_total_size_gib / cfg.MAX_WORKING_DIR_GIB\n\nprint(\"=== UOC LUONG TREN MAU ===\")\nprint(f\"So patch tho trung binh/case (truoc loc):     {avg_raw:.1f}\")\nprint(f\"So patch giu lai trung binh/case (sau loc):    {avg_kept:.1f}\")\nprint(f\"Ty le giu lai:                                  {keep_ratio*100:.1f}%\")\nprint(f\"Dung luong trung binh 1 patch anh (JPEG):       {avg_img_kb:.1f} KB\")\nprint(f\"Dung luong trung binh 1 patch mask (PNG):       {avg_mask_kb:.1f} KB\")\nprint(f\"Dung luong trung binh 1 cap patch (anh+mask):   {avg_patch_pair_kb:.1f} KB\")\n\nprint(\"\\n=== UOC LUONG CHO TOAN BO DATASET ({} case) ===\".format(total_cases))\nprint(f\"Tong so patch uoc tinh:      {est_total_patches:,.0f}\")\nprint(f\"Tong dung luong uoc tinh:    {est_total_size_gib:,.1f} GiB\")\nprint(f\"So vong day Kaggle Dataset can thiet (nguong {cfg.MAX_WORKING_DIR_GIB} GiB/vong): \"\n      f\"~{est_n_rounds:.1f} vong\")\n\nprint(\"\\nLuu y: day la uoc luong tu mau ngau nhien, so thuc te co the lech dang ke \"\n      \"do dien tich mo dao dong lon giua cac case (anh PANDA la biopsy core).\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:35:51.954575Z","iopub.execute_input":"2026-07-28T15:35:51.954922Z","iopub.status.idle":"2026-07-28T15:35:51.963744Z","shell.execute_reply.started":"2026-07-28T15:35:51.954869Z","shell.execute_reply":"2026-07-28T15:35:51.962734Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5c. Cắt thử TOÀN BỘ patch của MỘT case ngẫu nhiên để kiểm tra trực quan\n\nThay vì lấy mẫu rải rác nhiều case, cell dưới đây chọn **1 case ngẫu nhiên**, cắt **toàn bộ**\npatch của case đó theo đúng tham số hiện tại (Level 0, patch 500×500, tiêu chí ≥20% diện tích biểu mô (`MIN_EPITHELIAL_RATIO`)),\nrồi hiển thị từng patch với 3 panel: **ảnh gốc | mask tô màu | ảnh chồng lớp (overlay)** — giúp\nkiểm tra toàn diện cả việc căn chỉnh ảnh/mask lẫn độ phủ patch trên một slide thực tế, trước khi\nchạy toàn bộ 3204 case ở mục 6.\n\nLưu ý: số lượng patch hiển thị tùy thuộc vào case được chọn ngẫu nhiên (có thể vài patch đến\nvài chục patch) — chạy lại cell nhiều lần để xem qua các case khác nhau.\n","metadata":{}},{"cell_type":"code","source":"import matplotlib.pyplot as plt\nimport random\n\nMASK_COLORS_PREVIEW = {\n    cfg.MASK_BACKGROUND: (255, 255, 255),\n    cfg.MASK_STROMA:     (180, 180, 180),\n    cfg.MASK_BENIGN:     (0, 200, 0),\n    cfg.MASK_GLEASON_3:  (255, 255, 0),\n    cfg.MASK_GLEASON_4:  (255, 140, 0),\n    cfg.MASK_GLEASON_5:  (220, 0, 0),\n}\n\ndef colorize_mask_preview(mask_patch):\n    rgb = np.zeros((*mask_patch.shape, 3), dtype=np.uint8)\n    for val, color in MASK_COLORS_PREVIEW.items():\n        rgb[mask_patch == val] = color\n    return rgb\n\n\ndef make_overlay_preview(img_patch, mask_rgb, mask_patch, alpha=0.45):\n    overlay = img_patch.astype(np.float32).copy()\n    fg = mask_patch != cfg.MASK_BACKGROUND\n    overlay[fg] = (1 - alpha) * overlay[fg] + alpha * mask_rgb[fg].astype(np.float32)\n    return overlay.astype(np.uint8)\n\n\npreview_case_id = random.choice(list(case_df[\"image_id\"]))\nprint(f\"Case duoc chon ngau nhien: {preview_case_id}\")\n\nimg_path = cfg.TRAIN_IMAGES_DIR / f\"{preview_case_id}.tiff\"\nmask_path = get_mask_path(preview_case_id)\n\npreview_patches = []  # list of (x, y, img_patch, mask_patch)\n\nif mask_path is None:\n    print(\"Case nay khong co file mask, chay lai cell de chon case khac.\")\nelse:\n    if WSI_BACKEND == \"openslide\":\n        slide_img = open_slide_any(img_path)\n        slide_mask = open_slide_any(mask_path)\n        w, h = get_level_dims(slide_img, cfg.WSI_LEVEL)\n    else:\n        slide_img = img_path\n        slide_mask = mask_path\n        w, h = get_level_dims(img_path, cfg.WSI_LEVEL)\n\n    xs = compute_patch_coords(w, cfg.PATCH_SIZE)\n    ys = compute_patch_coords(h, cfg.PATCH_SIZE)\n    print(f\"Kich thuoc slide tai Level {cfg.WSI_LEVEL}: {w} x {h}  |  Luoi: {len(xs)} x {len(ys)} = {len(xs)*len(ys)} o\")\n\n    for y in ys:\n        for x in xs:\n            mask_patch = read_region_at_level(slide_mask, x, y, cfg.WSI_LEVEL, cfg.PATCH_SIZE)\n            if mask_patch.ndim == 3:\n                mask_patch = mask_patch[:, :, 0]\n            if not has_enough_epithelium(mask_patch):\n                continue\n            img_patch = read_region_at_level(slide_img, x, y, cfg.WSI_LEVEL, cfg.PATCH_SIZE)\n            preview_patches.append((x, y, img_patch, mask_patch))\n\n    if WSI_BACKEND == \"openslide\":\n        slide_img.close()\n        slide_mask.close()\n\n    print(f\"So patch giu lai (co mo) cua case nay: {len(preview_patches)}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:35:51.966674Z","iopub.execute_input":"2026-07-28T15:35:51.967076Z","iopub.status.idle":"2026-07-28T15:36:01.949478Z","shell.execute_reply.started":"2026-07-28T15:35:51.967022Z","shell.execute_reply":"2026-07-28T15:36:01.948639Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"n = len(preview_patches)\n\nif n == 0:\n    print(\"Case nay khong co patch nao dat tieu chi (mask != 0). Chay lai cell truoc de chon case khac.\")\nelse:\n    fig, axes = plt.subplots(n, 3, figsize=(9, 3 * n))\n    if n == 1:\n        axes = axes.reshape(1, 3)\n\n    for i, (x, y, img_patch, mask_patch) in enumerate(preview_patches):\n        mask_rgb = colorize_mask_preview(mask_patch)\n        overlay = make_overlay_preview(img_patch, mask_rgb, mask_patch)\n        label = label_patch(mask_patch)\n\n        axes[i, 0].imshow(img_patch)\n        axes[i, 0].set_title(f\"Anh goc (x={x}, y={y})\", fontsize=8)\n        axes[i, 0].axis(\"off\")\n\n        axes[i, 1].imshow(mask_rgb)\n        axes[i, 1].set_title(f\"Mask - nhan: {label}\", fontsize=8)\n        axes[i, 1].axis(\"off\")\n\n        axes[i, 2].imshow(overlay)\n        axes[i, 2].set_title(\"Chong lop\", fontsize=8)\n        axes[i, 2].axis(\"off\")\n\n    fig.suptitle(f\"Case: {preview_case_id}\", fontsize=11, y=1.0)\n    plt.tight_layout()\n    plt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:36:01.950553Z","iopub.execute_input":"2026-07-28T15:36:01.951163Z","iopub.status.idle":"2026-07-28T15:36:11.71862Z","shell.execute_reply.started":"2026-07-28T15:36:01.951136Z","shell.execute_reply":"2026-07-28T15:36:11.717601Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5d. Nạp tiến độ từ phiên làm việc trước (nếu có)\n\nNếu đây là phiên tiếp theo sau khi phiên trước bị hết giờ/dừng giữa chừng:\n1. \"Add Data\" → chọn output (hoặc Dataset bạn đã tạo) của phiên trước.\n2. Sửa `cfg.PREVIOUS_OUTPUT_DIR` ở cell Config phía trên trỏ đúng đường dẫn thư mục đó\n   (thư mục chứa `manifest.csv` và `processed_cases.csv` của phiên trước).\n3. Chạy cell dưới đây — nó sẽ copy 2 file đó vào `/kaggle/working`, để mục 6 tự động nhận diện\n   và bỏ qua các case đã xử lý xong từ phiên trước.\n\nNếu đây là phiên đầu tiên (chưa có gì để tiếp nối), bỏ qua mục này (giữ nguyên\n`cfg.PREVIOUS_OUTPUT_DIR = None`).\n","metadata":{}},{"cell_type":"code","source":"import shutil\nimport zipfile\n\nif cfg.PREVIOUS_OUTPUT_DIR is not None:\n    # Kaggle tu dong nen output thanh 1 file _output_.zip neu co qua nhieu file\n    # (truong hop nay chac chan xay ra vi output co hang nghin patch anh/mask rieng le).\n    zip_candidates = list(cfg.PREVIOUS_OUTPUT_DIR.glob(\"*.zip\"))\n\n    if zip_candidates:\n        zip_path = zip_candidates[0]\n        extract_dir = Path(\"/kaggle/working/_previous_output_extracted\")\n        extract_dir.mkdir(parents=True, exist_ok=True)\n        print(f\"Phat hien output dang file nen: {zip_path.name} - dang giai nen...\")\n        with zipfile.ZipFile(zip_path, \"r\") as zf:\n            zf.extractall(extract_dir)\n        print(f\"Da giai nen vao: {extract_dir}\")\n        search_root = extract_dir\n    else:\n        search_root = cfg.PREVIOUS_OUTPUT_DIR\n\n    # Tim manifest.csv / processed_cases.csv o BAT KY thu muc con nao (khong gia dinh cau truc co dinh)\n    prev_manifest_matches = list(search_root.rglob(\"manifest.csv\"))\n    prev_processed_matches = list(search_root.rglob(\"processed_cases.csv\"))\n\n    if prev_manifest_matches and prev_processed_matches:\n        shutil.copy(prev_manifest_matches[0], cfg.MANIFEST_PATH)\n        shutil.copy(prev_processed_matches[0], cfg.PROCESSED_CASES_PATH)\n        n_prev_patches = len(pd.read_csv(cfg.MANIFEST_PATH))\n        n_prev_cases = len(pd.read_csv(cfg.PROCESSED_CASES_PATH))\n        print(f\"Da nap tien do tu phien truoc: {n_prev_cases} case da xong, {n_prev_patches} patch da co.\")\n        print(\"\\nLuu y: cac FILE ANH/MASK cua phien truoc van con nam trong output/zip cu, KHONG duoc\")\n        print(\"copy vao phien nay (chi copy 2 file CSV theo doi tien do). Ban se can gop cac output tu\")\n        print(\"nhieu vong lai voi nhau (giai nen tat ca cac _output_.zip cua tung version) o buoc cuoi\")\n        print(\"truoc khi dung toan bo du lieu de huan luyen mo hinh.\")\n    else:\n        print(f\"KHONG tim thay manifest.csv/processed_cases.csv trong {search_root}. \"\n              \"Kiem tra lai duong dan (vao tab Input de xem duong dan chinh xac).\")\nelse:\n    print(\"PREVIOUS_OUTPUT_DIR dang la None - day la phien dau tien, khong can nap gi ca.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:36:11.71979Z","iopub.execute_input":"2026-07-28T15:36:11.720188Z","iopub.status.idle":"2026-07-28T15:36:12.597254Z","shell.execute_reply.started":"2026-07-28T15:36:11.720162Z","shell.execute_reply":"2026-07-28T15:36:12.596321Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 6. Trích xuất patch — resumable (bỏ qua case đã xử lý xong ở lần chạy trước)\n\nTiêu chí giữ patch: mask phải có ít nhất 1 pixel mô (khác background) — đúng theo tiêu chí gốc của bài báo, không còn lọc theo độ sáng. Chạy cell này để trích patch. Nếu tổng dung lượng gần chạm ngưỡng an toàn\n(`cfg.MAX_WORKING_DIR_GIB`), notebook sẽ **dừng giữa chừng** (không mất dữ liệu đã ghi) và\nin hướng dẫn để bạn đẩy patch lên Kaggle Dataset ở mục 7, rồi quay lại chạy tiếp cell này —\ncác case đã xử lý xong sẽ tự động được bỏ qua nhờ `processed_cases.csv`.\n","metadata":{}},{"cell_type":"code","source":"import time\n\n# Nap tien do da xu ly tu truoc (neu co) - co the la tu chinh phien nay (chay lai cell) hoac\n# da duoc nap tu phien truoc o muc 5d\nif cfg.PROCESSED_CASES_PATH.exists():\n    processed_cases = set(pd.read_csv(cfg.PROCESSED_CASES_PATH)[\"image_id\"].astype(str))\nelse:\n    processed_cases = set()\n\nif cfg.MANIFEST_PATH.exists():\n    manifest_rows = pd.read_csv(cfg.MANIFEST_PATH).to_dict(\"records\")\nelse:\n    manifest_rows = []\n\nremaining_cases = case_df[~case_df[\"image_id\"].isin(processed_cases)]\nprint(f\"So case da xu ly xong tu truoc: {len(processed_cases)}\")\nprint(f\"So case con lai can xu ly: {len(remaining_cases)}\")\n\n\ndef save_progress():\n    \"\"\"Luu ca manifest va danh sach case da xong. Goi sau MOI case de chong mat du lieu\n    neu phien bi Kaggle ngat dot ngot (het gio, mat ket noi, v.v.).\"\"\"\n    pd.DataFrame(manifest_rows).to_csv(cfg.MANIFEST_PATH, index=False)\n    pd.DataFrame({\"image_id\": sorted(processed_cases)}).to_csv(cfg.PROCESSED_CASES_PATH, index=False)\n\n\nstart_time = time.time()\nSTOPPED_EARLY = False\nSTOP_REASON = None\n\nfor _, row in remaining_cases.iterrows():\n    image_id = row[\"image_id\"]\n\n    # 1) Kiem tra dung luong TRUOC KHI xu ly case tiep theo\n    current_size = get_current_output_size_gib()\n    if current_size >= cfg.MAX_WORKING_DIR_GIB:\n        STOPPED_EARLY = True\n        STOP_REASON = \"dung_luong\"\n        print(f\"\\n[DUNG SOM - DUNG LUONG] Hien tai ~{current_size:.2f} GiB, da gan nguong an toan \"\n              f\"{cfg.MAX_WORKING_DIR_GIB} GiB.\")\n        break\n\n    # 2) Kiem tra thoi gian TRUOC KHI xu ly case tiep theo\n    elapsed_hours = (time.time() - start_time) / 3600\n    if elapsed_hours >= cfg.MAX_RUNTIME_HOURS:\n        STOPPED_EARLY = True\n        STOP_REASON = \"thoi_gian\"\n        print(f\"\\n[DUNG SOM - THOI GIAN] Da chay ~{elapsed_hours:.2f} gio, gan nguong an toan \"\n              f\"{cfg.MAX_RUNTIME_HOURS} gio (Kaggle gioi han thuc te la 12 gio/phien).\")\n        break\n\n    img_path = cfg.TRAIN_IMAGES_DIR / f\"{image_id}.tiff\"\n    mask_path = get_mask_path(image_id)\n    if mask_path is None:\n        print(f\"[Bo qua {image_id}]: khong tim thay file mask.\")\n        processed_cases.add(image_id)\n        save_progress()\n        continue\n\n    try:\n        if WSI_BACKEND == \"openslide\":\n            slide_img = open_slide_any(img_path)\n            slide_mask = open_slide_any(mask_path)\n            w, h = get_level_dims(slide_img, cfg.WSI_LEVEL)\n        else:\n            slide_img = img_path\n            slide_mask = mask_path\n            w, h = get_level_dims(img_path, cfg.WSI_LEVEL)\n\n        xs = compute_patch_coords(w, cfg.PATCH_SIZE)\n        ys = compute_patch_coords(h, cfg.PATCH_SIZE)\n\n        n_saved = 0\n        for y in ys:\n            for x in xs:\n                mask_patch = read_region_at_level(slide_mask, x, y, cfg.WSI_LEVEL, cfg.PATCH_SIZE)\n                if mask_patch.ndim == 3:\n                    mask_patch = mask_patch[:, :, 0]\n\n                if not has_enough_epithelium(mask_patch):\n                    continue\n\n                label = label_patch(mask_patch)\n\n                img_patch = read_region_at_level(slide_img, x, y, cfg.WSI_LEVEL, cfg.PATCH_SIZE)\n\n                patch_id = f\"{image_id}_{x}_{y}\"\n                img_out_path = cfg.PATCHES_IMAGES_DIR / f\"{patch_id}.jpg\"\n                mask_out_path = cfg.PATCHES_MASKS_DIR / f\"{patch_id}.png\"\n\n                Image.fromarray(img_patch).save(img_out_path, format=\"JPEG\", quality=cfg.JPEG_QUALITY)\n                Image.fromarray(mask_patch).save(mask_out_path, format=\"PNG\")  # KHONG duoc dung JPEG cho mask\n\n                manifest_rows.append({\n                    \"patch_id\": patch_id,\n                    \"image_id\": image_id,\n                    \"x\": x,\n                    \"y\": y,\n                    \"label\": label,  # co the la None neu khong dat nguong gan nhan\n                    \"image_path\": str(img_out_path),\n                    \"mask_path\": str(mask_out_path),\n                })\n                n_saved += 1\n\n        if WSI_BACKEND == \"openslide\":\n            slide_img.close()\n            slide_mask.close()\n\n        processed_cases.add(image_id)\n        save_progress()  # LUU NGAY sau moi case - chong mat du lieu neu phien bi ngat dot ngot\n        print(f\"[OK] {image_id}: da luu {n_saved} patch. (~{elapsed_hours:.2f}h da chay)\")\n\n    except Exception as e:\n        print(f\"[LOI {image_id}]: {e}\")\n        processed_cases.add(image_id)  # danh dau da \"xu ly\" (that bai) de khong lap lai vo han\n        save_progress()\n        continue\n\nprint(f\"\\nDa luu manifest: {cfg.MANIFEST_PATH} ({len(manifest_rows)} patch)\")\nprint(f\"Da luu tien do: {cfg.PROCESSED_CASES_PATH} ({len(processed_cases)}/{len(case_df)} case)\")\n\nif STOPPED_EARLY:\n    if STOP_REASON == \"dung_luong\":\n        print(\"\\n=> Hay chay muc 7 de day patch len Kaggle Dataset, roi muc 8 de xoa cuc bo, \"\n              \"sau do QUAN TRONG: bam 'Save Version' de luu lai output cua phien nay, \"\n              \"roi mo phien moi va lam theo huong dan o muc 5d de tiep tuc.\")\n    elif STOP_REASON == \"thoi_gian\":\n        print(\"\\n=> Phien sap het gio. HAY BAM 'Save Version' NGAY BAY GIO de luu lai \"\n              \"manifest.csv/processed_cases.csv (va patch da co). Sau do mo phien moi, \"\n              \"'Add Data' output cua phien nay, va lam theo huong dan o muc 5d de tiep tuc.\")\nelse:\n    print(\"\\nDA XU LY XONG TOAN BO CASE.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:55:54.991305Z","iopub.execute_input":"2026-07-28T15:55:54.99161Z","iopub.status.idle":"2026-07-28T19:06:24.122606Z","shell.execute_reply.started":"2026-07-28T15:55:54.991585Z","shell.execute_reply":"2026-07-28T19:06:24.120139Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7. Đẩy patch hiện tại lên Kaggle Dataset (khi dừng sớm do gần chạm 20Gi)\n\nCần **Kaggle API token** (`kaggle.json`) để dùng lệnh `kaggle datasets ...` ngay trong notebook:\n1. Vào trang cá nhân Kaggle → Settings → API → \"Create New Token\" → tải file `kaggle.json`.\n2. Vào tab **Add-ons → Secrets** trong notebook, thêm secret chứa nội dung file này\n   (hoặc upload `kaggle.json` như một Kaggle Dataset riêng rồi copy vào `~/.kaggle/`).\n3. Chạy cell bên dưới để đẩy toàn bộ `panda_patches` hiện tại lên Kaggle Dataset của bạn.\n\n**Lần đầu** dùng `kaggle datasets create`; **các lần sau** (bổ sung thêm patch) dùng\n`kaggle datasets version` để cập nhật dataset đã tạo, tránh tạo trùng nhiều dataset rời rạc.\n","metadata":{}},{"cell_type":"code","source":"# Vi du lenh day len Kaggle Dataset (CHINH lai duong dan/ten dataset cho phu hop)\n# Bo comment va chinh sua truoc khi chay:\n\n# import json, os\n# os.makedirs(\"/root/.kaggle\", exist_ok=True)\n# with open(\"/root/.kaggle/kaggle.json\", \"w\") as f:\n#     json.dump({\"username\": \"TEN_TAI_KHOAN_CUA_BAN\", \"key\": \"API_KEY_CUA_BAN\"}, f)\n# os.chmod(\"/root/.kaggle/kaggle.json\", 0o600)\n#\n# # Tao file metadata cho dataset (chi can lam 1 lan dau)\n# dataset_meta = {\n#     \"title\": \"panda-radboud-patches-500\",\n#     \"id\": \"TEN_TAI_KHOAN_CUA_BAN/panda-radboud-patches-500\",\n#     \"licenses\": [{\"name\": \"CC-BY-SA-4.0\"}]\n# }\n# with open(cfg.OUTPUT_ROOT / \"dataset-metadata.json\", \"w\") as f:\n#     json.dump(dataset_meta, f)\n#\n# # LAN DAU: tao moi dataset\n# !kaggle datasets create -p {cfg.OUTPUT_ROOT} --dir-mode zip\n#\n# # CAC LAN SAU: cap nhat (them phien ban moi) vao dataset da tao\n# !kaggle datasets version -p {cfg.OUTPUT_ROOT} -m \"Them patch batch tiep theo\" --dir-mode zip\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:55:34.463899Z","iopub.status.idle":"2026-07-28T15:55:34.464731Z","shell.execute_reply.started":"2026-07-28T15:55:34.464492Z","shell.execute_reply":"2026-07-28T15:55:34.464514Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 8. Sau khi đã đẩy lên Dataset thành công — dọn ổ đĩa để chạy tiếp\n\n**CẢNH BÁO:** chỉ chạy cell dưới đây SAU KHI đã xác nhận patch đã lên Kaggle Dataset thành\ncông (kiểm tra lại trên trang Dataset). Việc này xóa ảnh/mask đã lưu cục bộ để giải phóng\ndung lượng — `manifest.csv` và `processed_cases.csv` KHÔNG bị xóa, nên tiến độ vẫn được giữ.\n","metadata":{}},{"cell_type":"code","source":"# Chi chay sau khi da xac nhan patch da duoc day len Kaggle Dataset an toan\nimport shutil\n\nCONFIRM_DELETE = False  # doi thanh True thu cong sau khi da kiem tra ky\n\nif CONFIRM_DELETE:\n    shutil.rmtree(cfg.PATCHES_IMAGES_DIR)\n    shutil.rmtree(cfg.PATCHES_MASKS_DIR)\n    cfg.PATCHES_IMAGES_DIR.mkdir(parents=True, exist_ok=True)\n    cfg.PATCHES_MASKS_DIR.mkdir(parents=True, exist_ok=True)\n    print(\"Da xoa anh/mask cuc bo. manifest.csv va processed_cases.csv van con nguyen.\")\n    print(\"Dung luong hien tai:\", round(get_current_output_size_gib(), 3), \"GiB\")\nelse:\n    print(\"CONFIRM_DELETE dang la False — chua xoa gi ca. Doi thanh True neu chac chan da day len Dataset an toan.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:55:34.465962Z","iopub.status.idle":"2026-07-28T15:55:34.46636Z","shell.execute_reply.started":"2026-07-28T15:55:34.466185Z","shell.execute_reply":"2026-07-28T15:55:34.466203Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## Tổng kết Stage 2\n\nSau khi chạy xong toàn bộ (có thể qua nhiều vòng đẩy Dataset + xóa cục bộ), bạn sẽ có:\n- Một hoặc nhiều **Kaggle Dataset** chứa toàn bộ patch ảnh (JPEG) + patch mask (PNG).\n- File `manifest.csv` hoàn chỉnh (danh sách patch, tọa độ, nhãn, đường dẫn) — nên đẩy file\n  này lên cùng Dataset cuối cùng, vì đây là \"bảng tra cứu\" để load dữ liệu khi huấn luyện.\n\n**Bước tiếp theo (Stage 3):** chia 5-fold subject-wise theo `image_id` (không theo patch) từ\n`manifest.csv`, rồi bắt đầu huấn luyện mô hình classification/segmentation.\n","metadata":{}},{"cell_type":"markdown","source":"## 9. Gộp toàn bộ output từ các vòng lại thành 1 bộ dữ liệu hoàn chỉnh\n\n**Chỉ chạy mục này SAU KHI đã xử lý xong toàn bộ 3204 case** (qua nhiều vòng\n\"Save & Run All\" + mục 5d ở các phiên trước).\n\n**Chuẩn bị trước khi chạy:**\n1. Mở 1 notebook mới (hoặc dùng chính notebook này ở phiên cuối).\n2. \"Add Data\" → thêm output (hoặc Dataset) của **TẤT CẢ các vòng** đã chạy trước đó\n   (mỗi vòng là 1 notebook version riêng, mỗi cái có `_output_.zip` riêng).\n3. Ở cell Config, điền `cfg.ALL_ROUND_OUTPUT_DIRS` là danh sách đường dẫn tới từng vòng đó.\n\n**Cell dưới đây sẽ:**\n- Tự giải nén từng `_output_.zip` (nếu có).\n- Gộp toàn bộ `manifest.csv` của các vòng lại, **loại bỏ trùng lặp** theo `patch_id`\n  (phòng trường hợp có sự cố khiến 1 case bị xử lý lại ở nhiều vòng).\n- Copy toàn bộ ảnh/mask vào 1 thư mục `panda_patches_merged/` duy nhất.\n- Cập nhật lại đường dẫn trong manifest cho khớp với vị trí gộp cuối cùng.\n- Xuất `manifest_final.csv` — đây là file dùng để load dữ liệu khi huấn luyện mô hình (Stage 3).\n","metadata":{}},{"cell_type":"markdown","source":"### 9a. Hàm khôi phục `manifest.csv` khi bị thiếu\n\nTrường hợp thực tế: một vòng chạy bị Kaggle **kill đột ngột** (hết 12 giờ, mất kết nối, v.v.)\ntrước khi kịp lưu `manifest.csv`, nhưng **ảnh/mask đã cắt vẫn còn nguyên** trong output (Kaggle\nvẫn giữ lại `/kaggle/working` tại thời điểm bị kill làm Output của version đó).\n\nVì tên file có dạng `{image_id}_{x}_{y}.jpg`/`.png` và mask vẫn đọc được giá trị pixel gốc,\ncó thể **quét lại toàn bộ file** để dựng lại manifest (tính lại nhãn từ chính mask) mà không\ncần trích xuất lại từ WSI gốc — không mất công, không mất dữ liệu đã có.\n","metadata":{}},{"cell_type":"code","source":"def reconstruct_manifest_from_patches(images_dir, masks_dir):\n    \"\"\"\n    Quet toan bo file mask trong `masks_dir`, doi chieu voi file anh tuong ung trong\n    `images_dir`, tinh lai nhan tu mask, dung lai thanh 1 DataFrame giong cau truc manifest.csv.\n    Dung khi 1 vong chay bi ngat dot ngot truoc khi kip luu manifest.csv.\n    \"\"\"\n    records = []\n    masks_dir = Path(masks_dir)\n    images_dir = Path(images_dir)\n\n    for mask_path in masks_dir.glob(\"*.png\"):\n        patch_id = mask_path.stem  # vi du: \"0018ae58b01bdadc8e347995b69f99aa_500_0\"\n        parts = patch_id.rsplit(\"_\", 2)\n        if len(parts) != 3:\n            continue  # ten file khong dung dinh dang mong doi, bo qua\n        image_id, x_str, y_str = parts\n        try:\n            x, y = int(x_str), int(y_str)\n        except ValueError:\n            continue\n\n        img_path = images_dir / f\"{patch_id}.jpg\"\n        if not img_path.exists():\n            continue  # co mask nhung thieu anh tuong ung (hiem gap), bo qua de dam bao cap doi\n\n        mask_arr = np.array(Image.open(mask_path))\n        if mask_arr.ndim == 3:\n            mask_arr = mask_arr[:, :, 0]\n        label = label_patch(mask_arr)\n\n        records.append({\n            \"patch_id\": patch_id,\n            \"image_id\": image_id,\n            \"x\": x,\n            \"y\": y,\n            \"label\": label,\n            \"image_path\": str(img_path),\n            \"mask_path\": str(mask_path),\n        })\n\n    return pd.DataFrame(records)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T19:10:00.891147Z","iopub.execute_input":"2026-07-28T19:10:00.891674Z","iopub.status.idle":"2026-07-28T19:10:00.899962Z","shell.execute_reply.started":"2026-07-28T19:10:00.89164Z","shell.execute_reply":"2026-07-28T19:10:00.898893Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import zipfile\n\ncfg.FINAL_MERGED_DIR.mkdir(parents=True, exist_ok=True)\nfinal_images_dir = cfg.FINAL_MERGED_DIR / \"images\"\nfinal_masks_dir = cfg.FINAL_MERGED_DIR / \"masks\"\nfinal_images_dir.mkdir(parents=True, exist_ok=True)\nfinal_masks_dir.mkdir(parents=True, exist_ok=True)\n\nall_manifest_dfs = []\n\nif not cfg.ALL_ROUND_OUTPUT_DIRS:\n    print(\"cfg.ALL_ROUND_OUTPUT_DIRS dang rong. Hay dien duong dan cac vong da chay o cell Config truoc.\")\n\nfor round_dir in cfg.ALL_ROUND_OUTPUT_DIRS:\n    round_dir = Path(round_dir)\n\n    zip_candidates = list(round_dir.glob(\"*.zip\"))\n    if zip_candidates:\n        extract_dir = Path(f\"/kaggle/working/_extract_{round_dir.name}\")\n        extract_dir.mkdir(parents=True, exist_ok=True)\n        print(f\"[{round_dir.name}] Dang giai nen {zip_candidates[0].name} ...\")\n        with zipfile.ZipFile(zip_candidates[0], \"r\") as zf:\n            zf.extractall(extract_dir)\n        search_root = extract_dir\n    else:\n        search_root = round_dir\n\n    manifest_matches = list(search_root.rglob(\"manifest.csv\"))\n    images_dirs = list(search_root.rglob(\"images\"))\n    masks_dirs = list(search_root.rglob(\"masks\"))\n    src_images_dir = images_dirs[0] if images_dirs else None\n    src_masks_dir = masks_dirs[0] if masks_dirs else None\n\n    if manifest_matches:\n        round_manifest = pd.read_csv(manifest_matches[0])\n    elif src_images_dir is not None and src_masks_dir is not None:\n        print(f\"[{round_dir.name}] KHONG co manifest.csv - dang KHOI PHUC tu ten file anh/mask thuc te...\")\n        round_manifest = reconstruct_manifest_from_patches(src_images_dir, src_masks_dir)\n        print(f\"[{round_dir.name}] Da khoi phuc {len(round_manifest)} dong manifest tu file thuc te.\")\n    else:\n        print(f\"[Bo qua {round_dir.name}]: khong co manifest.csv va cung khong tim thay thu muc images/masks.\")\n        continue\n\n    all_manifest_dfs.append(round_manifest)\n\n    n_copied_img, n_copied_mask = 0, 0\n    for _, row in round_manifest.iterrows():\n        img_name = Path(str(row[\"image_path\"])).name\n        mask_name = Path(str(row[\"mask_path\"])).name\n\n        if src_images_dir is not None:\n            src_img = src_images_dir / img_name\n            if src_img.exists():\n                shutil.copy(src_img, final_images_dir / img_name)\n                n_copied_img += 1\n\n        if src_masks_dir is not None:\n            src_mask = src_masks_dir / mask_name\n            if src_mask.exists():\n                shutil.copy(src_mask, final_masks_dir / mask_name)\n                n_copied_mask += 1\n\n    print(f\"[{round_dir.name}] Manifest: {len(round_manifest)} dong | \"\n          f\"Da copy: {n_copied_img} anh, {n_copied_mask} mask.\")\n\nif all_manifest_dfs:\n    merged_manifest = pd.concat(all_manifest_dfs, ignore_index=True)\n    before = len(merged_manifest)\n    merged_manifest = merged_manifest.drop_duplicates(subset=\"patch_id\", keep=\"first\")\n    after = len(merged_manifest)\n    if before != after:\n        print(f\"\\nDa loai {before - after} dong trung lap (patch_id giong nhau giua cac vong).\")\n\n    # Cap nhat lai duong dan cho khop voi thu muc gop cuoi cung\n    merged_manifest[\"image_path\"] = merged_manifest[\"patch_id\"].apply(\n        lambda pid: str(final_images_dir / f\"{pid}.jpg\"))\n    merged_manifest[\"mask_path\"] = merged_manifest[\"patch_id\"].apply(\n        lambda pid: str(final_masks_dir / f\"{pid}.png\"))\n\n    final_manifest_path = cfg.FINAL_MERGED_DIR / \"manifest_final.csv\"\n    merged_manifest.to_csv(final_manifest_path, index=False)\n\n    # Suy ra processed_cases tu chinh manifest da gop (huu ich khi processed_cases.csv goc bi mat\n    # do phien bi kill dot ngot). Luu them ban ten TIEU CHUAN (khong co \"_final\") de co the dung\n    # ngay thu muc nay lam cfg.PREVIOUS_OUTPUT_DIR cho vong tiep theo (muc 5d se tu nhan dien).\n    reconstructed_processed_ids = sorted(merged_manifest[\"image_id\"].unique().tolist())\n    pd.DataFrame({\"image_id\": reconstructed_processed_ids}).to_csv(\n        cfg.FINAL_MERGED_DIR / \"processed_cases.csv\", index=False)\n    merged_manifest.to_csv(cfg.FINAL_MERGED_DIR / \"manifest.csv\", index=False)\n\n    print(f\"\\nDa suy ra processed_cases.csv tu manifest da gop: {len(reconstructed_processed_ids)} case.\")\n    print(\"Luu y: neu manifest cua vong nao do phai KHOI PHUC tu ten file (muc 9a), case dang xu ly\")\n    print(\"dot lien truoc khi bi kill co the chua cat het toan bo patch cua no - anh huong toi da 1 case/lan bi kill.\")\n\n    total_size_gib = sum(f.stat().st_size for f in cfg.FINAL_MERGED_DIR.rglob(\"*\") if f.is_file()) / (1024 ** 3)\n\n    print(f\"\\n=== HOAN TAT GOP DU LIEU ===\")\n    print(f\"Tong so patch sau khi gop va loai trung: {len(merged_manifest):,}\")\n    print(f\"Tong so case duy nhat: {merged_manifest['image_id'].nunique():,}\")\n    print(f\"Tong dung luong thu muc gop: {total_size_gib:.2f} GiB\")\n    print(f\"Manifest cuoi cung: {final_manifest_path}\")\n\n    if total_size_gib >= cfg.MAX_WORKING_DIR_GIB:\n        print(f\"\\n[CANH BAO] Dung luong ({total_size_gib:.2f} GiB) da vuot nguong an toan \"\n              f\"{cfg.MAX_WORKING_DIR_GIB} GiB. KHONG the 'Save & Run All' truc tiep thu muc nay - \"\n              \"hay dung lai cach day len Kaggle Dataset qua Kaggle API (xem mau lenh o muc 7 ben tren, \"\n              \"doi duong dan sang cfg.FINAL_MERGED_DIR) de luu tru ket qua cuoi cung.\")\nelse:\n    print(\"Khong co du lieu nao duoc gop (kiem tra lai cfg.ALL_ROUND_OUTPUT_DIRS).\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T19:10:02.607798Z","iopub.execute_input":"2026-07-28T19:10:02.608283Z","iopub.status.idle":"2026-07-28T19:11:34.271482Z","shell.execute_reply.started":"2026-07-28T19:10:02.608252Z","shell.execute_reply":"2026-07-28T19:11:34.270242Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import shutil\nfrom pathlib import Path\n\n# --- Sửa 2 dòng dưới cho đúng với môi trường đang chạy ---\nSOURCE_FOLDER = Path(\"/kaggle/working/panda_patches\")   # Kaggle: thư mục chứa patch đã gộp\n# SOURCE_FOLDER = Path(\"/content/drive/MyDrive/panda_patches\") # Colab: đổi sang dòng này nếu chạy trên Colab\nZIP_OUTPUT_PATH = \"/kaggle/working/panda_patches_final\"        # sẽ tạo ra file này + \".zip\"\n# ZIP_OUTPUT_PATH = \"/content/panda_patches_final\"              # Colab: nên zip ra /content trước, không zip thẳng vào Drive\n\n# --- Kiểm tra dung lượng trước khi nén ---\ntotal_size_gib = sum(f.stat().st_size for f in SOURCE_FOLDER.rglob(\"*\") if f.is_file()) / (1024 ** 3)\nprint(f\"Kich thuoc thu muc can nen: {total_size_gib:.2f} GiB\")\n\n# --- Nén ---\nprint(\"Dang nen, co the mat vai phut tuy so luong file...\")\nshutil.make_archive(ZIP_OUTPUT_PATH, \"zip\", root_dir=str(SOURCE_FOLDER.parent), base_dir=SOURCE_FOLDER.name)\n\nzip_path = Path(ZIP_OUTPUT_PATH + \".zip\")\nzip_size_gib = zip_path.stat().st_size / (1024 ** 3)\nprint(f\"\\nDa tao file zip: {zip_path} ({zip_size_gib:.2f} GiB)\")\nprint(\"Vao tab file (Kaggle) hoac panel Files ben trai (Colab), tim file nay, bam Download.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T19:13:50.789775Z","iopub.execute_input":"2026-07-28T19:13:50.790984Z","iopub.status.idle":"2026-07-28T19:16:03.233388Z","shell.execute_reply.started":"2026-07-28T19:13:50.790946Z","shell.execute_reply":"2026-07-28T19:16:03.232489Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## 10. Kiểm tra tính toàn vẹn dữ liệu cuối cùng\n\nVì bộ dữ liệu được ghép từ **nhiều vòng chạy khác nhau** (một số vòng phải khôi phục thủ công\nsau khi bị kill giữa chừng), cần quét lại toàn bộ `manifest_final.csv` để xác nhận mọi\nảnh/mask đều **mở và giải mã được bình thường**, không có file hỏng/thiếu — trước khi coi đây\nlà bộ dữ liệu \"sạch\" cuối cùng.\n","metadata":{}},{"cell_type":"code","source":"manifest_df = pd.read_csv(cfg.FINAL_MERGED_DIR / \"manifest_final.csv\")\nprint(f\"Dang kiem tra tinh toan ven cua {len(manifest_df):,} patch...\")\n\nbad_rows = []\nfor i, row in manifest_df.iterrows():\n    if i % 20000 == 0 and i > 0:\n        print(f\"  ... da kiem tra {i:,}/{len(manifest_df):,}\")\n\n    try:\n        img = Image.open(row[\"image_path\"])\n        img.load()  # ep giai ma toan bo, khong chi doc header\n    except Exception as e:\n        bad_rows.append({\"patch_id\": row[\"patch_id\"], \"loai_loi\": \"anh\", \"chi_tiet\": str(e)})\n        continue\n\n    try:\n        mask = Image.open(row[\"mask_path\"])\n        mask.load()\n    except Exception as e:\n        bad_rows.append({\"patch_id\": row[\"patch_id\"], \"loai_loi\": \"mask\", \"chi_tiet\": str(e)})\n\nprint(f\"\\nSo patch loi: {len(bad_rows)}\")\n\nif bad_rows:\n    bad_df = pd.DataFrame(bad_rows)\n    bad_df.to_csv(cfg.FINAL_MERGED_DIR / \"integrity_check_errors.csv\", index=False)\n    print(\"Da luu danh sach loi tai:\", cfg.FINAL_MERGED_DIR / \"integrity_check_errors.csv\")\n\n    bad_ids = set(bad_df[\"patch_id\"])\n    manifest_df = manifest_df[~manifest_df[\"patch_id\"].isin(bad_ids)].reset_index(drop=True)\n    manifest_df.to_csv(cfg.FINAL_MERGED_DIR / \"manifest_final.csv\", index=False)\n    print(f\"Da loai {len(bad_ids)} patch loi khoi manifest_final.csv. Con lai: {len(manifest_df):,} patch.\")\nelse:\n    print(\"Tat ca patch deu doc va giai ma binh thuong.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:55:34.471521Z","iopub.status.idle":"2026-07-28T15:55:34.471821Z","shell.execute_reply.started":"2026-07-28T15:55:34.47169Z","shell.execute_reply":"2026-07-28T15:55:34.471707Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 11. Gán fold (5-fold subject-wise)\n\nChia `image_id` (không phải patch) thành 5 fold, theo đúng mô tả bài báo: *\"dataset was\ndivided into five folds subject-wise\"* — đảm bảo mọi patch của cùng 1 case luôn nằm trong\ncùng 1 fold, tránh rò rỉ dữ liệu giữa train/test.\n","metadata":{}},{"cell_type":"code","source":"from sklearn.model_selection import GroupKFold\n\n# Xao tron thu tu truoc de tranh fold bi lech do thu tu xu ly case ban dau (khong ngau nhien)\nmanifest_df = manifest_df.sample(frac=1, random_state=cfg.RANDOM_SEED).reset_index(drop=True)\n\ngkf = GroupKFold(n_splits=cfg.N_FOLDS)\ndummy_X = np.zeros(len(manifest_df))\nfold_assignment = np.full(len(manifest_df), -1)\n\nfor fold_idx, (_, test_idx) in enumerate(gkf.split(dummy_X, groups=manifest_df[\"image_id\"])):\n    fold_assignment[test_idx] = fold_idx\n\nmanifest_df[\"fold\"] = fold_assignment\n\n# Kiem tra: dam bao khong case nao bi chia vao nhieu fold khac nhau (dung subject-wise)\ncheck = manifest_df.groupby(\"image_id\")[\"fold\"].nunique()\nassert (check == 1).all(), \"LOI: co case bi chia vao nhieu fold khac nhau - kiem tra lai!\"\n\nprint(\"So patch theo tung fold:\")\nprint(manifest_df[\"fold\"].value_counts().sort_index())\nprint(\"\\nSo case theo tung fold:\")\nprint(manifest_df.groupby(\"fold\")[\"image_id\"].nunique())\n\nmanifest_df.to_csv(cfg.FINAL_MERGED_DIR / \"manifest_final.csv\", index=False)\nprint(\"\\nDa luu manifest_final.csv voi cot 'fold' moi them.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:55:34.473011Z","iopub.status.idle":"2026-07-28T15:55:34.473382Z","shell.execute_reply.started":"2026-07-28T15:55:34.473231Z","shell.execute_reply":"2026-07-28T15:55:34.473258Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 12. Hàm xác định vai trò train / validation / test cho từng vòng CV\n\nBài báo: *\"each fold was used once as a test set while the other folds were used for\ntraining and validation\"* — nghĩa là với 5 fold, có **5 vòng CV**, mỗi vòng 1 fold khác nhau\nlàm test. Hàm dưới đây xác định vai trò (train/val/test) của từng patch **cho 1 vòng CV cụ\nthể**, quy ước: fold liền sau fold-test (theo modulo) dùng làm validation, phần còn lại dùng\nđể training.\n","metadata":{}},{"cell_type":"code","source":"def get_role(fold, test_fold, n_folds=cfg.N_FOLDS):\n    \"\"\"\n    fold: gia tri cot 'fold' cua 1 dong (0..n_folds-1)\n    test_fold: fold nao dang dung lam TEST trong vong CV hien tai (0..n_folds-1)\n    Tra ve 'test', 'val', hoac 'train'.\n    \"\"\"\n    if fold == test_fold:\n        return \"test\"\n    val_fold = (test_fold + 1) % n_folds\n    if fold == val_fold:\n        return \"val\"\n    return \"train\"\n\n\n# Vi du minh hoa cho vong CV dau tien (test_fold = 0)\nexample_test_fold = 0\nmanifest_df[f\"role_round{example_test_fold}\"] = manifest_df[\"fold\"].apply(\n    lambda f: get_role(f, example_test_fold))\n\nprint(f\"Phan bo vai tro cho vong CV voi test_fold={example_test_fold}:\")\nprint(manifest_df[f\"role_round{example_test_fold}\"].value_counts())\n\nprint(\"\\nO Stage 3 (huan luyen), lap qua ca 5 gia tri test_fold (0..4), moi lan goi lai\")\nprint(\"get_role() de xac dinh dung tap train/val/test cho vong CV tuong ung.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:55:34.474761Z","iopub.status.idle":"2026-07-28T15:55:34.47525Z","shell.execute_reply.started":"2026-07-28T15:55:34.475005Z","shell.execute_reply":"2026-07-28T15:55:34.475034Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 13. Hàm cân bằng lớp bằng augmentation (chỉ dùng cho classification)\n\nBài báo (mục *Data Augmentation*): dùng lật ngang, lật dọc, xoay 90° để **oversample** các\nlớp ít hơn cho tới khi bằng số lượng của lớp nhiều nhất — **chỉ áp dụng cho tập training**,\nkhông áp dụng cho validation/test.\n\n**Lưu ý quan trọng:** vì tập training khác nhau giữa 5 vòng CV (do fold nào làm test/val thay\nđổi mỗi vòng), hàm dưới đây được thiết kế để gọi **riêng cho từng vòng CV cụ thể ở Stage 3**\n(dùng tập train của vòng đó, đã lọc theo `get_role()` ở mục 12 và loại các dòng `label` rỗng),\nKHÔNG chạy sẵn 1 lần cố định ở đây — vì làm vậy sẽ tạo ra bộ augmented sai cho 4/5 vòng CW còn\nlại. Cell dưới đây chỉ **định nghĩa hàm**, không tự động chạy trên toàn bộ dữ liệu.\n","metadata":{}},{"cell_type":"code","source":"import random\n\ndef augment_to_balance(train_df, output_images_dir, output_masks_dir, seed=cfg.RANDOM_SEED):\n    \"\"\"\n    train_df: subset cua manifest (chi cac dong co label khac rong) dung lam TRAINING cho\n              1 vong CV cu the (da loc theo get_role() == 'train').\n    Sinh them anh augmented (lat ngang/doc, xoay 90/180/270 do) cho cac lop it hon, cho den\n    khi so luong bang lop nhieu nhat - dung ky thuat bai bao mo ta.\n    Tra ve DataFrame gom ca du lieu goc + du lieu augmented.\n    \"\"\"\n    random.seed(seed)\n    output_images_dir = Path(output_images_dir)\n    output_masks_dir = Path(output_masks_dir)\n    output_images_dir.mkdir(parents=True, exist_ok=True)\n    output_masks_dir.mkdir(parents=True, exist_ok=True)\n\n    class_counts = train_df[\"label\"].value_counts()\n    target_count = class_counts.max()\n    print(\"So luong hien tai theo lop:\")\n    print(class_counts)\n    print(f\"Muc tieu can dat: {target_count} moi lop\")\n\n    transforms = [\n        (\"hflip\", lambda im: im.transpose(Image.FLIP_LEFT_RIGHT)),\n        (\"vflip\", lambda im: im.transpose(Image.FLIP_TOP_BOTTOM)),\n        (\"rot90\", lambda im: im.rotate(90, expand=True)),\n        (\"rot180\", lambda im: im.rotate(180, expand=True)),\n        (\"rot270\", lambda im: im.rotate(270, expand=True)),\n    ]\n\n    augmented_rows = []\n    for label, count in class_counts.items():\n        n_needed = target_count - count\n        if n_needed <= 0:\n            continue\n        class_rows = train_df[train_df[\"label\"] == label]\n        print(f\"Lop '{label}': can sinh them {n_needed} anh...\")\n\n        for i in range(n_needed):\n            src_row = class_rows.sample(1, random_state=seed + i).iloc[0]\n            transform_name, transform_fn = random.choice(transforms)\n\n            img = Image.open(src_row[\"image_path\"])\n            mask = Image.open(src_row[\"mask_path\"])\n\n            aug_img = transform_fn(img).convert(\"RGB\")\n            aug_mask = transform_fn(mask)\n\n            aug_patch_id = f\"{src_row['patch_id']}_aug{i}_{transform_name}\"\n            aug_img_path = output_images_dir / f\"{aug_patch_id}.jpg\"\n            aug_mask_path = output_masks_dir / f\"{aug_patch_id}.png\"\n\n            aug_img.save(aug_img_path, format=\"JPEG\", quality=cfg.JPEG_QUALITY)\n            aug_mask.save(aug_mask_path, format=\"PNG\")  # PNG - KHONG dung JPEG cho mask\n\n            augmented_rows.append({\n                \"patch_id\": aug_patch_id,\n                \"image_id\": src_row[\"image_id\"],\n                \"x\": src_row[\"x\"],\n                \"y\": src_row[\"y\"],\n                \"label\": label,\n                \"image_path\": str(aug_img_path),\n                \"mask_path\": str(aug_mask_path),\n                \"fold\": src_row[\"fold\"],\n                \"is_augmented\": True,\n            })\n\n    train_df = train_df.copy()\n    train_df[\"is_augmented\"] = False\n    augmented_df = pd.DataFrame(augmented_rows)\n    combined_df = pd.concat([train_df, augmented_df], ignore_index=True)\n\n    print(f\"\\nTong so anh sau augmentation: {len(combined_df):,} \"\n          f\"(goc: {len(train_df):,}, sinh them: {len(augmented_df):,})\")\n    print(combined_df[\"label\"].value_counts())\n    return combined_df\n\n\n# Vi du cach goi (KHONG tu chay o day - bo comment va chay o Stage 3 khi can, cho vong CV cu the):\n#\n# train_subset = manifest_df[\n#     (manifest_df[\"role_round0\"] == \"train\") & (manifest_df[\"label\"].notna())\n# ]\n# balanced_train_df = augment_to_balance(\n#     train_subset,\n#     cfg.AUGMENTED_IMAGES_DIR / \"round0\",\n#     cfg.AUGMENTED_MASKS_DIR / \"round0\",\n# )\n# balanced_train_df.to_csv(cfg.FINAL_MERGED_DIR / \"manifest_round0_train_balanced.csv\", index=False)\nprint(\"Ham augment_to_balance() da san sang, chua duoc goi. Xem vi du trong comment o tren.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-28T15:55:34.478025Z","iopub.status.idle":"2026-07-28T15:55:34.47846Z","shell.execute_reply.started":"2026-07-28T15:55:34.478239Z","shell.execute_reply":"2026-07-28T15:55:34.478267Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"---\n## Tổng kết cuối cùng — Stage 2 hoàn chỉnh\n\nSau các mục 1–13, `manifest_final.csv` trong `panda_patches_merged/` đã có đầy đủ:\n- `patch_id, image_id, x, y, label, image_path, mask_path` — dữ liệu gốc.\n- `fold` — gán 5-fold subject-wise.\n- Đã kiểm tra tính toàn vẹn (không còn patch hỏng).\n\nSẵn sàng cho Stage 3: dùng `get_role()` để lấy đúng tập train/val/test cho từng vòng CV, dùng\n`augment_to_balance()` khi huấn luyện classification (áp dụng riêng cho từng vòng), và dùng\ntrực tiếp `mask_path` (không cần augment cân bằng lớp) cho segmentation.\n","metadata":{}}]}