{"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":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":117682,"databundleVersionId":15062069},{"sourceType":"modelInstanceVersion","sourceId":767555,"databundleVersionId":15853392,"modelInstanceId":585849,"modelId":598135},{"sourceType":"modelInstanceVersion","sourceId":766711,"databundleVersionId":15844159,"modelInstanceId":585849,"modelId":598135},{"sourceType":"modelInstanceVersion","sourceId":767584,"databundleVersionId":15853653,"modelInstanceId":585883,"modelId":598173},{"sourceType":"modelInstanceVersion","sourceId":766755,"databundleVersionId":15844653,"modelInstanceId":585883,"modelId":598173},{"sourceType":"modelInstanceVersion","sourceId":766723,"databundleVersionId":15844322,"modelInstanceId":585859,"modelId":598146},{"sourceType":"modelInstanceVersion","sourceId":767586,"databundleVersionId":15853696,"modelInstanceId":585859,"modelId":598146},{"sourceType":"modelInstanceVersion","sourceId":765693,"databundleVersionId":15831525,"modelInstanceId":566219,"modelId":578601},{"sourceType":"kernelVersion","sourceId":297896256}],"dockerImageVersionId":31287,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"!pip install -q --no-index --find-links=\"/kaggle/input/notebooks/blueshyy/surface-nnunet-package/output\" nnunetv2 tifffile scipy scikit-image","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-02-27T09:45:43.014164Z","iopub.execute_input":"2026-02-27T09:45:43.014733Z","iopub.status.idle":"2026-02-27T09:48:13.133602Z","shell.execute_reply.started":"2026-02-27T09:45:43.014693Z","shell.execute_reply":"2026-02-27T09:48:13.132599Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"%%bash\nset -e\n\nDATASET=\"Dataset100_VesuviusSurface\"\nTRIPLET=\"nnUNetTrainer__nnUNetResEncUNetMPlans__3d_fullres\"\n\n# === 0) 填你真实的源路径根（能看到 nnUNet_results160_450e 这一层） ===\nSRC_160=\"/kaggle/input/models/blueshyy/nnunet-160/pytorch/450e/3\"\nSRC_192=\"/kaggle/input/models/blueshyy/nnunet-192/pytorch/330/2\"\nSRC_224=\"/kaggle/input/models/blueshyy/nnunet-224/pytorch/230e/2\"\n\n# === 1) 只准备一份 nnUNet_raw 的 dataset.json（推理识别用） ===\nRAW_DST=\"/kaggle/working/nnunet/nnUNet_raw/${DATASET}\"\nmkdir -p \"${RAW_DST}\"\n\n# 优先从 224 复制（你已验证过）；如果你愿意也可以改成 160/192\ncp \"${SRC_224}/nnUNet_results224_230e/${DATASET}/${TRIPLET}/dataset.json\" \\\n   \"${RAW_DST}/dataset.json\"\n\n# === 2) 封装函数：把某个源 nnUNet_resultsXXX_xxxe 变成独立的 nnUNet_results 根目录 ===\nmake_model_root () {\n  TAG=\"$1\"       # p160 / p192 / p224\n  SRCROOT=\"$2\"   # 上面的 SRC_160/SRC_192/SRC_224\n  RESNAME=\"$3\"   # nnUNet_results160_450e / nnUNet_results192_330e / nnUNet_results224_230e\n\n  SRC=\"${SRCROOT}/${RESNAME}\"\n  DST=\"/kaggle/working/nnunet_models/${TAG}/nnUNet_results\"\n\n  echo \"==> build ${TAG} from ${SRC}\"\n\n  mkdir -p \"${DST}/${DATASET}/${TRIPLET}/fold_all\"\n\n  # plans / fingerprint\n  for f in plans.json dataset_fingerprint.json; do\n    if [ ! -e \"${DST}/${DATASET}/${TRIPLET}/${f}\" ]; then\n      ln -s \"${SRC}/${DATASET}/${TRIPLET}/${f}\" \"${DST}/${DATASET}/${TRIPLET}/${f}\" 2>/dev/null || \\\n      cp \"${SRC}/${DATASET}/${TRIPLET}/${f}\" \"${DST}/${DATASET}/${TRIPLET}/${f}\"\n    fi\n  done\n\n  # fold_all（checkpoint_best.pth 等）\n  if [ ! -e \"${DST}/${DATASET}/${TRIPLET}/fold_all/checkpoint_best.pth\" ]; then\n    ln -s \"${SRC}/${DATASET}/${TRIPLET}/fold_all/\"* \"${DST}/${DATASET}/${TRIPLET}/fold_all/\" 2>/dev/null || \\\n    cp -a \"${SRC}/${DATASET}/${TRIPLET}/fold_all/.\" \"${DST}/${DATASET}/${TRIPLET}/fold_all/\"\n  fi\n\n  # dataset.json（推理必需）：直接链接到 raw 那份\n  if [ ! -e \"${DST}/${DATASET}/${TRIPLET}/dataset.json\" ]; then\n    ln -s \"${RAW_DST}/dataset.json\" \"${DST}/${DATASET}/${TRIPLET}/dataset.json\" 2>/dev/null || \\\n    cp \"${RAW_DST}/dataset.json\" \"${DST}/${DATASET}/${TRIPLET}/dataset.json\"\n  fi\n\n  # 简单验证\n  echo \"[check] ${TAG}\"\n  ls -l \"${DST}/${DATASET}/${TRIPLET}/dataset.json\"\n  ls -l \"${DST}/${DATASET}/${TRIPLET}/plans.json\"\n  ls -l \"${DST}/${DATASET}/${TRIPLET}/dataset_fingerprint.json\"\n  ls -l \"${DST}/${DATASET}/${TRIPLET}/fold_all/checkpoint_best.pth\"\n  echo \"OK: ${DST}/${DATASET}/${TRIPLET}\"\n  echo\n}\n\n# === 3) 生成三套独立 results 根目录 ===\nmake_model_root p160 \"${SRC_160}\" \"nnUNet_results160_1000\"\nmake_model_root p192 \"${SRC_192}\" \"nnUNet_results192_680\"\nmake_model_root p224 \"${SRC_224}\" \"nnUNet_results224_460\"","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-27T13:36:49.186685Z","iopub.execute_input":"2026-02-27T13:36:49.187192Z","iopub.status.idle":"2026-02-27T13:36:49.302028Z","shell.execute_reply.started":"2026-02-27T13:36:49.187163Z","shell.execute_reply":"2026-02-27T13:36:49.301492Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"%%writefile infer_ensemble.py\n\"\"\"nnUNetv2 多模型集成推理 + 生成 submission.zip（Vesuvius Challenge, streaming, 双GPU并行方案B）\n\n方案B：\n- 同一 case 内并行跑多个模型（最多等于 GPU 数），每个模型进程绑定到一张 GPU\n- 读出每个模型的 npz(probabilities)，取 surface 概率做平均\n- public anchor: OR(每个模型 argmax != 0)\n- 对 avg_surface_prob + anchor 做 seeded hysteresis + 3D closing + remove_small_objects\n- 写入 zip；每个 case 完整清理临时目录（不累计占盘）\n\n注意：\n- 要真正解决 P100(sm_60) 不兼容：需要换到 T4/V100/A100/L4 等（sm_70+）\n- 本脚本的并行是“模型级并行”，不是 nnUNet 内部多卡\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport shutil\nimport subprocess\nimport zipfile\nfrom pathlib import Path\nfrom typing import Optional, Sequence, List, Tuple, Dict\n\nimport numpy as np\nimport pandas as pd\nimport tifffile\n\n\n# ------------------------- postprocess deps -------------------------\n\ndef _try_import_postprocess_deps() -> tuple[object, object]:\n\t\"\"\"延迟导入：只有启用后处理时才需要 scipy/skimage。\"\"\"\n\ttry:\n\t\timport scipy.ndimage as ndi  # type: ignore\n\texcept Exception as exc:  # pragma: no cover\n\t\traise RuntimeError(\"启用后处理需要 scipy：pip install scipy\") from exc\n\ttry:\n\t\tfrom skimage.morphology import remove_small_objects  # type: ignore\n\texcept Exception as exc:  # pragma: no cover\n\t\traise RuntimeError(\"启用后处理需要 scikit-image：pip install scikit-image\") from exc\n\treturn ndi, remove_small_objects\n\n\ndef build_anisotropic_struct(z_radius: int, xy_radius: int) -> Optional[np.ndarray]:\n\t\"\"\"构造 3D 各向异性结构元素（用于 closing）。\"\"\"\n\tz, r = int(z_radius), int(xy_radius)\n\tif z == 0 and r == 0:\n\t\treturn None\n\tif z == 0 and r > 0:\n\t\tsize = 2 * r + 1\n\t\tstruct = np.zeros((1, size, size), dtype=bool)\n\t\tcy, cx = r, r\n\t\tfor dy in range(-r, r + 1):\n\t\t\tfor dx in range(-r, r + 1):\n\t\t\t\tif dy * dy + dx * dx <= r * r:\n\t\t\t\t\tstruct[0, cy + dy, cx + dx] = True\n\t\treturn struct\n\tif z > 0 and r == 0:\n\t\tstruct = np.zeros((2 * z + 1, 1, 1), dtype=bool)\n\t\tstruct[:, 0, 0] = True\n\t\treturn struct\n\tdepth = 2 * z + 1\n\tsize = 2 * r + 1\n\tstruct = np.zeros((depth, size, size), dtype=bool)\n\tcz, cy, cx = z, r, r\n\tfor dz in range(-z, z + 1):\n\t\tfor dy in range(-r, r + 1):\n\t\t\tfor dx in range(-r, r + 1):\n\t\t\t\tif dy * dy + dx * dx <= r * r:\n\t\t\t\t\tstruct[cz + dz, cy + dy, cx + dx] = True\n\treturn struct\n\n\ndef seeded_hysteresis_with_topology(\n\tprob: np.ndarray,\n\tpub_fg_bool: np.ndarray,\n\t*,\n\tT_low: float = 0.40,\n\tT_high: float = 0.80,\n\tz_radius: int = 3,\n\txy_radius: int = 2,\n\tdust_min_size: int = 100,\n) -> np.ndarray:\n\t\"\"\"public-anchored + seeded hysteresis + closing + dust removal -> uint8 mask(0/1)\"\"\"\n\tndi, remove_small_objects = _try_import_postprocess_deps()\n\n\tprob = np.asarray(prob, dtype=np.float32)\n\tpub_fg_bool = np.asarray(pub_fg_bool, dtype=bool)\n\n\tstrong = prob >= float(T_high)\n\tweak = (prob >= float(T_low)) | pub_fg_bool\n\n\tif not strong.any():\n\t\treturn np.zeros_like(prob, dtype=np.uint8)\n\n\tstruct_hyst = ndi.generate_binary_structure(3, 3)\n\tmask = ndi.binary_propagation(strong, mask=weak, structure=struct_hyst)\n\tif not mask.any():\n\t\treturn np.zeros_like(prob, dtype=np.uint8)\n\n\tstruct_close = build_anisotropic_struct(z_radius, xy_radius)\n\tif struct_close is not None:\n\t\tmask = ndi.binary_closing(mask, structure=struct_close)\n\n\tif int(dust_min_size) > 0:\n\t\tmask = remove_small_objects(mask.astype(bool), min_size=int(dust_min_size))\n\n\treturn mask.astype(np.uint8)\n\n\n# ------------------------- io utils -------------------------\n\ndef safe_symlink_or_copy(src: Path, dst: Path) -> None:\n\tdst.parent.mkdir(parents=True, exist_ok=True)\n\tif dst.exists():\n\t\treturn\n\ttry:\n\t\tdst.symlink_to(src.resolve())\n\texcept Exception:\n\t\tshutil.copy2(src, dst)\n\n\ndef write_spacing_json(json_path: Path, spacing: Sequence[float] = (1.0, 1.0, 1.0)) -> None:\n\tjson_path.parent.mkdir(parents=True, exist_ok=True)\n\tjson_path.write_text(json.dumps({\"spacing\": list(spacing)}), encoding=\"utf-8\")\n\n\ndef _prepare_single_case_input(\n\t*,\n\tcase_id: str,\n\tsrc_tif: Path,\n\ttmp_in: Path,\n\tspacing: Sequence[float],\n\tuse_symlinks: bool,\n) -> None:\n\t\"\"\"为单个 case 准备 nnUNet 输入：<case>_0000.tif + <case>_0000.json\"\"\"\n\ttmp_in.mkdir(parents=True, exist_ok=True)\n\tdst_tif = tmp_in / f\"{case_id}_0000.tif\"\n\tdst_json = tmp_in / f\"{case_id}_0000.json\"\n\tif use_symlinks:\n\t\tsafe_symlink_or_copy(src_tif, dst_tif)\n\telse:\n\t\tif not dst_tif.exists():\n\t\t\tshutil.copy2(src_tif, dst_tif)\n\t# spacing json\n\tif not dst_json.exists():\n\t\twrite_spacing_json(dst_json, spacing)\n\n\ndef _load_probabilities_from_npz(path: Path) -> np.ndarray:\n\tdata = np.load(path)\n\tif \"probabilities\" not in data:\n\t\traise KeyError(f\"{path} missing key 'probabilities'. Keys={list(data.keys())}\")\n\tprobs = data[\"probabilities\"]\n\tif probs.ndim != 4:\n\t\traise ValueError(f\"Unexpected probabilities shape in {path}: {probs.shape}\")\n\treturn probs\n\n\ndef _find_single_npz(pred_dir: Path) -> Path:\n\tnpzs = sorted(pred_dir.glob(\"*.npz\"))\n\tif len(npzs) != 1:\n\t\traise FileNotFoundError(\n\t\t\tf\"Expected exactly 1 .npz in {pred_dir}, found {len(npzs)}: {[p.name for p in npzs]}\"\n\t\t)\n\treturn npzs[0]\n\n\ndef _build_predict_cmd(\n\t*,\n\tinput_dir: Path,\n\toutput_dir: Path,\n\tdataset_id: int,\n\tconfiguration: str,\n\tfold: str,\n\tplans_name: str,\n\ttrainer: str,\n\tcheckpoint: str,\n\tdisable_tta: bool,\n\tsave_probabilities: bool,\n) -> str:\n\tcmd = (\n\t\tf\"nnUNetv2_predict \"\n\t\tf\"-i \\\"{input_dir}\\\" -o \\\"{output_dir}\\\" \"\n\t\tf\"-d {dataset_id:03d} -c {configuration} -f {fold} \"\n\t\tf\"-p {plans_name} -tr {trainer} -chk {checkpoint} \"\n\t\tf\"--verbose\"\n\t)\n\tif disable_tta:\n\t\tcmd += \" --disable_tta\"\n\tif save_probabilities:\n\t\tcmd += \" --save_probabilities\"\n\treturn cmd\n\n\n# ------------------------- parallel ensemble core (方案B) -------------------------\n\ndef _run_predict_subprocess(\n\t*,\n\tcmd: str,\n\tenv: Dict[str, str],\n) -> None:\n\t# 用 Popen 并行，stdout/stderr 继承 notebook，方便看日志\n\tp = subprocess.Popen(cmd, shell=True, env=env)\n\tret = p.wait()\n\tif ret != 0:\n\t\traise RuntimeError(f\"nnUNetv2_predict failed (exit={ret}). cmd={cmd}\")\n\n\ndef ensemble_predict_single_case_parallel(\n\t*,\n\tcase_id: str,\n\tsrc_tif: Path,\n\ttmp_root: Path,\n\ttmp_in: Path,\n\tmodel_results_roots: List[Path],\n\tnnunet_raw: Path,\n\tnnunet_preprocessed: Path,\n\tdataset_id: int,\n\tconfiguration: str,\n\tfold: str,\n\tplans_name: str,\n\ttrainer: str,\n\tcheckpoint: str,\n\tdisable_tta: bool,\n\tsurface_label: int,\n\tpostprocess: bool,\n\tT_low: float,\n\tT_high: float,\n\tz_radius: int,\n\txy_radius: int,\n\tdust_min_size: int,\n\tspacing: Sequence[float],\n\tuse_symlinks: bool,\n\tgpus: List[int],\n\tparallel: int,\n) -> np.ndarray:\n\t\"\"\"\n\t方案B：同一个 case 内按 batch 并行跑模型（batch 大小 = parallel），每个进程绑定到不同 GPU\n\t返回：uint8 mask (0/1)\n\t\"\"\"\n\t_prepare_single_case_input(\n\t\tcase_id=case_id,\n\t\tsrc_tif=src_tif,\n\t\ttmp_in=tmp_in,\n\t\tspacing=spacing,\n\t\tuse_symlinks=use_symlinks,\n\t)\n\n\tif parallel < 1:\n\t\tparallel = 1\n\tif not gpus:\n\t\t# 没给就默认单卡 0\n\t\tgpus = [0]\n\tparallel = min(parallel, len(gpus))\n\n\tsurface_probs: List[np.ndarray] = []\n\tpub_fg_or: Optional[np.ndarray] = None\n\n\t# 以 parallel 为 batch size 分批跑\n\tn_models = len(model_results_roots)\n\tfor start in range(0, n_models, parallel):\n\t\tbatch = list(range(start, min(start + parallel, n_models)))\n\n\t\t# 1) 启动这一批子进程\n\t\tprocs: List[Tuple[int, subprocess.Popen]] = []\n\t\tfor bi, mi in enumerate(batch):\n\t\t\tresults_root = model_results_roots[mi]\n\t\t\tgpu_id = gpus[bi]  # batch 内第 bi 个模型用 gpus[bi]\n\n\t\t\tpred_dir = tmp_root / f\"pred_m{mi}\"\n\t\t\tpred_dir.mkdir(parents=True, exist_ok=True)\n\n\t\t\tcmd = _build_predict_cmd(\n\t\t\t\tinput_dir=tmp_in,\n\t\t\t\toutput_dir=pred_dir,\n\t\t\t\tdataset_id=dataset_id,\n\t\t\t\tconfiguration=configuration,\n\t\t\t\tfold=fold,\n\t\t\t\tplans_name=plans_name,\n\t\t\t\ttrainer=trainer,\n\t\t\t\tcheckpoint=checkpoint,\n\t\t\t\tdisable_tta=disable_tta,\n\t\t\t\tsave_probabilities=True,\n\t\t\t)\n\n\t\t\tenv = os.environ.copy()\n\t\t\t# 关键：绑定 GPU（让该进程只看到一张卡）\n\t\t\tenv[\"CUDA_VISIBLE_DEVICES\"] = str(gpu_id)\n\t\t\t# 关键：为该进程指定 nnU-Net 路径\n\t\t\tenv[\"nnUNet_raw\"] = str(nnunet_raw)\n\t\t\tenv[\"nnUNet_preprocessed\"] = str(nnunet_preprocessed)\n\t\t\tenv[\"nnUNet_results\"] = str(results_root)\n\n\t\t\tprint(f\"[case {case_id}] launch model#{mi} on GPU{gpu_id}: {results_root}\")\n\t\t\tp = subprocess.Popen(cmd, shell=True, env=env)\n\t\t\tprocs.append((mi, p))\n\n\t\t# 2) 等待这一批完成\n\t\tfor mi, p in procs:\n\t\t\tret = p.wait()\n\t\t\tif ret != 0:\n\t\t\t\traise RuntimeError(f\"[case {case_id}] model#{mi} failed (exit={ret}).\")\n\n\t\t# 3) 读取这一批的输出 npz\n\t\tfor mi in batch:\n\t\t\tpred_dir = tmp_root / f\"pred_m{mi}\"\n\t\t\tnpz_path = _find_single_npz(pred_dir)\n\t\t\tprobs = _load_probabilities_from_npz(npz_path)\n\n\t\t\tif surface_label < 0 or surface_label >= probs.shape[0]:\n\t\t\t\traise ValueError(f\"surface_label={surface_label} 越界，模型类别数={probs.shape[0]} (model idx={mi})\")\n\n\t\t\tsp = probs[int(surface_label)].astype(np.float32)\n\t\t\tsurface_probs.append(sp)\n\n\t\t\tlabels = probs.argmax(0).astype(np.uint8)\n\t\t\tfg = (labels != 0)\n\t\t\tpub_fg_or = fg if pub_fg_or is None else (pub_fg_or | fg)\n\n\t\t\t# 清理该模型输出目录\n\t\t\tshutil.rmtree(pred_dir, ignore_errors=True)\n\n\tif not surface_probs:\n\t\traise RuntimeError(\"No models provided for ensemble.\")\n\n\tavg_surface = np.mean(np.stack(surface_probs, axis=0), axis=0).astype(np.float32)\n\tpub_fg_bool = np.asarray(pub_fg_or, dtype=bool) if pub_fg_or is not None else np.zeros_like(avg_surface, dtype=bool)\n\n\tif postprocess:\n\t\tmask = seeded_hysteresis_with_topology(\n\t\t\tavg_surface,\n\t\t\tpub_fg_bool=pub_fg_bool,\n\t\t\tT_low=float(T_low),\n\t\t\tT_high=float(T_high),\n\t\t\tz_radius=int(z_radius),\n\t\t\txy_radius=int(xy_radius),\n\t\t\tdust_min_size=int(dust_min_size),\n\t\t)\n\telse:\n\t\tmask = (avg_surface >= float(T_high)).astype(np.uint8)\n\n\treturn mask\n\n\n# ------------------------- args & main -------------------------\n\ndef parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:\n\tp = argparse.ArgumentParser(description=\"nnUNetv2 ensemble inference + submission.zip (方案B: 双GPU并行)\")\n\tp.add_argument(\"--root-dir\", type=Path, required=True, help=\"比赛数据根目录（包含 test_images/test.csv）\")\n\tp.add_argument(\"--work-dir\", type=Path, default=Path(\"./work\"), help=\"工作目录（默认 ./work）\")\n\n\t# nnUNet env（raw/preprocessed 固定；results 由 ensemble-results 提供）\n\tp.add_argument(\"--nnunet-raw\", type=Path, required=True)\n\tp.add_argument(\"--nnunet-preprocessed\", type=Path, required=True)\n\n\t# ensemble models: each is a nnUNet_results root\n\tp.add_argument(\"--ensemble-results\", type=Path, nargs=\"+\", required=True,\n\t\t\t\t   help=\"多个模型的 nnUNet_results 根目录（至少 2 个）\")\n\n\t# model config\n\tp.add_argument(\"--dataset-id\", type=int, default=100)\n\tp.add_argument(\"--configuration\", type=str, default=\"3d_fullres\")\n\tp.add_argument(\"--plans-name\", type=str, default=\"nnUNetResEncUNetMPlans\")\n\tp.add_argument(\"--trainer\", type=str, default=\"nnUNetTrainer\")\n\tp.add_argument(\"--fold\", type=str, default=\"all\")\n\tp.add_argument(\"--checkpoint\", type=str, default=\"checkpoint_best.pth\")\n\n\tp.add_argument(\"--disable-tta\", type=int, default=0, choices=[0, 1])\n\tp.add_argument(\"--surface-label\", type=int, default=1)\n\n\t# postprocess（建议开启）\n\tp.add_argument(\"--postprocess\", type=int, default=1, choices=[0, 1])\n\tp.add_argument(\"--T-low\", dest=\"T_low\", type=float, default=0.28)\n\tp.add_argument(\"--T-high\", dest=\"T_high\", type=float, default=0.80)\n\tp.add_argument(\"--z-radius\", type=int, default=3)\n\tp.add_argument(\"--xy-radius\", type=int, default=2)\n\tp.add_argument(\"--dust-min-size\", type=int, default=100)\n\n\tp.add_argument(\"--spacing\", type=float, nargs=3, default=(1.0, 1.0, 1.0))\n\tp.add_argument(\"--no-symlinks\", action=\"store_true\")\n\n\t# 并行控制（方案B）\n\tp.add_argument(\"--gpus\", type=int, nargs=\"+\", default=[0, 1], help=\"可用 GPU id 列表，例如 0 1\")\n\tp.add_argument(\"--parallel\", type=int, default=2, help=\"每个 case 同时跑多少个模型（<=len(gpus)）\")\n\n\tp.add_argument(\"--zip-path\", type=Path, default=None, help=\"输出 submission.zip 路径（默认 work-dir/submission.zip）\")\n\treturn p.parse_args(argv)\n\n\ndef main(argv: Optional[Sequence[str]] = None) -> int:\n\targs = parse_args(argv)\n\twork_dir: Path = args.work_dir\n\twork_dir.mkdir(parents=True, exist_ok=True)\n\n\tnnunet_raw: Path = args.nnunet_raw\n\tnnunet_preprocessed: Path = args.nnunet_preprocessed\n\n\t# data\n\ttest_images_dir = args.root_dir / \"test_images\"\n\ttest_csv = args.root_dir / \"test.csv\"\n\tif not test_images_dir.exists():\n\t\traise FileNotFoundError(f\"test_images not found: {test_images_dir}\")\n\tif not test_csv.exists():\n\t\traise FileNotFoundError(f\"test.csv not found: {test_csv}\")\n\n\tdf = pd.read_csv(str(test_csv))\n\tids = df[\"id\"].tolist()\n\tif not ids:\n\t\traise RuntimeError(\"test.csv has no ids\")\n\n\tmodel_results_roots = [Path(p) for p in args.ensemble_results]\n\tif len(model_results_roots) < 2:\n\t\traise ValueError(\"--ensemble-results 至少传 2 个模型 results 根目录\")\n\tfor p in model_results_roots:\n\t\tif not p.exists():\n\t\t\traise FileNotFoundError(f\"Ensemble results root not found: {p}\")\n\n\tgpus = list(args.gpus)\n\tif not gpus:\n\t\tgpus = [0]\n\tparallel = int(args.parallel)\n\tparallel = max(1, min(parallel, len(gpus)))\n\n\tzip_path = args.zip_path or (work_dir / \"submission.zip\")\n\tzip_path.parent.mkdir(parents=True, exist_ok=True)\n\n\t# temp dirs (per-case). 每个 case 结束后会整体删除\n\ttmp_root = work_dir / \"_tmp_nnunet_ensemble\"\n\ttmp_in = tmp_root / \"in\"\n\ttmp_masks = tmp_root / \"masks\"\n\n\tuse_symlinks = not args.no_symlinks\n\n\twith zipfile.ZipFile(zip_path, \"w\", compression=zipfile.ZIP_DEFLATED) as zf:\n\t\tfor i, case_id in enumerate(ids, start=1):\n\t\t\tshutil.rmtree(tmp_root, ignore_errors=True)\n\t\t\ttmp_in.mkdir(parents=True, exist_ok=True)\n\t\t\ttmp_masks.mkdir(parents=True, exist_ok=True)\n\n\t\t\tsrc_tif = test_images_dir / f\"{case_id}.tif\"\n\t\t\tif not src_tif.exists():\n\t\t\t\traise FileNotFoundError(f\"Missing test tif: {src_tif}\")\n\n\t\t\tmask = ensemble_predict_single_case_parallel(\n\t\t\t\tcase_id=case_id,\n\t\t\t\tsrc_tif=src_tif,\n\t\t\t\ttmp_root=tmp_root,\n\t\t\t\ttmp_in=tmp_in,\n\t\t\t\tmodel_results_roots=model_results_roots,\n\t\t\t\tnnunet_raw=nnunet_raw,\n\t\t\t\tnnunet_preprocessed=nnunet_preprocessed,\n\t\t\t\tdataset_id=args.dataset_id,\n\t\t\t\tconfiguration=args.configuration,\n\t\t\t\tfold=args.fold,\n\t\t\t\tplans_name=args.plans_name,\n\t\t\t\ttrainer=args.trainer,\n\t\t\t\tcheckpoint=args.checkpoint,\n\t\t\t\tdisable_tta=bool(args.disable_tta),\n\t\t\t\tsurface_label=args.surface_label,\n\t\t\t\tpostprocess=bool(args.postprocess),\n\t\t\t\tT_low=float(args.T_low),\n\t\t\t\tT_high=float(args.T_high),\n\t\t\t\tz_radius=int(args.z_radius),\n\t\t\t\txy_radius=int(args.xy_radius),\n\t\t\t\tdust_min_size=int(args.dust_min_size),\n\t\t\t\tspacing=args.spacing,\n\t\t\t\tuse_symlinks=use_symlinks,\n\t\t\t\tgpus=gpus,\n\t\t\t\tparallel=parallel,\n\t\t\t)\n\n\t\t\tout_tif = tmp_masks / f\"{case_id}.tif\"\n\t\t\ttifffile.imwrite(str(out_tif), mask.astype(np.uint8))\n\n\t\t\tzf.write(out_tif, arcname=f\"{case_id}.tif\")\n\t\t\ttry:\n\t\t\t\tout_tif.unlink()\n\t\t\texcept Exception:\n\t\t\t\tpass\n\n\t\t\tshutil.rmtree(tmp_root, ignore_errors=True)\n\t\t\tprint(f\"[{i}/{len(ids)}] done: {case_id}\")\n\n\tprint(\"Submission ZIP:\", zip_path)\n\treturn 0\n\n\nif __name__ == \"__main__\":\n\traise SystemExit(main())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-26T17:42:26.748674Z","iopub.execute_input":"2026-02-26T17:42:26.749037Z","iopub.status.idle":"2026-02-26T17:42:26.762380Z","shell.execute_reply.started":"2026-02-26T17:42:26.749010Z","shell.execute_reply":"2026-02-26T17:42:26.761609Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!python infer_ensemble.py \\\n  --root-dir /kaggle/input/vesuvius-challenge-surface-detection \\\n  --work-dir /kaggle/working \\\n  --nnunet-raw /kaggle/working/nnunet/nnUNet_raw \\\n  --nnunet-preprocessed /kaggle/working/nnunet/nnUNet_preprocessed \\\n  --ensemble-results \\\n    /kaggle/working/nnunet_models/p160/nnUNet_results \\\n    /kaggle/working/nnunet_models/p192/nnUNet_results \\\n    /kaggle/working/nnunet_models/p224/nnUNet_results \\\n  --checkpoint checkpoint_best.pth \\\n  --disable-tta 1 \\\n  --postprocess 1 \\\n  --T-low 0.28 --T-high 0.80 \\\n  --z-radius 3 --xy-radius 2 --dust-min-size 100 \\\n  --gpus 0 1 \\\n  --parallel 2","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-26T17:42:33.476239Z","iopub.execute_input":"2026-02-26T17:42:33.476527Z","iopub.status.idle":"2026-02-26T17:47:43.015631Z","shell.execute_reply.started":"2026-02-26T17:42:33.476503Z","shell.execute_reply":"2026-02-26T17:47:43.014579Z"}},"outputs":[],"execution_count":null}]}