{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceId":117682,"databundleVersionId":15062069,"sourceType":"competition"},{"sourceId":290917305,"sourceType":"kernelVersion"},{"sourceId":655294,"sourceType":"modelInstanceVersion","modelInstanceId":495238,"modelId":510647},{"sourceId":660383,"sourceType":"modelInstanceVersion","modelInstanceId":499479,"modelId":510647},{"sourceId":665589,"sourceType":"modelInstanceVersion","modelInstanceId":503784,"modelId":510647},{"sourceId":665924,"sourceType":"modelInstanceVersion","modelInstanceId":504051,"modelId":510647},{"sourceId":672178,"sourceType":"modelInstanceVersion","modelInstanceId":495238,"modelId":510647},{"sourceId":673516,"sourceType":"modelInstanceVersion","modelInstanceId":499479,"modelId":510647},{"sourceId":674747,"sourceType":"modelInstanceVersion","modelInstanceId":503784,"modelId":510647},{"sourceId":681152,"sourceType":"modelInstanceVersion","modelInstanceId":516822,"modelId":510647},{"sourceId":732880,"sourceType":"modelInstanceVersion","modelInstanceId":516822,"modelId":510647}],"dockerImageVersionId":31193,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# %% [code]\n\"\"\"\n================================================================================\n   VESUVIUS V27 - TUNED HYSTERESIS ENSEMBLE\n\n   - Base: V24 (Best Score: 0.537)\n   - Optimization 1: Ensemble Weights adjusted to 0.75/0.25 (Favoring ComboLoss)\n   - Optimization 2: Z_RADIUS increased to 2 (Better vertical connectivity)\n   - Optimization 3: T_HIGH lowered to 0.80 (Improved Recall)\n   - Constraint: Kept Overlap 0.25 & 4x TTA to ensure <9h runtime\n================================================================================\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nfrom IPython.display import clear_output\n\n# ============================================================================\n# 1. SETUP PACKAGES\n# ============================================================================\nvar = \"/kaggle/input/vsdetection-packages-offline-installer-only/whls\"\nif os.path.exists(var):\n    print(f\"Installing packages from: {var}\")\n    subprocess.run(\n        f\"pip install {var}/keras_nightly-*.whl {var}/tifffile-*.whl {var}/imagecodecs-*.whl {var}/medicai-*.whl --no-index --find-links {var}\",\n        shell=True, check=True\n    )\n    clear_output()\nelse:\n    print(f\"❌ ERROR: Package directory not found at: {var}\")\n    sys.exit(1)\n\nos.environ[\"KERAS_BACKEND\"] = \"tensorflow\"\nos.environ[\"TF_CPP_MIN_LOG_LEVEL\"] = \"2\"\n\n# Protobuf patch\ntry:\n    from google.protobuf import message_factory as _message_factory\n    if not hasattr(_message_factory.MessageFactory, \"GetPrototype\"):\n        from google.protobuf.message_factory import GetMessageClass\n        def _GetPrototype(self, descriptor):\n            return GetMessageClass(descriptor)\n        _message_factory.MessageFactory.GetPrototype = _GetPrototype\nexcept:\n    pass\n\nimport keras\nfrom keras import ops\nfrom medicai.transforms import Compose, NormalizeIntensity \nfrom medicai.models import TransUNet\nfrom medicai.utils.inference import SlidingWindowInference\n\nimport numpy as np\nimport pandas as pd\nimport zipfile\nimport tifffile\nimport scipy.ndimage as ndi\nfrom skimage.morphology import remove_small_objects\nimport gc\n\nprint(\"=\"*60)\nprint(\"VESUVIUS V27 - OPTIMIZED HYSTERESIS\")\nprint(\"=\"*60)\n\n# ============================================================================\n# CONFIG\n# ============================================================================\nroot_dir = \"/kaggle/input/vesuvius-challenge-surface-detection\"\ntest_dir = f\"{root_dir}/test_images\"\noutput_dir = \"/kaggle/working/submission_masks\"\nzip_path = \"/kaggle/working/submission.zip\"\nos.makedirs(output_dir, exist_ok=True)\n\n# Model config\nNUM_CLASSES = 3\nPATCH_SIZE = (160, 160, 160)\nOVERLAP = 0.25      # Sharp inference\nBATCH_SIZE = 2      # Speed optimization\n\n# Post-processing config (Tuned V24)\nT_LOW = 0.45        # Keep connectivity permissive\nT_HIGH = 0.80       # Lowered from 0.85 to find more seeds\nZ_RADIUS = 2        # Increased from 1 to 2 (Fixes vertical splits)\nXY_RADIUS = 0       # Keep 0 (Prevent mergers)\nDUST_MIN_SIZE = 300 # Lowered from 500 to keep smaller valid fragments\n\n# ============================================================================\n# MODEL LOADING\n# ============================================================================\ndef get_ensemble_models():\n    base_path = \"/kaggle/input/vsd-model/keras/transunet\"\n    \n    # 1. The \"Super Model\" (V3 ComboLoss)\n    path_combo = f\"{base_path}/3/transunet.seresnext50.160px.comboloss.weights.h5\"\n    # 2. The \"Support Model\" (V2 Default) - Actually the TPU model path from V24\n    # Let's use the path we used in V24 which worked:\n    path_tpu = \"/kaggle/input/train-vesuvius-surface-3d-detection-on-tpu/model.weights.h5\"\n    \n    models = []\n    \n    if os.path.exists(path_combo):\n        print(f\"Loading Model 1 (ComboLoss): {path_combo}\")\n        m = TransUNet(input_shape=(160, 160, 160, 1), encoder_name='seresnext50', classifier_activation=None, num_classes=NUM_CLASSES)\n        m.load_weights(path_combo)\n        models.append(m)\n        \n    if os.path.exists(path_tpu):\n        print(f\"Loading Model 2 (TPU): {path_tpu}\")\n        m = TransUNet(input_shape=(160, 160, 160, 1), encoder_name='seresnext50', classifier_activation=None, num_classes=NUM_CLASSES)\n        m.load_weights(path_tpu)\n        models.append(m)\n        \n    if not models:\n        print(\"WARNING: No weights found. Initializing random model.\")\n        m = TransUNet(input_shape=(160, 160, 160, 1), encoder_name='seresnext50', classifier_activation=None, num_classes=NUM_CLASSES)\n        return [m]\n        \n    return models\n\n# ============================================================================\n# TRANSFORMS\n# ============================================================================\ndef val_transformation(image):\n    data = {\"image\": image}\n    pipeline = Compose([\n        NormalizeIntensity(keys=[\"image\"], nonzero=True, channel_wise=False)\n    ])\n    result = pipeline(data)\n    return result[\"image\"]\n\ndef load_volume(path):\n    vol = tifffile.imread(path).astype(np.float32)\n    vol = vol[None, ..., None]\n    return vol\n\n# ============================================================================\n# INFERENCE LOGIC (4x TTA)\n# ============================================================================\ndef predict_with_tta(inputs, swi):\n    logits = []\n    # Original\n    logits.append(swi(inputs))\n    # Rotations\n    for k in [1, 2, 3]:\n        img_r = np.rot90(inputs, k=k, axes=(2, 3))\n        p = swi(img_r)\n        p = np.rot90(p, k=-k, axes=(2, 3))\n        logits.append(p)\n    return np.mean(logits, axis=0)\n\ndef ensemble_predict(inputs, models):\n    # Weighted Ensemble (Aligned with 0.546 solution)\n    if len(models) == 2:\n        weights = [0.75, 0.25] # Stronger weight for ComboLoss\n    else:\n        weights = [1.0]\n        \n    ensemble_probs = []\n    \n    for i, model in enumerate(models):\n        swi = SlidingWindowInference(\n            model, \n            num_classes=NUM_CLASSES, \n            roi_size=PATCH_SIZE, \n            sw_batch_size=BATCH_SIZE, \n            mode='gaussian', \n            overlap=OVERLAP\n        )\n        \n        logits = predict_with_tta(inputs, swi)\n        probs = ops.softmax(logits, axis=-1)\n        fg_probs = probs[..., 1] \n        ensemble_probs.append(fg_probs * weights[i])\n    \n    final_probs = np.sum(ensemble_probs, axis=0)\n    return np.squeeze(final_probs)\n\n# ============================================================================\n# POST-PROCESSING (HYSTERESIS)\n# ============================================================================\ndef build_anisotropic_struct(z_radius, xy_radius):\n    z, r = z_radius, xy_radius\n    if z == 0 and r == 0: return None\n    depth = 2 * z + 1\n    size = 2 * r + 1\n    struct = np.zeros((depth, size, size), dtype=bool)\n    cz, cy, cx = z, r, r\n    for dz in range(-z, z + 1):\n        for dy in range(-r, r + 1):\n            for dx in range(-r, r + 1):\n                if dy * dy + dx * dx <= r * r:\n                    struct[cz + dz, cy + dy, cx + dx] = True\n    return struct\n\ndef topo_postprocess(probs, T_low, T_high, z_radius, xy_radius, dust_min_size):\n    # 1. Hysteresis\n    strong = probs >= T_high\n    weak = probs >= T_low\n    \n    if not strong.any(): return np.zeros_like(probs, dtype=np.uint8)\n    \n    struct_hyst = ndi.generate_binary_structure(3, 3)\n    mask = ndi.binary_propagation(strong, mask=weak, structure=struct_hyst)\n    \n    if not mask.any(): return np.zeros_like(probs, dtype=np.uint8)\n\n    # 2. Anisotropic Closing\n    struct = build_anisotropic_struct(z_radius, xy_radius)\n    if struct is not None:\n        mask = ndi.binary_closing(mask, structure=struct)\n\n    # 3. Dust Removal\n    if dust_min_size > 0:\n        mask = remove_small_objects(mask.astype(bool), min_size=dust_min_size)\n\n    return mask.astype(np.uint8)\n\n# ============================================================================\n# MAIN PIPELINE\n# ============================================================================\nprint(\"\\nLoading models...\")\nmodels = get_ensemble_models()\ntest_df = pd.read_csv(f\"{root_dir}/test.csv\")\n\nprint(\"\\nStarting Inference...\")\nwith zipfile.ZipFile(zip_path, \"w\", compression=zipfile.ZIP_DEFLATED) as z:\n    for idx, row in test_df.iterrows():\n        image_id = row[\"id\"]\n        print(f\"\\n[{idx+1}/{len(test_df)}] Processing {image_id}...\")\n        \n        vol = load_volume(f\"{test_dir}/{image_id}.tif\")\n        vol = val_transformation(vol)\n        \n        probs = ensemble_predict(vol, models)\n        \n        final_mask = topo_postprocess(\n            probs, \n            T_low=T_LOW, \n            T_high=T_HIGH, \n            z_radius=Z_RADIUS, \n            xy_radius=XY_RADIUS, \n            dust_min_size=DUST_MIN_SIZE\n        )\n        \n        print(f\"    Foreground voxels: {final_mask.sum():,}\")\n        \n        out_path = f\"{output_dir}/{image_id}.tif\"\n        tifffile.imwrite(out_path, final_mask)\n        z.write(out_path, arcname=f\"{image_id}.tif\")\n        os.remove(out_path)\n        \n        del vol, probs, final_mask\n        gc.collect()\n\nprint(\"\\n\" + \"=\"*60)\nprint(\"V27 COMPLETE\")\nprint(f\"Submission: {zip_path}\")\nprint(\"=\"*60)","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}