{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"codemirror_mode":{"name":"ipython","version":3},"file_extension":".py","mimetype":"text/x-python","name":"python","nbconvert_exporter":"python","pygments_lexer":"ipython3","version":"3.11.13"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":117682,"databundleVersionId":15062069},{"sourceType":"datasetVersion","sourceId":14987870,"datasetId":9593933,"databundleVersionId":15861784},{"sourceType":"kernelVersion","sourceId":290917305}],"dockerImageVersionId":31193,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true},"papermill":{"default_parameters":{},"duration":694.414622,"end_time":"2026-02-26T18:12:55.286302","environment_variables":{},"exception":null,"input_path":"__notebook__.ipynb","output_path":"__notebook__.ipynb","parameters":{},"start_time":"2026-02-26T18:01:20.87168","version":"2.6.0"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Bronze Medal - uuNet by ChatGPT5.2 - [Infer]\nThis is the notebook to infer my ChatGPT5.2 vibe coded bronze medal in Vesuvius Challenge. The train notebook is [here][1]. Discussion is [here][2]\n\n[1]: https://www.kaggle.com/code/cdeotte/train-bronze-medal-uunet-by-chatgpt\n[2]: https://www.kaggle.com/competitions/vesuvius-challenge-surface-detection/writeups/bronze-medal-chatgpt-vibe-coding","metadata":{}},{"cell_type":"code","source":"# ============================================================\n# Vesuvius — SINGLE-PATH DROP-IN (public-anchored + private-seeded hysteresis) — 2xGPU FORK\n#\n# UPDATE (THIS TURN):\n#   ✅ Infer MODEL v31 (architecture matches your v31 TRAIN code):\n#        - ResEncUNet encoder depth n_blocks_per_stage = [1,3,4,6,6,6]\n#        - InstanceNorm3D (affine) everywhere (NOT GroupNorm)\n#        - Decoder uses 1 conv per stage after concat (host-like)\n#        - Deep supervision 4 heads: [aux80, aux40, aux20, main]\n#        - Load weights into TRAIN graph, then wrap MAIN-only for inference logits\n#\n# Everything else unchanged: TTA (top-n extras), OV06_MAIN_ONLY overlaps, postprocess, 2xGPU fork, zip writing.\n# ============================================================\n\nfrom IPython.display import clear_output\nimport os\n\n# protobuf stability (common Kaggle container mismatch)\nos.environ.setdefault(\"PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION\", \"python\")\nos.environ[\"KERAS_BACKEND\"] = \"jax\"\nos.environ.setdefault(\"OMP_NUM_THREADS\", \"4\")\nos.environ.setdefault(\"MKL_NUM_THREADS\", \"4\")\nos.environ.setdefault(\"OPENBLAS_NUM_THREADS\", \"4\")\nos.environ.setdefault(\"NUMEXPR_NUM_THREADS\", \"4\")\n\nvar=\"/kaggle/input/notebooks/ipythonx/vsdetection-packages-offline-installer-only/whls\"\n!pip install \\\n  \"$var\"/keras_nightly-*.whl \\\n  \"$var\"/tifffile-*.whl \\\n  \"$var\"/imagecodecs-*.whl \\\n  \"$var\"/medicai-*.whl \\\n  --no-index \\\n  --find-links \"$var\"\nclear_output()\n\n# --- Protobuf compatibility patch (MessageFactory.GetPrototype) ---\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\n        print(\"Patched protobuf: added MessageFactory.GetPrototype\")\nexcept Exception as e:\n    print(\"Could not patch protobuf MessageFactory:\", e)\n\nimport time, zipfile\nimport numpy as np\nimport pandas as pd\nimport tifffile\nimport scipy.ndimage as ndi\nfrom skimage.morphology import remove_small_objects\nimport keras\nfrom medicai.transforms import Compose, NormalizeIntensity\n\nprint(\"Keras backend:\", keras.config.backend(), \"Keras version:\", keras.version())\n\n# ----------------------------\n# CONFIG — UPDATED FOR v31\n# ----------------------------\nCFG = dict(\n    # model v31 weights\n    kaggle_model_path=\"/kaggle/input/datasets/cdeotte/model-vesuvius-v31/\",  \n    weights_relpath=\"model_v31_e970.weights.h5\",                            \n\n    # SWI overlaps\n    overlap_lo=0.5,\n    overlap_hi=0.66,          # OV06 main view\n    OV06_MAIN_ONLY=True,\n\n    # TTA: 0..6 (0=no TTA; 6=all extras)\n    TTA=6,\n\n    # binary logit definition\n    INK_MODE=\"fg12\",\n\n    # thresholds\n    T_low=0.55,\n    T_high=0.9,\n\n    # topology\n    z_radius=3,\n    xy_radius=2,\n    dust_min_size=100,\n\n    # warmup\n    DO_WARMUP=True,\n\n    # multi-gpu\n    USE_2GPU=True,\n)\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\ntest_df = pd.read_csv(f\"{root_dir}/test.csv\")\nids = test_df[\"id\"].tolist()\nprint(\"Num test volumes:\", len(ids))\n\nROI = (160, 160, 160)\n\n# ----------------------------\n# Numerics (same)\n# ----------------------------\ndef sigmoid_stable(x):\n    x = np.asarray(x, dtype=np.float32)\n    out = np.empty_like(x, dtype=np.float32)\n    pos = x >= 0\n    out[pos] = 1.0 / (1.0 + np.exp(-x[pos]))\n    ex = np.exp(x[~pos])\n    out[~pos] = ex / (1.0 + ex)\n    return out\n\ndef logsumexp2(a, b):\n    a = np.asarray(a, dtype=np.float32)\n    b = np.asarray(b, dtype=np.float32)\n    m = np.maximum(a, b)\n    return m + np.log(np.exp(a - m) + np.exp(b - m) + 1e-12)\n\ndef binary_logit_from_multiclass_logits(logits_5d, mode=\"fg12\"):\n    x = np.asarray(logits_5d, dtype=np.float32)[0]  # (D,H,W,3)\n    L0, L1, L2 = x[...,0], x[...,1], x[...,2]\n    if mode == \"fg12\":\n        return (logsumexp2(L1, L2) - L0).astype(np.float32, copy=False)\n    elif mode == \"class1\":\n        return (L1 - logsumexp2(L0, L2)).astype(np.float32, copy=False)\n    else:\n        raise ValueError(f\"Unknown INK_MODE={mode}\")\n\n# ----------------------------\n# Topology helpers (same)\n# ----------------------------\ndef build_anisotropic_struct(z_radius: int, xy_radius: int):\n    z, r = int(z_radius), int(xy_radius)\n    if z == 0 and r == 0:\n        return None\n    if z == 0 and r > 0:\n        size = 2*r + 1\n        struct = np.zeros((1, size, size), dtype=bool)\n        cy = cx = r\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[0, cy+dy, cx+dx] = True\n        return struct\n    if z > 0 and r == 0:\n        struct = np.zeros((2*z+1, 1, 1), dtype=bool)\n        struct[:, 0, 0] = True\n        return struct\n    depth = 2*z + 1\n    size  = 2*r + 1\n    struct = np.zeros((depth, size, size), dtype=bool)\n    cz = z; cy = cx = 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\n# ============================================================\n# Topology postproc (KEEP SAME)\n# ============================================================\ndef seeded_hysteresis_with_topology(\n    prob, pub_fg_bool,\n    T_low=0.50, T_high=0.90,\n    z_radius=3, xy_radius=2, dust_min_size=100,\n    do_bridge_break=True,\n    bridge_kind=\"xy\",\n    bridge_iters=1,\n):\n    prob = np.asarray(prob, dtype=np.float32)\n    pub_fg_bool = np.asarray(pub_fg_bool, dtype=bool)\n\n    strong = prob >= float(T_high)\n    weak = (prob >= float(T_low)) | pub_fg_bool\n\n    if not strong.any():\n        return np.zeros_like(prob, dtype=np.uint8)\n\n    struct26 = ndi.generate_binary_structure(3, 3)\n    mask = ndi.binary_propagation(strong, mask=weak, structure=struct26)\n\n    if not mask.any():\n        return np.zeros_like(prob, dtype=np.uint8)\n\n    if do_bridge_break:\n        if bridge_kind == \"xy\":\n            st2 = ndi.generate_binary_structure(2, 1)\n            for z in range(mask.shape[0]):\n                mask[z] = ndi.binary_opening(mask[z], structure=st2, iterations=int(bridge_iters))\n        else:\n            st3 = ndi.generate_binary_structure(3, 1)\n            mask = ndi.binary_opening(mask, structure=st3, iterations=int(bridge_iters))\n\n    struct_close = build_anisotropic_struct(z_radius, xy_radius)\n    if struct_close is not None:\n        mask = ndi.binary_closing(mask, structure=struct_close)\n\n    lbl, n = ndi.label(mask, structure=ndi.generate_binary_structure(3, 3))\n    if n > 0:\n        keep_ids = np.unique(lbl[strong])\n        keep_ids = keep_ids[keep_ids != 0]\n        if keep_ids.size == 0:\n            return np.zeros_like(prob, dtype=np.uint8)\n        mask = np.isin(lbl, keep_ids)\n\n    if int(dust_min_size) > 0:\n        mask = remove_small_objects(mask.astype(bool), min_size=int(dust_min_size))\n\n    return mask.astype(np.uint8)\n\n# ----------------------------\n# TTA helper — TOP-N extras (ordered)\n#   TTA=0 => identity only\n#   TTA=N => identity + first N extras below\n# ----------------------------\ndef iter_tta_topn(volume, tta_n: int):\n    \"\"\"\n    volume: (1,D,H,W,1)\n    yields: (v, inv, is_identity)\n    \"\"\"\n    tta_n = int(tta_n)\n    if tta_n < 0: tta_n = 0\n    if tta_n > 6: tta_n = 6\n\n    # identity (always)\n    yield volume, (lambda y: y), True\n\n    if tta_n == 0:\n        return\n\n    extras = []\n    # axes: (N, D, H, W, C) => D axis=1, H axis=2, W axis=3\n\n    # 1) flip W (axis=3)\n    extras.append((np.flip(volume, axis=3), (lambda y: np.flip(y, axis=3)), False))\n    # 2) flip H (axis=2)\n    extras.append((np.flip(volume, axis=2), (lambda y: np.flip(y, axis=2)), False))\n    # 3) rot180 in (H,W)\n    extras.append((np.rot90(volume, k=2, axes=(2, 3)), (lambda y: np.rot90(y, k=-2, axes=(2, 3))), False))\n    # 4) flip D (axis=1)\n    extras.append((np.flip(volume, axis=1), (lambda y: np.flip(y, axis=1)), False))\n    # 5) rot90 k=1\n    extras.append((np.rot90(volume, k=1, axes=(2, 3)), (lambda y: np.rot90(y, k=-1, axes=(2, 3))), False))\n    # 6) rot90 k=3\n    extras.append((np.rot90(volume, k=3, axes=(2, 3)), (lambda y: np.rot90(y, k=-3, axes=(2, 3))), False))\n\n    for i in range(tta_n):\n        yield extras[i]\n\n# ============================================================\n# Worker (per GPU): builds model v31 + SWIs inside process\n# ============================================================\ndef _worker(gpu_id: int, ids_subset, test_dir, output_dir, CFG, ROI, weights_path):\n    os.environ[\"CUDA_VISIBLE_DEVICES\"] = str(gpu_id)\n    os.environ[\"KERAS_BACKEND\"] = \"jax\"\n\n    import time\n    import numpy as np\n    import tifffile\n    import keras\n    from keras import ops\n    from keras import layers\n    from medicai.transforms import Compose, NormalizeIntensity\n    from medicai.utils.inference import SlidingWindowInference\n\n    tta_n = int(CFG.get(\"TTA\", 0))\n    if tta_n < 0: tta_n = 0\n    if tta_n > 6: tta_n = 6\n\n    print(f\"[GPU{gpu_id}] backend={keras.config.backend()} | vols={len(ids_subset)} | TTA={tta_n}\")\n\n    _val_pipeline = Compose([\n        NormalizeIntensity(keys=[\"image\"], nonzero=True, channel_wise=False),\n    ])\n    def _val_transform(img):\n        return _val_pipeline({\"image\": img})[\"image\"]\n\n    def _load(path):\n        vol = tifffile.imread(path).astype(np.float32)\n        return vol[None, ..., None]  # (1,D,H,W,1)\n\n    # ============================================================\n    # v31 ARCH: InstanceNorm3D + encoder depth [1,3,4,6,6,6] + decoder conv1\n    # + deep supervision 4 heads [aux80, aux40, aux20, main]\n    # We load into train graph, then wrap MAIN-only (logits) for SWI.\n    # ============================================================\n    class InstanceNorm3D(layers.Layer):\n        def __init__(self, eps=1e-5, affine=True, name=None):\n            super().__init__(name=name)\n            self.eps = eps\n            self.affine = affine\n\n        def build(self, input_shape):\n            c = int(input_shape[-1])\n            if self.affine:\n                self.gamma = self.add_weight(name=\"gamma\", shape=(c,), initializer=\"ones\", trainable=True)\n                self.beta  = self.add_weight(name=\"beta\",  shape=(c,), initializer=\"zeros\", trainable=True)\n            else:\n                self.gamma, self.beta = None, None\n            super().build(input_shape)\n\n        def call(self, x):\n            mean = ops.mean(x, axis=(1, 2, 3), keepdims=True)\n            var  = ops.mean(ops.square(x - mean), axis=(1, 2, 3), keepdims=True)\n            xhat = (x - mean) / ops.sqrt(var + self.eps)\n            if self.affine:\n                gamma = ops.reshape(self.gamma, (1, 1, 1, 1, -1))\n                beta  = ops.reshape(self.beta,  (1, 1, 1, 1, -1))\n                xhat = xhat * gamma + beta\n            return xhat\n\n    def IN(x, name: str, eps=1e-5, affine=True):\n        return InstanceNorm3D(eps=eps, affine=affine, name=name)(x)\n\n    def conv3d(x, out_ch, k=3, s=1, name=None):\n        return layers.Conv3D(out_ch, kernel_size=k, strides=s, padding=\"same\", use_bias=True, name=name)(x)\n\n    def res_block_in(x, out_ch, name: str, leaky=0.01, eps=1e-5):\n        in_ch = int(x.shape[-1])\n\n        y = conv3d(x, out_ch, k=3, s=1, name=f\"{name}_conv1\")\n        y = IN(y, name=f\"{name}_in1\", eps=eps, affine=True)\n        y = layers.LeakyReLU(alpha=leaky, name=f\"{name}_lrelu1\")(y)\n\n        y = conv3d(y, out_ch, k=3, s=1, name=f\"{name}_conv2\")\n        y = IN(y, name=f\"{name}_in2\", eps=eps, affine=True)\n\n        if in_ch != out_ch:\n            skip = conv3d(x, out_ch, k=1, s=1, name=f\"{name}_skip\")\n            skip = IN(skip, name=f\"{name}_skip_in\", eps=eps, affine=True)\n        else:\n            skip = x\n\n        y = layers.Add(name=f\"{name}_add\")([y, skip])\n        y = layers.LeakyReLU(alpha=leaky, name=f\"{name}_lrelu2\")(y)\n        return y\n\n    def downsample_in(x, out_ch, name: str, leaky=0.01, eps=1e-5):\n        x = conv3d(x, out_ch, k=3, s=2, name=f\"{name}_downconv\")\n        x = IN(x, name=f\"{name}_down_in\", eps=eps, affine=True)\n        x = layers.LeakyReLU(alpha=leaky, name=f\"{name}_down_lrelu\")(x)\n        return x\n\n    # v31 decoder: single conv after concat\n    def dec_conv1_in(x, out_ch, name: str, leaky=0.01, eps=1e-5):\n        x = conv3d(x, out_ch, k=3, s=1, name=f\"{name}_conv\")\n        x = IN(x, name=f\"{name}_in\", eps=eps, affine=True)\n        x = layers.LeakyReLU(alpha=leaky, name=f\"{name}_lrelu\")(x)\n        return x\n\n    def up_block_in(x, skip, out_ch, name: str, leaky=0.01, eps=1e-5):\n        x = layers.Conv3DTranspose(out_ch, kernel_size=2, strides=2, padding=\"same\",\n                                   use_bias=True, name=f\"{name}_up\")(x)\n        x = IN(x, name=f\"{name}_up_in\", eps=eps, affine=True)\n        x = layers.LeakyReLU(alpha=leaky, name=f\"{name}_up_lrelu\")(x)\n\n        x = layers.Concatenate(axis=-1, name=f\"{name}_cat\")([x, skip])\n        x = dec_conv1_in(x, out_ch, name=f\"{name}_dec1\", leaky=leaky, eps=eps)\n        return x\n\n    def build_v31_deepsup4_train_and_main(\n        input_shape=(160, 160, 160, 1),\n        num_classes=3,\n        features_per_stage=(32, 64, 128, 256, 320, 320),\n        n_blocks_per_stage=(1, 3, 4, 6, 6, 6),\n        leaky=0.01,\n        eps=1e-5,\n        classifier_activation=None,  # IMPORTANT: None => logits (matches your inference pipeline)\n    ):\n        assert len(features_per_stage) == 6\n        assert len(n_blocks_per_stage) == 6\n\n        inputs = keras.Input(shape=input_shape, name=\"image\")\n\n        # Encoder stage 0\n        x = conv3d(inputs, features_per_stage[0], k=3, s=1, name=\"enc0_conv\")\n        x = IN(x, name=\"enc0_in\", eps=eps, affine=True)\n        x = layers.LeakyReLU(alpha=leaky, name=\"enc0_lrelu\")(x)\n        for b in range(n_blocks_per_stage[0]):\n            x = res_block_in(x, features_per_stage[0], name=f\"enc0_res{b}\", leaky=leaky, eps=eps)\n        skip0 = x\n        skips = [skip0]\n\n        # Encoder stages 1..5\n        for s in range(1, 6):\n            x = downsample_in(x, features_per_stage[s], name=f\"enc{s}\", leaky=leaky, eps=eps)\n            for b in range(n_blocks_per_stage[s]):\n                x = res_block_in(x, features_per_stage[s], name=f\"enc{s}_res{b}\", leaky=leaky, eps=eps)\n            skips.append(x)\n\n        # Decoder dec4..dec0\n        feat_dec3 = feat_dec2 = feat_dec1 = None\n        for s in reversed(range(0, 5)):\n            x = up_block_in(x, skips[s], features_per_stage[s], name=f\"dec{s}\", leaky=leaky, eps=eps)\n            if s == 3: feat_dec3 = x  # 20^3\n            if s == 2: feat_dec2 = x  # 40^3\n            if s == 1: feat_dec1 = x  # 80^3\n        feat_dec0 = x                # 160^3\n\n        def head(feat, name):\n            lg = layers.Conv3D(num_classes, kernel_size=1, padding=\"same\", name=f\"{name}_logits\")(feat)\n            if classifier_activation:\n                return layers.Activation(classifier_activation, name=f\"{name}_prob\")(lg)\n            return lg\n\n        aux20 = head(feat_dec3, \"aux20\")\n        aux40 = head(feat_dec2, \"aux40\")\n        aux80 = head(feat_dec1, \"aux80\")\n        main  = head(feat_dec0, \"main\")\n\n        model_train = keras.Model(\n            inputs=inputs,\n            outputs=[aux80, aux40, aux20, main],\n            name=\"nnunet3d_resenc_deepsup4_train_in\",\n        )\n        model_main = keras.Model(\n            inputs=inputs,\n            outputs=main,\n            name=\"nnunet3d_resenc_deepsup4_main_only\",\n        )\n        return model_train, model_main\n\n    model_train, model = build_v31_deepsup4_train_and_main(\n        input_shape=ROI + (1,),\n        num_classes=3,\n        features_per_stage=(32, 64, 128, 256, 320, 320),\n        n_blocks_per_stage=(1, 3, 4, 6, 6, 6),\n        classifier_activation=None,  # logits for inference pipeline\n        eps=1e-5,\n    )\n\n    # Load weights into TRAIN graph (must match saved topology)\n    model_train.load_weights(weights_path)\n\n    # Ensure model is MAIN-only logits\n    print(f\"[GPU{gpu_id}] loaded v31 weights OK | train_outputs={len(model_train.outputs)} | main_output_shape={model.output_shape}\")\n\n    def _build_swi(overlap):\n        return SlidingWindowInference(\n            model,\n            num_classes=3,\n            roi_size=ROI,\n            sw_batch_size=1,\n            mode=\"gaussian\",\n            overlap=float(overlap),\n        )\n\n    swi_lo = _build_swi(CFG[\"overlap_lo\"])\n    swi_hi = _build_swi(CFG[\"overlap_hi\"])\n\n    def _warmup(vol):\n        _ = np.asarray(swi_lo(vol))\n        _ = np.asarray(swi_hi(vol))\n\n    def _predict_pub_labels_and_private_prob(vol):\n        mode = CFG[\"INK_MODE\"]\n        tta_n_local = int(CFG.get(\"TTA\", 0))\n        if tta_n_local < 0: tta_n_local = 0\n        if tta_n_local > 6: tta_n_local = 6\n\n        # No TTA => identity only, ONE inference\n        if tta_n_local == 0:\n            l = np.asarray(swi_hi(vol))  # main view uses HI\n            pub_labels = l.argmax(-1).astype(np.uint8).squeeze()\n            s = binary_logit_from_multiclass_logits(l, mode=mode)\n            prob = sigmoid_stable(s)\n            return pub_labels, prob\n\n        logits_sum = None\n        s_sum = None\n        n = 0\n\n        for (v, inv, is_id) in iter_tta_topn(vol, tta_n_local):\n\n            if CFG[\"OV06_MAIN_ONLY\"]:\n                swi_use = swi_hi if is_id else swi_lo\n            else:\n                swi_use = swi_hi\n\n            # ONE inference for this view\n            l = np.asarray(swi_use(v))\n            l = inv(l).astype(np.float32, copy=False)\n\n            logits_sum = l if logits_sum is None else (logits_sum + l)\n\n            s = binary_logit_from_multiclass_logits(l, mode=mode).astype(np.float32, copy=False)\n            s_sum = s if s_sum is None else (s_sum + s)\n\n            n += 1\n\n        mean_logits = logits_sum / float(n)\n        pub_labels = mean_logits.argmax(-1).astype(np.uint8).squeeze()\n\n        s_mean = (s_sum / float(n)).astype(np.float32, copy=False)\n        prob = sigmoid_stable(s_mean)\n        return pub_labels, prob\n\n    # warmup once per GPU\n    if len(ids_subset) and CFG.get(\"DO_WARMUP\", True):\n        first_id = ids_subset[0]\n        v0 = _val_transform(_load(f\"{test_dir}/{first_id}.tif\"))\n        print(f\"[GPU{gpu_id}] warmup compile...\")\n        _warmup(v0)\n\n    for k, image_id in enumerate(ids_subset):\n        t0 = time.perf_counter()\n        vol = _val_transform(_load(f\"{test_dir}/{image_id}.tif\"))\n\n        pub_labels, prob = _predict_pub_labels_and_private_prob(vol)\n        pub_fg = (pub_labels != 0)\n\n        out = seeded_hysteresis_with_topology(\n            prob,\n            pub_fg_bool=pub_fg,\n            T_low=CFG[\"T_low\"],\n            T_high=CFG[\"T_high\"],\n            z_radius=CFG[\"z_radius\"],\n            xy_radius=CFG[\"xy_radius\"],\n            dust_min_size=CFG[\"dust_min_size\"],\n        ).astype(np.uint8)\n\n        out_path = f\"{output_dir}/{image_id}.tif\"\n        tifffile.imwrite(out_path, out)\n\n        dt = time.perf_counter() - t0\n        print(f\"[GPU{gpu_id}] [{k+1}/{len(ids_subset)}] id={image_id} | {dt/60:.2f} min | positives={int(out.sum())}\")\n\n# ============================================================\n# Run (1xGPU or 2xGPU) — FORK ONLY\n# ============================================================\nweights_path = f\"{CFG['kaggle_model_path'].rstrip('/')}/{CFG['weights_relpath']}\"\n\ntta_n = int(CFG.get(\"TTA\", 0))\ntta_n = max(0, min(6, tta_n))\n\nprint(\"CFG:\",\n      f\"weights={CFG['weights_relpath']},\",\n      f\"TTA={tta_n} (extras; total views={1+tta_n}),\",\n      f\"overlap_lo={CFG['overlap_lo']}, overlap_hi={CFG['overlap_hi']},\",\n      f\"INK_MODE={CFG['INK_MODE']}, T_low={CFG['T_low']}, T_high={CFG['T_high']},\",\n      f\"OV06_MAIN_ONLY={CFG['OV06_MAIN_ONLY']}, USE_2GPU={CFG['USE_2GPU']}\")\n\nt_global0 = time.perf_counter()\n\ndef _run_single_gpu():\n    _worker(0, ids, test_dir, output_dir, CFG, ROI, weights_path)\n\ndef _run_two_gpu():\n    mid = len(ids) // 2\n    ids0 = ids[:mid]\n    ids1 = ids[mid:]\n\n    import multiprocessing as mp\n    ctx = mp.get_context(\"fork\")\n\n    p0 = ctx.Process(target=_worker, args=(0, ids0, test_dir, output_dir, CFG, ROI, weights_path))\n    p1 = ctx.Process(target=_worker, args=(1, ids1, test_dir, output_dir, CFG, ROI, weights_path))\n\n    p0.start(); p1.start()\n    p0.join();  p1.join()\n\n    if p0.exitcode != 0 or p1.exitcode != 0:\n        raise RuntimeError(f\"Worker failed: GPU0 exit={p0.exitcode}, GPU1 exit={p1.exitcode}. Check logs above.\")\n\nif CFG[\"USE_2GPU\"]:\n    _run_two_gpu()\nelse:\n    _run_single_gpu()\n\n# ============================================================\n# Sanity check + zip (main process)\n# ============================================================\nmissing = [image_id for image_id in ids if not os.path.exists(f\"{output_dir}/{image_id}.tif\")]\nif missing:\n    print(f\"ERROR: missing {len(missing)}/{len(ids)} outputs. Example: {missing[0]}\")\n    raise FileNotFoundError(f\"Missing outputs (first): {output_dir}/{missing[0]}.tif\")\n\nwith zipfile.ZipFile(zip_path, \"w\", compression=zipfile.ZIP_DEFLATED) as zf:\n    for i, image_id in enumerate(ids):\n        out_path = f\"{output_dir}/{image_id}.tif\"\n        zf.write(out_path, arcname=f\"{image_id}.tif\")\n        os.remove(out_path)\n\n        if (i+1) % 10 == 0 or (i+1) == len(ids):\n            elapsed = time.perf_counter() - t_global0\n            print(f\"[ZIP] added {i+1}/{len(ids)} | elapsed {elapsed/3600:.2f} h\")\n\nprint(\"Submission ZIP:\", zip_path)","metadata":{"execution":{"iopub.execute_input":"2026-02-26T18:01:25.084055Z","iopub.status.busy":"2026-02-26T18:01:25.083758Z","iopub.status.idle":"2026-02-26T18:12:52.149383Z","shell.execute_reply":"2026-02-26T18:12:52.148411Z"},"papermill":{"duration":687.071978,"end_time":"2026-02-26T18:12:52.150894","exception":false,"start_time":"2026-02-26T18:01:25.078916","status":"completed"},"tags":[]},"outputs":[],"execution_count":null}]}