{"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":"none","dataSources":[{"sourceId":45867,"databundleVersionId":6924515,"isSourceIdPinned":false,"sourceType":"competition"},{"sourceId":6984590,"sourceType":"datasetVersion","datasetId":4014175},{"sourceId":14677508,"sourceType":"datasetVersion","datasetId":9376928},{"sourceId":14680954,"sourceType":"datasetVersion","datasetId":9378985}],"dockerImageVersionId":31259,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"!apt-get update && apt-get install -y libvips\n!pip install pyvips","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-12T22:28:18.973551Z","iopub.execute_input":"2026-02-12T22:28:18.974078Z","iopub.status.idle":"2026-02-12T22:28:30.441592Z","shell.execute_reply.started":"2026-02-12T22:28:18.974022Z","shell.execute_reply":"2026-02-12T22:28:30.440638Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\n# --- CRITICAL: MUST BE SET BEFORE IMPORTING PYVIPS ---\n# This prevents the \"No space left on device\" error by using the large disk\n# instead of the tiny /tmp partition.\n# It tells Python and Pyvips to use this directory for temporary buffers\ntemp_dir = \"/kaggle/working/tmp_vips\"\nos.makedirs(temp_dir, exist_ok=True)\nos.environ['TMPDIR'] = temp_dir\nos.environ['TEMP'] = temp_dir\nos.environ['TMP'] = temp_dir\nos.environ['VIPS_CACHE_MAX'] = '200'\nos.environ['VIPS_DISC_THRESHOLD'] = '3gb'\n\nGENERATE_PATCHES = True\nif not GENERATE_PATCHES:\n    import numpy as np\n    import pandas as pd\n    from sklearn.model_selection import train_test_split\n    import os\n    import cv2\n    from tqdm import tqdm\n    import h5py\n    import pyvips # Imported AFTER env vars are set\n    import multiprocessing\n    from concurrent.futures import ProcessPoolExecutor, as_completed # ProcessPool is safer for GIL-heavy encoding\n    import gc","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# --- CONFIGURATION ---\nTHUMB_DIR = \"/kaggle/input/UBC-OCEAN/train_thumbnails\"\nWSI_DIR = \"/kaggle/input/UBC-OCEAN/train_images\"\nPATCH_SIZE = 256\nTHUMB_CONF = 0.6\nRAW_ATLAS_PATH = \"/kaggle/input/raw-atlas-ubc-ocean/raw_atlas.csv\" \nLABELS_PATH = \"/kaggle/input/UBC-OCEAN/train.csv\"\nTRAIN_VAL_ATLAS_PATH = \"/kaggle/input/train-val-atlas-ubc-ocean/train_val_atlas.csv\"\n\n# --- RUN FLAGS ---\n# Set these before running the cell!\nMODE = 'train'          # Options: 'train' or 'valid'\nPART_ID = 0             # Which part is this? (e.g., 0, 1, 2...)\nTOTAL_PARTS = 6         # Total number of parts you plan to split this into\nCOMPRESSION = 'lzf'     # 'lzf' is fast and supported by h5py natively\n\n# --- CHUNKING STRATEGY ---\n# Train: 1 (Random Access). Validation: 32 (Sequential Batch Read)\nCHUNK_SIZE = (1, 256, 256, 3) if MODE == 'train' else (32, 256, 256, 3)\n\n# Targets\nTRAIN_TARGET_PER_CLASS = 60000 \nVAL_TARGET_PER_CLASS = int(0.1*TRAIN_TARGET_PER_CLASS)   \nVAL_SPLIT_RATIO = 0.1          \n\n# Output\nOUTPUT_H5 = f\"ubc_ocean_part_{PART_ID}.h5\"\nFINAL_CSV = f\"atlas_part_{PART_ID}.csv\"\n\n# Optimizations\nNUM_WORKERS = 3 # Max is 4 but might OOM\nJXL_EFFORT = 3 # Seems to be optimal for this data\n\n# --- DEBUG SWITCH ---\nDEBUG_RUN = False       # <--- REMEMBER TO SET TO FALSE FOR FULL RUN\nDEBUG_SLIDES = 2\nif DEBUG_RUN:\n    import matplotlib.pyplot as plt\n#print(f\"Working on part {PART_ID+1} of {TOTAL_PARTS}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"if DEBUG_RUN:\n    with_mask=os.listdir('/kaggle/input/ubc-ovarian-cancer-competition-supplemental-masks')\n    with_mask=[int(m.split('.')[0]) for m in with_mask]\n    train_df=pd.read_csv('/kaggle/input/UBC-OCEAN/train.csv')\n    train_df=train_df.loc[(~train_df['image_id'].isin(with_mask))&(~train_df['is_tma'])]\n    image_ids=train_df['image_id'].values\n    train_df","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def get_real_dimensions_strict(image_ids, wsi_dir):\n    print(\"1. Scanning true WSI dimensions...\")\n    dims = {}\n    for image_id in tqdm(image_ids, mininterval=1):\n        path = os.path.join(wsi_dir, f\"{image_id}.png\")\n        img = pyvips.Image.new_from_file(path)\n        dims[image_id] = (img.width, img.height)\n    return dims\n\ndef generate_strict_atlas(image_ids, thumb_dir, wsi_dir):\n    # 1. Get Truth\n    real_dims = get_real_dimensions_strict(image_ids, wsi_dir)\n    \n    all_coords = []\n    print(f\"\\n2. Generating atlas for {len(image_ids)} WSI slides...\")\n    \n    for image_id in tqdm(image_ids, mininterval=5):\n        w_wsi, h_wsi = real_dims[image_id]\n        \n        # 2. Load Thumbnail\n        thumb_path = os.path.join(thumb_dir, f\"{image_id}_thumbnail.png\")            \n        thumb = cv2.imread(thumb_path)\n        thumb = cv2.cvtColor(thumb, cv2.COLOR_BGR2RGB)\n        \n        # 3. Mask\n        hsv = cv2.cvtColor(thumb, cv2.COLOR_RGB2HSV)\n        s = hsv[:, :, 1]\n        _, mask = cv2.threshold(s, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)\n        \n        # 4. Scale\n        h_thumb, w_thumb, _ = thumb.shape\n        scale_x = w_wsi / w_thumb\n        scale_y = h_wsi / h_thumb\n        \n        patch_area_on_thumb = (PATCH_SIZE / scale_x) * (PATCH_SIZE / scale_y)\n        pixel_threshold = patch_area_on_thumb * THUMB_CONF\n        \n        # 5. Grid Sampling (Iterate WSI, not Thumb)\n        for y in range(0, h_wsi, PATCH_SIZE):\n            if y + PATCH_SIZE > h_wsi: continue \n                \n            for x in range(0, w_wsi, PATCH_SIZE):\n                if x + PATCH_SIZE > w_wsi: continue\n                \n                # Map WSI -> Thumb\n                tx_start = int(x / scale_x)\n                ty_start = int(y / scale_y)\n                tx_end = int((x + PATCH_SIZE) / scale_x)\n                ty_end = int((y + PATCH_SIZE) / scale_y)\n                \n                # Check Tissue\n                mask_region = mask[ty_start:ty_end, tx_start:tx_end]\n                if np.count_nonzero(mask_region) > pixel_threshold:\n                    all_coords.append({\n                        'image_id': image_id,\n                        'x': x,\n                        'y': y\n                    })\n\n    return pd.DataFrame(all_coords)\n\n# --- EXECUTE ---\n#df_atlas = generate_strict_atlas(image_ids, THUMB_DIR, WSI_DIR)\n#print(f\"Total Patches Found: {len(df_atlas)}\")\n#df_atlas.to_csv(RAW_ATLAS_PATH, index=False)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def create_leakage_proof_split():\n    print(\"1. Loading Raw Atlas...\")\n    df_atlas = pd.read_csv(RAW_ATLAS_PATH)\n    \n    # Merge labels if needed\n    if 'label' not in df_atlas.columns:\n        df_labels = pd.read_csv(LABELS_PATH)\n        df_atlas = df_atlas.merge(df_labels[['image_id', 'label']], on='image_id', how='left')\n\n    # 2. Split SLIDES (IDs), not Patches\n    # We group by label first to ensure the split is stratified (balanced classes in val)\n    unique_ids_per_class = df_atlas.groupby('label')['image_id'].unique()\n    \n    train_ids = []\n    val_ids = []\n    \n    print(\"-\" * 40)\n    print(f\"Splitting Slides (Target: {int(VAL_SPLIT_RATIO*100)}% Val)...\")\n    \n    for label, ids in unique_ids_per_class.items():\n        # Handle edge case: very few slides\n        if len(ids) < 4:\n            print(f\"  Warning: Class {label} has only {len(ids)} slides. Putting 1 in Val.\")\n            t_ids = ids[:-1]\n            v_ids = [ids[-1]]\n        else:\n            t_ids, v_ids = train_test_split(ids, test_size=VAL_SPLIT_RATIO, random_state=42)\n            \n        train_ids.extend(t_ids)\n        val_ids.extend(v_ids)\n        print(f\"  {label}: {len(t_ids)} Train Slides | {len(v_ids)} Val Slides\")\n        \n    # 3. Filter the Atlas based on these IDs\n    # This effectively creates two separate pools of patches\n    df_train_pool = df_atlas[df_atlas['image_id'].isin(train_ids)]\n    df_val_pool = df_atlas[df_atlas['image_id'].isin(val_ids)]\n    \n    # 4. Sample Patches from the Pools\n    print(\"\\nSampling Patches...\")\n    \n    # Function to sample N patches per class\n    def sample_per_class(df, target_n):\n        sampled_dfs = []\n        for label, group in df.groupby('label'):\n            n = min(len(group), target_n)\n            sampled_dfs.append(group.sample(n=n, random_state=42))\n        return pd.concat(sampled_dfs)\n\n    df_train_final = sample_per_class(df_train_pool, TRAIN_TARGET_PER_CLASS)\n    df_train_final['split'] = 'train' # Metadata tag\n    \n    df_val_final = sample_per_class(df_val_pool, VAL_TARGET_PER_CLASS)\n    df_val_final['split'] = 'valid'   # Metadata tag\n    \n    # 5. Combine and Sort (CRITICAL for HDF5 speed)\n    df_final = pd.concat([df_train_final, df_val_final])\n    df_final = df_final.sort_values(by=['image_id', 'y', 'x'])\n    \n    print(\"-\" * 40)\n    print(f\"Final Train Size: {len(df_train_final)}\")\n    print(f\"Final Val Size:   {len(df_val_final)}\")\n    print(f\"Total Dataset:    {len(df_final)}\")\n    \n    df_final.to_csv(TRAIN_VAL_ATLAS_PATH, index=False)\n    print(f\"Saved to {TRAIN_VAL_ATLAS_PATH}\")\n\n#create_leakage_proof_split()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport struct\nimport zlib\nimport gc  # Added for memory management\nimport numpy as np\nimport pandas as pd\nimport pyvips\nimport cv2\nfrom tqdm import tqdm\n\n# --- CONFIGURATION ---\nMODE = 'valid' # 'train' or 'valid'     \nPART_ID = 5 # 0...5        \nTOTAL_PARTS = 6         \n\nif MODE == 'valid':\n    PART_ID = 0\n    TOTAL_PARTS = 1\n\nOUTPUT_DIR = \"/kaggle/working\"\nWSI_DIR = \"/kaggle/input/UBC-OCEAN/train_images\"\nATLAS_PATH = \"/kaggle/input/train-val-atlas-ubc-ocean/train_val_atlas.csv\"\nLABEL_MAP = {\"CC\": 0, \"EC\": 1, \"HGSC\": 2, \"LGSC\": 3, \"MC\": 4}\n\n# --- HEADER SPECIFICATION ---\nHEADER_FMT = '<8sIQiiii3s25x' \nMAGIC = b'UBC_DATA'\nHEADER_SIZE = 64\n\n# DEBUG\n# Create a debug folder\nif not os.path.exists(\"debug_patches\"): os.makedirs(\"debug_patches\")\n\nclass ChromaticTissueVerifier:\n    \"\"\"\n    SOTA Verifier: High-Recall Configuration for Blind Processing.\n    \"\"\"\n    def __init__(self, \n                 chroma_thresh=5,       \n                 min_chroma_frac=0.01,  # 1% stain is enough (Very permissive)\n                 edge_min=8,            # 8 filters sensor noise in PNGs\n                 min_edge_frac=0.02):  # 1% structure (Keeps wispy mucin)\n        \n        self.chroma_thresh = chroma_thresh\n        self.min_chroma_frac = min_chroma_frac\n        self.edge_min = edge_min\n        self.min_edge_frac = min_edge_frac\n\n    def verify(self, patch_rgb: np.ndarray) -> bool:\n        total_pixels = patch_rgb.shape[0] * patch_rgb.shape[1]\n\n        # --- Stage 1: Chromatic Area Analysis ---\n        p_max = patch_rgb.max(axis=2)\n        p_min = patch_rgb.min(axis=2)\n        chroma = p_max - p_min\n        \n        chromatic_pixels = np.count_nonzero(chroma > self.chroma_thresh)\n        chromatic_frac = chromatic_pixels / total_pixels\n        \n        if chromatic_frac > self.min_chroma_frac:\n            return True\n\n        # --- Stage 2: Structural Analysis (Mucin Rescue) ---\n        gray = cv2.cvtColor(patch_rgb, cv2.COLOR_RGB2GRAY)\n\n        # 16-bit Signed Gradients (Safe math)\n        gx = cv2.Sobel(gray, cv2.CV_16S, 1, 0, ksize=3)\n        gy = cv2.Sobel(gray, cv2.CV_16S, 0, 1, ksize=3)\n        \n        # Exact L1 Norm\n        grad_l1 = np.abs(gx) + np.abs(gy) \n        \n        # Lower bound only (No upper bound)\n        edge_pixels = np.count_nonzero(grad_l1 > self.edge_min)\n        edge_frac = edge_pixels / total_pixels\n        \n        if edge_frac > self.min_edge_frac:\n            return True\n\n        return False\n\nctv = ChromaticTissueVerifier()\n\ndef generate_robust_shard():\n    suffix = \"valid\" if MODE == 'valid' else f\"{MODE}_part_{PART_ID}\"\n    bin_filename = f\"{OUTPUT_DIR}/ubc_ocean_{suffix}.bin\"\n    meta_filename = f\"{OUTPUT_DIR}/ubc_ocean_{suffix}.csv\"\n    \n    print(f\"=== GENERATING SHARD: {suffix.upper()} ===\")\n    \n    df = pd.read_csv(ATLAS_PATH)\n    df = df[df['split'] == MODE].sort_values(['image_id', 'y', 'x'])\n    \n    if TOTAL_PARTS > 1:\n        total = len(df)\n        base = total // TOTAL_PARTS\n        rem = total % TOTAL_PARTS\n        start = sum(base + (1 if i < rem else 0) for i in range(PART_ID))\n        curr = base + (1 if PART_ID < rem else 0)\n        df = df.iloc[start : start+curr]\n\n    # RAISE: If slice is empty\n    if len(df) == 0:\n        raise ValueError(f\"Empty Dataframe for PART_ID {PART_ID} in MODE {MODE}. Check TOTAL_PARTS.\")\n\n    metadata_rows = []\n    checksum = 0\n    \n    with open(bin_filename, \"wb\") as f_bin:\n        f_bin.write(b'\\x00' * HEADER_SIZE) \n        \n        buffer = []\n        total_written = 0\n        \n        # Group by Slide ID\n        for wsi_id, group in tqdm(df.groupby('image_id'), desc=f\"Part {PART_ID}\"):\n            \n            lbls = group['label'].map(LABEL_MAP).astype(np.int8).values\n            wsi_path = os.path.join(WSI_DIR, f\"{wsi_id}.png\")\n            \n            slide = pyvips.Image.new_from_file(wsi_path, access='sequential')\n            region = pyvips.Region.new(slide)\n            \n            xs, ys = group['x'].values, group['y'].values\n            \n            for i in range(len(xs)):\n                # Bounds check\n                if xs[i]+256 > slide.width or ys[i]+256 > slide.height: continue\n                \n                # Fetch Raw Pixel Data\n                blob = region.fetch(xs[i], ys[i], 256, 256)\n                img = np.frombuffer(blob, dtype=np.uint8).reshape(256, 256, slide.bands)\n\n                # --- STRICT VALIDATION ---\n                # Raise immediately if not strictly 3-channel RGB\n                if img.shape[2] != 3:\n                    raise ValueError(f\"CRITICAL: Non-RGB input detected. \"\n                                     f\"Slide: {wsi_id}, Patch: ({xs[i]}, {ys[i]}), \"\n                                     f\"Channels: {img.shape[2]}\")\n                \n                \n                # --- VERIFY ---\n                #is_kept = ctv.verify(img)\n                is_kept = True\n                if not is_kept:\n                    cv2.imwrite(f\"debug_patches/REJECTED_{wsi_id}_{xs[i]}.jpg\", cv2.cvtColor(img, cv2.COLOR_RGB2BGR))\n                    continue\n                \n                # --- WRITE TO BUFFER ---\n                img_chw = img.transpose(2, 0, 1) \n                buffer.append(img_chw)\n                \n                idx_in_file = total_written + len(buffer) - 1\n                metadata_rows.append({\n                    'idx': idx_in_file,\n                    'wsi_id': int(wsi_id), \n                    'label': lbls[i], \n                    'x': xs[i], \n                    'y': ys[i]\n                })\n                \n                if len(buffer) >= 256:\n                    data_bytes = np.stack(buffer).tobytes()\n                    f_bin.write(data_bytes)\n                    checksum = zlib.crc32(data_bytes, checksum)\n                    total_written += len(buffer)\n                    buffer = []\n            \n            # --- MEMORY MANAGEMENT ---\n            # Explicitly delete the objects to clear references\n            del region\n            del slide\n            # Force garbage collection immediately after the heavy slide is done\n            gc.collect() \n\n        # Flush remaining buffer\n        if buffer:\n            data_bytes = np.stack(buffer).tobytes()\n            f_bin.write(data_bytes)\n            checksum = zlib.crc32(data_bytes, checksum)\n            total_written += len(buffer)\n\n        # Write Real Header\n        f_bin.seek(0)\n        header = struct.pack(HEADER_FMT, MAGIC, checksum, total_written, 3, 256, 256, 1, b'CHW')\n        f_bin.write(header)\n\n    pd.DataFrame(metadata_rows).to_csv(meta_filename, index=False)\n    print(\"Done.\")\n\nif __name__ == \"__main__\":\n    generate_robust_shard()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# --- CONFIGURATION ---\nINPUT_DIR = \"./shards\"         \nOUTPUT_DIR = \"./dataset\"       \nOUTPUT_PREFIX = \"ubc_train\"    \n\n# 256MB Buffer: Optimizes disk write speeds\nWRITE_BUFFER_SIZE = 256 * 1024 * 1024 \n\n# Header Config \nHEADER_FMT = '<8sIQiiii3s25x'\nHEADER_SIZE = 64\nMAGIC = b'UBC_DATA'\nPATCH_SIZE_BYTES = 3 * 256 * 256  # 196,608 bytes\n\ndef fail_fast_merge():\n    if not os.path.exists(OUTPUT_DIR): os.makedirs(OUTPUT_DIR)\n    \n    # 1. Discovery\n    csv_files = sorted(glob.glob(os.path.join(INPUT_DIR, \"*_part_*.csv\")))\n    bin_files = [f.replace('.csv', '.bin') for f in csv_files]\n    \n    if not csv_files:\n        raise FileNotFoundError(\"No shards found in INPUT_DIR\")\n\n    print(f\"Found {len(csv_files)} shards. Mapping metadata...\")\n\n    # 2. Map Metadata & Open Handles\n    df_list = []\n    file_handles = []\n    \n    # We open all files at once to allow instant random jumping between them\n    for i, (c_path, b_path) in enumerate(zip(csv_files, bin_files)):\n        if not os.path.exists(b_path):\n            raise FileNotFoundError(f\"Missing binary file: {b_path}\")\n            \n        df = pd.read_csv(c_path)\n        df['shard_id'] = i             # Which file is it in?\n        df['original_idx'] = df['idx'] # Which patch # is it in that file?\n        df_list.append(df)\n        \n        file_handles.append(open(b_path, \"rb\"))\n\n    # 3. Global Shuffle\n    # Concat all metadata into one giant map\n    global_df = pd.concat(df_list, ignore_index=True)\n    total_patches = len(global_df)\n    \n    print(f\"Total Patches: {total_patches}\")\n    print(\"Executing Global Random Shuffle...\")\n    \n    # frac=1 shuffles 100% of rows. random_state=42 ensures we get the same shuffle if we re-run.\n    global_df = global_df.sample(frac=1, random_state=42).reset_index(drop=True)\n    \n    # 4. Transcription (The Heavy Lifting)\n    out_bin = os.path.join(OUTPUT_DIR, f\"{OUTPUT_PREFIX}_shuffled.bin\")\n    out_csv = os.path.join(OUTPUT_DIR, f\"{OUTPUT_PREFIX}_shuffled.csv\")\n    \n    print(f\"Writing to {out_bin}...\")\n    \n    with open(out_bin, \"wb\") as f_out:\n        # Write Placeholder Header (we will overwrite this at the end)\n        f_out.write(b'\\x00' * HEADER_SIZE)\n        \n        current_checksum = 0\n        buffer = bytearray()\n        \n        # Convert to numpy for faster lookups in the loop\n        shard_ids = global_df['shard_id'].values\n        orig_idxs = global_df['original_idx'].values\n        \n        for i in tqdm(range(total_patches), unit=\"patch\"):\n            # A. Locate the patch\n            shard_id = shard_ids[i]\n            idx = orig_idxs[i]\n            \n            # B. Calculate Absolute Byte Offset\n            # We MUST add HEADER_SIZE because f.seek(0) goes to the magic bytes, not patch 0.\n            seek_pos = HEADER_SIZE + (idx * PATCH_SIZE_BYTES)\n            \n            # C. Read\n            f_src = file_handles[shard_id]\n            f_src.seek(seek_pos)\n            data = f_src.read(PATCH_SIZE_BYTES)\n            \n            # D. Fail Fast Integrity Check\n            if len(data) != PATCH_SIZE_BYTES:\n                raise ValueError(f\"CRITICAL: Read incomplete patch at Shard {shard_id}, Idx {idx}\")\n            \n            # E. Buffer\n            buffer.extend(data)\n            \n            # F. Write to Disk (in chunks)\n            if len(buffer) >= WRITE_BUFFER_SIZE:\n                # Incremental CRC: Pass the previous checksum as the 2nd argument\n                current_checksum = zlib.crc32(buffer, current_checksum)\n                f_out.write(buffer)\n                buffer = bytearray()\n        \n        # Flush remaining buffer\n        if len(buffer) > 0:\n            current_checksum = zlib.crc32(buffer, current_checksum)\n            f_out.write(buffer)\n        \n        # 5. Finalize Header\n        print(\"Finalizing Header with CRC32...\")\n        f_out.seek(0)\n        # & 0xFFFFFFFF ensures valid unsigned 32-bit integer\n        final_crc = current_checksum & 0xFFFFFFFF\n        header = struct.pack(HEADER_FMT, MAGIC, final_crc, total_patches, 3, 256, 256, 1, b'CHW')\n        f_out.write(header)\n\n    # Close Inputs\n    for f in file_handles: f.close()\n\n    # 6. Save Clean Metadata\n    # We drop 'idx' (redundant) and the shuffle helpers\n    final_cols = ['wsi_id', 'label', 'x', 'y']\n    global_df[final_cols].to_csv(out_csv, index=False)\n    \n    # 7. Entropy Report\n    print(\"-\" * 30)\n    print(\"SHUFFLE QUALITY REPORT\")\n    print(\"-\" * 30)\n    \n    # Check max consecutive patches from same slide\n    wsi_series = global_df['wsi_id']\n    changes = wsi_series != wsi_series.shift()\n    groups = changes.cumsum()\n    run_lengths = wsi_series.groupby(groups).count()\n    \n    print(f\"Max Consecutive Patches from same WSI: {run_lengths.max()}\")\n    print(f\"Mean Run Length: {run_lengths.mean():.2f}\")\n    print(\"Done.\")\n\nif __name__ == \"__main__\":\n    fail_fast_merge()","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}