panseg 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
panseg/artifacts.py ADDED
@@ -0,0 +1,109 @@
1
+ """Load timestamped .eval-artifacts dumps produced by panseg infer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+ REPO_ROOT = Path(__file__).resolve().parents[1]
11
+ DEFAULT_ARTIFACTS_DIR = REPO_ROOT / ".eval-artifacts"
12
+
13
+
14
+ def list_eval_runs(root: Path = DEFAULT_ARTIFACTS_DIR) -> list[Path]:
15
+ if not root.is_dir():
16
+ return []
17
+ runs = [p for p in root.iterdir() if p.is_dir() and (p / "manifest.json").is_file()]
18
+ return sorted(runs, key=lambda p: p.name, reverse=True)
19
+
20
+
21
+ def load_manifest(run_dir: Path) -> dict:
22
+ path = run_dir / "manifest.json"
23
+ if not path.is_file():
24
+ raise FileNotFoundError(f"No manifest.json in {run_dir}")
25
+ return json.loads(path.read_text())
26
+
27
+
28
+ def as_3d(data: np.ndarray) -> np.ndarray:
29
+ while data.ndim > 3:
30
+ data = data[..., 0]
31
+ if data.ndim != 3:
32
+ raise ValueError(f"Expected a 3D volume, got shape {data.shape}")
33
+ return data
34
+
35
+
36
+ def load_nifti_3d(path: Path) -> np.ndarray:
37
+ import nibabel as nib
38
+
39
+ image = nib.as_closest_canonical(nib.load(path))
40
+ return as_3d(np.asarray(image.dataobj))
41
+
42
+
43
+ def load_eval_artifact(run_dir: Path) -> dict:
44
+ run_dir = Path(run_dir)
45
+ manifest = load_manifest(run_dir)
46
+ image_name = manifest.get("image")
47
+ if not image_name:
48
+ raise FileNotFoundError(f"{run_dir} manifest is missing image")
49
+ image_path = run_dir / image_name
50
+ if not image_path.is_file():
51
+ raise FileNotFoundError(f"Image not found: {image_path}")
52
+
53
+ gt_name = manifest.get("ground_truth")
54
+ gt_path = run_dir / gt_name if gt_name else None
55
+ if gt_path is not None and not gt_path.is_file():
56
+ gt_path = None
57
+
58
+ masks: list[dict] = []
59
+ for entry in manifest.get("models", []):
60
+ if entry.get("status") != "ok" or not entry.get("mask"):
61
+ continue
62
+ mask_path = run_dir / entry["mask"]
63
+ if mask_path.is_file():
64
+ masks.append({"name": entry.get("name", mask_path.stem), "path": mask_path})
65
+
66
+ return {
67
+ "run_dir": run_dir,
68
+ "manifest": manifest,
69
+ "image_path": image_path,
70
+ "ground_truth_path": gt_path,
71
+ "masks": masks,
72
+ }
73
+
74
+
75
+ def window_slice(volume: np.ndarray, index: int) -> np.ndarray:
76
+ sl = volume[:, :, index].astype(np.float32)
77
+ lo, hi = np.percentile(sl, [0.5, 99.5])
78
+ if hi <= lo:
79
+ hi = lo + 1.0
80
+ scaled = np.clip((sl - lo) / (hi - lo), 0.0, 1.0)
81
+ return scaled.T
82
+
83
+
84
+ def overlay_mask(base: np.ndarray, mask: np.ndarray, color: tuple[float, float, float], alpha: float = 0.45) -> np.ndarray:
85
+ rgb = np.stack([base, base, base], axis=-1)
86
+ return overlay_on_rgb(rgb, mask, color, alpha)
87
+
88
+
89
+ def overlay_on_rgb(
90
+ rgb: np.ndarray,
91
+ mask: np.ndarray,
92
+ color: tuple[float, float, float],
93
+ alpha: float = 0.45,
94
+ ) -> np.ndarray:
95
+ out = np.array(rgb, dtype=np.float32, copy=True)
96
+ hit = mask.T > 0
97
+ tint = np.array(color, dtype=np.float32)
98
+ out[hit] = (1.0 - alpha) * out[hit] + alpha * tint
99
+ return np.clip(out, 0.0, 1.0)
100
+
101
+
102
+ def dice_score(pred: np.ndarray, target: np.ndarray) -> float:
103
+ pred_b = pred > 0
104
+ target_b = target > 0
105
+ intersection = np.logical_and(pred_b, target_b).sum()
106
+ denom = pred_b.sum() + target_b.sum()
107
+ if denom == 0:
108
+ return float("nan")
109
+ return float(2.0 * intersection / denom)
panseg/cli.py ADDED
@@ -0,0 +1,176 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Literal
4
+
5
+ import typer
6
+
7
+ from panseg.infer import (
8
+ DEFAULT_PREDICTION_ARTIFACTS_DIR,
9
+ run_hf_inference,
10
+ )
11
+ from panseg.viewer import launch_viewer
12
+ from panseg.pipeline import PansegLightningModule
13
+ from panseg.utils import DATASET_PARQUETS, DATASETS, MODELS, build_model, download_dataset
14
+
15
+ app = typer.Typer()
16
+
17
+
18
+ @app.callback(invoke_without_command=True)
19
+ def main(ctx: typer.Context) -> None:
20
+ if ctx.invoked_subcommand is None:
21
+ print("Hello, welcome to panseg!")
22
+
23
+
24
+ @app.command()
25
+ def download(
26
+ dataset: str = typer.Option(
27
+ ...,
28
+ "--dataset",
29
+ "-d",
30
+ help="Dataset name, e.g. medical-decathlon-task07",
31
+ ),
32
+ output_dir: Path = typer.Option(
33
+ ...,
34
+ "--output-dir",
35
+ "-o",
36
+ help="Directory to download the dataset into",
37
+ ),
38
+ ) -> None:
39
+ """Download a registered Kaggle dataset."""
40
+ try:
41
+ download_dataset(dataset, output_dir)
42
+ except ValueError as exc:
43
+ raise typer.BadParameter(str(exc), param_hint="--dataset") from exc
44
+ print(f"Downloaded '{dataset}' to {output_dir}")
45
+
46
+
47
+ @app.command("eval")
48
+ def evaluate(
49
+ parquet: Path | None = typer.Option(
50
+ None,
51
+ "--parquet",
52
+ help="Dataset manifest parquet (defaults to the dataset's parquet)",
53
+ file_okay=True,
54
+ dir_okay=False,
55
+ path_type=Path,
56
+ ),
57
+ modality: Literal["ct", "mri"] = typer.Option(
58
+ ...,
59
+ "--modality",
60
+ "-m",
61
+ help="Imaging modality",
62
+ ),
63
+ model: str = typer.Option(
64
+ ...,
65
+ "--model",
66
+ help="Model name, e.g. totalseg, dints, mrseg, or nnunet",
67
+ ),
68
+ dataset: str = typer.Option(
69
+ ...,
70
+ "--dataset",
71
+ help="Dataset name, e.g. msd-pancreas, amos-pancreas, amos-ct-pancreas, panseg-t1-pancreas, or panseg-t2-pancreas",
72
+ ),
73
+ checkpoint: Path | None = typer.Option(
74
+ None,
75
+ "--checkpoint",
76
+ help="nnU-Net checkpoint.pth or trainer folder (required for --model nnunet)",
77
+ exists=True,
78
+ file_okay=True,
79
+ dir_okay=True,
80
+ path_type=Path,
81
+ ),
82
+ output: Path | None = typer.Option(
83
+ None,
84
+ "--output",
85
+ help="Write one row of metrics per evaluated case to this CSV",
86
+ file_okay=True,
87
+ dir_okay=False,
88
+ path_type=Path,
89
+ ),
90
+ ) -> None:
91
+ """Run evaluation on the test split of a parquet manifest."""
92
+ if model not in MODELS:
93
+ known = ", ".join(sorted(MODELS))
94
+ raise typer.BadParameter(
95
+ f"Unknown model '{model}'. Known models: {known}",
96
+ param_hint="--model",
97
+ )
98
+ if dataset not in DATASETS:
99
+ known = ", ".join(sorted(DATASETS))
100
+ raise typer.BadParameter(
101
+ f"Unknown dataset '{dataset}'. Known datasets: {known}",
102
+ param_hint="--dataset",
103
+ )
104
+ try:
105
+ parquet_path = parquet or DATASET_PARQUETS[dataset]
106
+ module = PansegLightningModule(
107
+ build_model(model, modality, checkpoint_path=checkpoint)
108
+ )
109
+ module.run_eval(parquet_path, dataset, output_csv=output)
110
+ except ValueError as exc:
111
+ raise typer.BadParameter(str(exc)) from exc
112
+ except FileNotFoundError as exc:
113
+ raise typer.BadParameter(str(exc), param_hint="--parquet") from exc
114
+
115
+
116
+ @app.command("infer")
117
+ def infer(
118
+ input_path: Path = typer.Option(
119
+ ...,
120
+ "--input",
121
+ "-i",
122
+ help="Path to a NIfTI volume (.nii / .nii.gz)",
123
+ exists=True,
124
+ file_okay=True,
125
+ dir_okay=False,
126
+ path_type=Path,
127
+ ),
128
+ checkpoint: str | None = typer.Option(
129
+ None,
130
+ "--checkpoint",
131
+ help=(
132
+ "Optional Hugging Face file path in "
133
+ "dillondesilva/comp3888-capstone-panseg-models. "
134
+ "Defaults to the CT or MRI annealing checkpoint for the resolved modality."
135
+ ),
136
+ ),
137
+ modality: Literal["ct", "mri"] | None = typer.Option(
138
+ None,
139
+ "--modality",
140
+ "-m",
141
+ help="Override the intensity-based CT/MRI routing",
142
+ ),
143
+ accelerator: Literal["auto", "cpu", "cuda", "xpu", "mps"] = typer.Option(
144
+ "auto",
145
+ "--accelerator",
146
+ "-a",
147
+ help="Device used to run the checkpoint",
148
+ ),
149
+ artifacts_dir: Path = typer.Option(
150
+ DEFAULT_PREDICTION_ARTIFACTS_DIR,
151
+ "--artifacts-dir",
152
+ help="Root directory for timestamped prediction dumps",
153
+ file_okay=False,
154
+ dir_okay=True,
155
+ path_type=Path,
156
+ ),
157
+ ) -> None:
158
+ """Download an HF nnU-Net checkpoint, segment one volume, and write a prediction artifact."""
159
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
160
+ try:
161
+ run_dir = run_hf_inference(
162
+ input_path,
163
+ modality=modality,
164
+ checkpoint=checkpoint,
165
+ accelerator=accelerator,
166
+ artifacts_root=artifacts_dir,
167
+ )
168
+ except (FileNotFoundError, ValueError, ImportError, RuntimeError) as exc:
169
+ raise typer.BadParameter(str(exc)) from exc
170
+ print(f"saved prediction artifact: {run_dir}")
171
+ if typer.confirm("Launch napari on the prediction artifact?", default=True):
172
+ launch_viewer(run_dir)
173
+
174
+
175
+ if __name__ == "__main__":
176
+ app()
panseg/constants.py ADDED
@@ -0,0 +1,9 @@
1
+ """Shared panseg constants."""
2
+
3
+ # Air on the Hounsfield scale, and a bright-voxel floor that CT clears and typical MRI does not.
4
+ CT_AIR_HU = -1000.0
5
+ CT_P99_MIN_HU = 400.0
6
+
7
+ HF_MODELS_REPO = "dillondesilva/comp3888-capstone-panseg-models"
8
+ DEFAULT_HF_CT_CHECKPOINT = "ct/ct_final_ckpt_c2b_annealing_aggressive.pth"
9
+ DEFAULT_HF_MRI_CHECKPOINT = "mri/mri_final_ckpt_c2b_annealing_aggressive.pth"
panseg/datasets.py ADDED
@@ -0,0 +1,162 @@
1
+ import json
2
+ from collections.abc import Sequence
3
+ from pathlib import Path
4
+
5
+ import nibabel as nib
6
+ import pandas as pd
7
+ import torch
8
+ from torch.utils.data import Dataset
9
+
10
+ _REPO_ROOT = Path(__file__).resolve().parents[1]
11
+ DEFAULT_PARQUET = _REPO_ROOT / "dataset_generation" / "output" / "ct_pancreas.parquet"
12
+ MRI_PARQUET = _REPO_ROOT / "dataset_generation" / "output" / "mri_pancreas.parquet"
13
+ _REQUIRED_COLUMNS = {
14
+ "dataset",
15
+ "case_id",
16
+ "image_path",
17
+ "label_path",
18
+ "label_value",
19
+ "split",
20
+ }
21
+
22
+
23
+ def resolve_volume_path(rel: str, repo_root: Path) -> Path:
24
+ path = Path(repo_root) / rel
25
+ if path.is_file():
26
+ return path
27
+ if rel.startswith("datasets/"):
28
+ alt = Path(repo_root) / rel.removeprefix("datasets/")
29
+ if alt.is_file():
30
+ return alt
31
+ return path
32
+
33
+
34
+ class ParquetPancreasDataset(Dataset):
35
+ def __init__(
36
+ self,
37
+ parquet_path: Path = DEFAULT_PARQUET,
38
+ source_dataset: str | Sequence[str] = "msd",
39
+ split: str | None = "test",
40
+ repo_root: Path = _REPO_ROOT,
41
+ ) -> None:
42
+ parquet_path = Path(parquet_path)
43
+ if not parquet_path.is_file():
44
+ raise FileNotFoundError(f"Parquet not found: {parquet_path}")
45
+
46
+ df = pd.read_parquet(parquet_path)
47
+ missing = _REQUIRED_COLUMNS - set(df.columns)
48
+ if missing:
49
+ raise ValueError(f"Parquet is missing columns: {sorted(missing)}")
50
+
51
+ if isinstance(source_dataset, str):
52
+ sources = (source_dataset,)
53
+ else:
54
+ sources = tuple(source_dataset)
55
+ df = df[df["dataset"].isin(sources)]
56
+ if split is not None:
57
+ df = df[df["split"] == split]
58
+ if df.empty:
59
+ qualifier = f" with split={split!r}" if split is not None else ""
60
+ label = ", ".join(sources)
61
+ raise FileNotFoundError(
62
+ f"No '{label}' rows{qualifier} in {parquet_path}"
63
+ )
64
+
65
+ self.cases: list[tuple[str, Path, Path, tuple[int, ...]]] = []
66
+ for row in df.itertuples(index=False):
67
+ image_path = resolve_volume_path(row.image_path, repo_root)
68
+ label_path = resolve_volume_path(row.label_path, repo_root)
69
+ if not image_path.is_file():
70
+ raise FileNotFoundError(f"Image not found: {image_path}")
71
+ if not label_path.is_file():
72
+ raise FileNotFoundError(f"Label not found: {label_path}")
73
+ extra = json.loads(getattr(row, "label_extra_values", None) or "[]")
74
+ values = (int(row.label_value), *(int(v) for v in extra))
75
+ self.cases.append((row.case_id, image_path, label_path, values))
76
+
77
+ def __len__(self) -> int:
78
+ return len(self.cases)
79
+
80
+ def __getitem__(self, index: int) -> dict[str, torch.Tensor | str]:
81
+ name, image_path, label_path, label_values = self.cases[index]
82
+ image = nib.load(image_path)
83
+ volume = torch.from_numpy(image.get_fdata().astype("float32"))
84
+ label = torch.from_numpy(nib.load(label_path).get_fdata())
85
+ mask = torch.isin(label, torch.tensor(label_values, dtype=label.dtype)).to(dtype=torch.float32)
86
+ affine = torch.tensor(image.affine, dtype=torch.float32)
87
+ return {"volume": volume, "mask": mask, "affine": affine, "name": name}
88
+
89
+
90
+ class MSDPancreasDataset(ParquetPancreasDataset):
91
+ def __init__(
92
+ self,
93
+ parquet_path: Path = DEFAULT_PARQUET,
94
+ split: str | None = "test",
95
+ repo_root: Path = _REPO_ROOT,
96
+ ) -> None:
97
+ super().__init__(
98
+ parquet_path=parquet_path,
99
+ source_dataset="msd",
100
+ split=split,
101
+ repo_root=repo_root,
102
+ )
103
+
104
+
105
+ class AMOSPancreasDataset(ParquetPancreasDataset):
106
+ def __init__(
107
+ self,
108
+ parquet_path: Path = MRI_PARQUET,
109
+ split: str | None = "test",
110
+ repo_root: Path = _REPO_ROOT,
111
+ ) -> None:
112
+ super().__init__(
113
+ parquet_path=parquet_path,
114
+ source_dataset="amos",
115
+ split=split,
116
+ repo_root=repo_root,
117
+ )
118
+
119
+
120
+ class AMOSCTPancreasDataset(ParquetPancreasDataset):
121
+ def __init__(
122
+ self,
123
+ parquet_path: Path = DEFAULT_PARQUET,
124
+ split: str | None = "test",
125
+ repo_root: Path = _REPO_ROOT,
126
+ ) -> None:
127
+ super().__init__(
128
+ parquet_path=parquet_path,
129
+ source_dataset="amos",
130
+ split=split,
131
+ repo_root=repo_root,
132
+ )
133
+
134
+
135
+ class PanSegT1Dataset(ParquetPancreasDataset):
136
+ def __init__(
137
+ self,
138
+ parquet_path: Path = MRI_PARQUET,
139
+ split: str | None = "test",
140
+ repo_root: Path = _REPO_ROOT,
141
+ ) -> None:
142
+ super().__init__(
143
+ parquet_path=parquet_path,
144
+ source_dataset="panseg_t1",
145
+ split=split,
146
+ repo_root=repo_root,
147
+ )
148
+
149
+
150
+ class PanSegT2Dataset(ParquetPancreasDataset):
151
+ def __init__(
152
+ self,
153
+ parquet_path: Path = MRI_PARQUET,
154
+ split: str | None = "test",
155
+ repo_root: Path = _REPO_ROOT,
156
+ ) -> None:
157
+ super().__init__(
158
+ parquet_path=parquet_path,
159
+ source_dataset="panseg_t2",
160
+ split=split,
161
+ repo_root=repo_root,
162
+ )