ct-segmentation-toolkit 0.2.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.
- ct_seg/__init__.py +78 -0
- ct_seg/denoise/__init__.py +24 -0
- ct_seg/denoise/data.py +348 -0
- ct_seg/denoise/denoise_slice.py +190 -0
- ct_seg/denoise/denoise_volume.py +164 -0
- ct_seg/denoise/eval.py +94 -0
- ct_seg/denoise/loss.py +78 -0
- ct_seg/denoise/model.py +205 -0
- ct_seg/denoise/tiffs.py +105 -0
- ct_seg/denoise/train.py +385 -0
- ct_seg/denoise/utils.py +68 -0
- ct_seg/labeling.py +145 -0
- ct_seg/model.py +117 -0
- ct_seg/segment.py +461 -0
- ct_seg/som_bands.py +199 -0
- ct_seg/tracking.py +84 -0
- ct_seg/train.py +441 -0
- ct_seg/viewer.py +240 -0
- ct_segmentation_toolkit-0.2.0.dist-info/METADATA +156 -0
- ct_segmentation_toolkit-0.2.0.dist-info/RECORD +24 -0
- ct_segmentation_toolkit-0.2.0.dist-info/WHEEL +5 -0
- ct_segmentation_toolkit-0.2.0.dist-info/licenses/LICENSE +201 -0
- ct_segmentation_toolkit-0.2.0.dist-info/licenses/NOTICE +18 -0
- ct_segmentation_toolkit-0.2.0.dist-info/top_level.txt +1 -0
ct_seg/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ct_seg — a CT image segmentation toolkit.
|
|
3
|
+
|
|
4
|
+
Supervised (U-Net), unsupervised (multi-Otsu / k-means / GMM), and label-free
|
|
5
|
+
spectral-spatial (Self-Organizing Map) segmentation of scientific image stacks,
|
|
6
|
+
plus self-supervised denoising (2.5D Noise2Inverse).
|
|
7
|
+
|
|
8
|
+
Only the lightweight, importable APIs are exported here. The interactive tools
|
|
9
|
+
(`labeling`, napari; `viewer`, pyvista) require the optional ``[viz]`` extra and
|
|
10
|
+
are imported directly from their modules when needed.
|
|
11
|
+
|
|
12
|
+
Import policy — torch is loaded LAZILY (PEP 562):
|
|
13
|
+
The torch-backed symbols (``UNetSegmentation``, ``count_parameters``,
|
|
14
|
+
``unet_ns_gn``, ``LCL``) are resolved on first attribute access instead of
|
|
15
|
+
at package import. Two reasons:
|
|
16
|
+
1. Classical users (k-means / GMM / Otsu / SOM) should not pay torch's
|
|
17
|
+
import cost — or even need torch installed — to segment an image.
|
|
18
|
+
2. Robustness: on some environments (observed on anaconda + macOS ARM,
|
|
19
|
+
2026-07-15) eagerly importing torch alongside scikit-learn is FATAL —
|
|
20
|
+
torch's bundled libomp and MKL's libiomp5 both initialize OpenMP and
|
|
21
|
+
the process aborts with ``OMP: Error #179`` the moment a scikit-learn
|
|
22
|
+
thread pool spins up (e.g., KMeans inside ``som_segment``). Keeping
|
|
23
|
+
torch out of the import path unless a torch API is actually requested
|
|
24
|
+
means the classical paths can never trigger that clash.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from ct_seg.segment import (
|
|
28
|
+
enhance_contrast,
|
|
29
|
+
normalize_stack,
|
|
30
|
+
segment_gmm,
|
|
31
|
+
segment_kmeans,
|
|
32
|
+
segment_otsu,
|
|
33
|
+
segment_unet,
|
|
34
|
+
)
|
|
35
|
+
from ct_seg.som_bands import extract_features, som_segment
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
# torch-backed exports, resolved lazily on first access (PEP 562).
|
|
40
|
+
_LAZY_TORCH_EXPORTS = {
|
|
41
|
+
"UNetSegmentation": ("ct_seg.model", "UNetSegmentation"),
|
|
42
|
+
"count_parameters": ("ct_seg.model", "count_parameters"),
|
|
43
|
+
"unet_ns_gn": ("ct_seg.denoise", "unet_ns_gn"),
|
|
44
|
+
"LCL": ("ct_seg.denoise", "LCL"),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"UNetSegmentation",
|
|
49
|
+
"count_parameters",
|
|
50
|
+
"normalize_stack",
|
|
51
|
+
"enhance_contrast",
|
|
52
|
+
"segment_otsu",
|
|
53
|
+
"segment_kmeans",
|
|
54
|
+
"segment_gmm",
|
|
55
|
+
"segment_unet",
|
|
56
|
+
"extract_features",
|
|
57
|
+
"som_segment",
|
|
58
|
+
"unet_ns_gn",
|
|
59
|
+
"LCL",
|
|
60
|
+
"__version__",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def __getattr__(name):
|
|
65
|
+
"""Resolve torch-backed exports on first use (see module docstring)."""
|
|
66
|
+
if name in _LAZY_TORCH_EXPORTS:
|
|
67
|
+
import importlib
|
|
68
|
+
|
|
69
|
+
module_name, attr = _LAZY_TORCH_EXPORTS[name]
|
|
70
|
+
value = getattr(importlib.import_module(module_name), attr)
|
|
71
|
+
globals()[name] = value # cache so the import cost is paid once
|
|
72
|
+
return value
|
|
73
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def __dir__():
|
|
77
|
+
"""Keep tab-completion/introspection honest about the lazy exports."""
|
|
78
|
+
return sorted(list(globals().keys()) + list(_LAZY_TORCH_EXPORTS.keys()))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ct_seg.denoise — self-supervised CT denoising (2.5D Noise2Inverse).
|
|
3
|
+
|
|
4
|
+
Cameron Renteria's own implementation of the Noise2Inverse (N2I) self-supervised
|
|
5
|
+
tomography-denoising framework: a no-skip U-Net (Group Normalization, LeakyReLU) trained
|
|
6
|
+
to map one sub-reconstruction (e.g. even-angle) to another (odd-angle), so it needs no
|
|
7
|
+
clean reference image. His additions over the base method include a 2.5D adjacent-slice
|
|
8
|
+
input, a Laplacian Contrast Loss (LCL) for edge preservation, edge-aware model selection,
|
|
9
|
+
and automatic GPU batch-size optimization.
|
|
10
|
+
|
|
11
|
+
Upstream method (credited): Noise2Inverse - Hendriksen, Pelt & Batenburg, IEEE Transactions
|
|
12
|
+
on Computational Imaging 6 (2020); original code https://github.com/ahendriksen/noise2inverse
|
|
13
|
+
(see ACKNOWLEDGMENTS.md). This subpackage is Copyright 2026 Cameron Renteria, Apache-2.0.
|
|
14
|
+
|
|
15
|
+
Only the lightweight building blocks (model, loss, edge score) are exported here. The
|
|
16
|
+
training/inference CLIs and dataset (`ct_seg.denoise.train`, `denoise_volume`,
|
|
17
|
+
`denoise_slice`, `data`) require the optional ``[denoise]`` extra (albumentations, PyYAML).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from ct_seg.denoise.eval import laplacian_score_batch
|
|
21
|
+
from ct_seg.denoise.loss import LCL
|
|
22
|
+
from ct_seg.denoise.model import unet_ns_gn
|
|
23
|
+
|
|
24
|
+
__all__ = ["unet_ns_gn", "LCL", "laplacian_score_batch"]
|
ct_seg/denoise/data.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""
|
|
2
|
+
data.py — Datasets and batch-size tuning for 2.5D Noise2Inverse.
|
|
3
|
+
|
|
4
|
+
Provides the PyTorch Dataset classes and helpers that feed the training and
|
|
5
|
+
inference pipelines:
|
|
6
|
+
* get_5_adjacent_slices - stack 5 adjacent slices (the 2.5D input), with
|
|
7
|
+
edge slices replicated at the volume boundaries.
|
|
8
|
+
* save_normalization_value - persist the training mean/std back into the
|
|
9
|
+
config YAML so inference normalizes identically.
|
|
10
|
+
* TomoDatasetTrain - load the even/odd sub-reconstructions, normalize
|
|
11
|
+
them, and serve augmented 2.5D patch pairs.
|
|
12
|
+
* TomoDatasetInfer - load the full reconstruction for inference,
|
|
13
|
+
normalized with the stored training statistics.
|
|
14
|
+
* InferenceBatchSizeOptimizer - binary-search the largest batch size that fits
|
|
15
|
+
in GPU memory to avoid out-of-memory errors.
|
|
16
|
+
|
|
17
|
+
Author: Cameron Renteria <crentb23@gmail.com>
|
|
18
|
+
License: Apache-2.0 (see LICENSE)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
|
|
23
|
+
import albumentations as A
|
|
24
|
+
import numpy as np
|
|
25
|
+
import torch
|
|
26
|
+
import torch.nn as nn
|
|
27
|
+
import yaml
|
|
28
|
+
from torch.utils.data import Dataset
|
|
29
|
+
|
|
30
|
+
from ct_seg.denoise import tiffs
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_5_adjacent_slices(idx, data):
|
|
34
|
+
"""
|
|
35
|
+
This function get 5 slices to be used for training.
|
|
36
|
+
|
|
37
|
+
params:
|
|
38
|
+
-idx (int) index for the image within the volume
|
|
39
|
+
-data (numpy array) reconstructed volume
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
if idx == 0:
|
|
43
|
+
prev_prev_slice = data[idx]
|
|
44
|
+
prev_slice = data[idx]
|
|
45
|
+
inp_slice = data[idx]
|
|
46
|
+
next_slice = data[idx + 1]
|
|
47
|
+
next_next_slice = data[idx + 2]
|
|
48
|
+
elif idx == 1:
|
|
49
|
+
prev_prev_slice = data[idx - 1]
|
|
50
|
+
prev_slice = data[idx - 1]
|
|
51
|
+
inp_slice = data[idx]
|
|
52
|
+
next_slice = data[idx + 1]
|
|
53
|
+
next_next_slice = data[idx + 2]
|
|
54
|
+
elif idx == data.shape[0] - 1:
|
|
55
|
+
prev_prev_slice = data[idx - 2]
|
|
56
|
+
prev_slice = data[idx - 1]
|
|
57
|
+
inp_slice = data[idx]
|
|
58
|
+
next_slice = data[idx]
|
|
59
|
+
next_next_slice = data[idx]
|
|
60
|
+
elif idx == data.shape[0] - 2:
|
|
61
|
+
prev_prev_slice = data[idx - 2]
|
|
62
|
+
prev_slice = data[idx - 1]
|
|
63
|
+
inp_slice = data[idx]
|
|
64
|
+
next_slice = data[idx + 1]
|
|
65
|
+
next_next_slice = data[idx + 1]
|
|
66
|
+
else:
|
|
67
|
+
prev_prev_slice = data[idx - 2]
|
|
68
|
+
prev_slice = data[idx - 1]
|
|
69
|
+
inp_slice = data[idx]
|
|
70
|
+
next_slice = data[idx + 1]
|
|
71
|
+
next_next_slice = data[idx + 2]
|
|
72
|
+
|
|
73
|
+
adj_slices = np.concatenate(
|
|
74
|
+
[
|
|
75
|
+
prev_prev_slice[..., np.newaxis],
|
|
76
|
+
prev_slice[..., np.newaxis],
|
|
77
|
+
inp_slice[..., np.newaxis],
|
|
78
|
+
next_slice[..., np.newaxis],
|
|
79
|
+
next_next_slice[..., np.newaxis],
|
|
80
|
+
],
|
|
81
|
+
axis=-1,
|
|
82
|
+
)
|
|
83
|
+
return adj_slices
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save_normalization_value(config_file, mean, std):
|
|
87
|
+
"""
|
|
88
|
+
This functin saves the mean and standard deviation back to the yaml file which is then used during inferencing
|
|
89
|
+
params
|
|
90
|
+
-config_file (str) location of the config file
|
|
91
|
+
-mean (float) mean used for normalization
|
|
92
|
+
-std (float) standard deviation used for normalization
|
|
93
|
+
"""
|
|
94
|
+
# safe load
|
|
95
|
+
try:
|
|
96
|
+
with open(config_file, "r") as file:
|
|
97
|
+
data = yaml.safe_load(file) # Use safe_load for security
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
data = {} # If the file doesn't exist, start with an empty dictionary
|
|
100
|
+
except yaml.YAMLError as exc:
|
|
101
|
+
print(f"Error loading YAML file: {exc}")
|
|
102
|
+
data = {} # Handle parsing errors
|
|
103
|
+
|
|
104
|
+
data["dataset"]["mean4norm"] = float(mean)
|
|
105
|
+
data["dataset"]["std4norm"] = float(std)
|
|
106
|
+
|
|
107
|
+
# write the data back to the yaml file
|
|
108
|
+
with open(config_file, "w") as file:
|
|
109
|
+
yaml.safe_dump(data, file, default_flow_style=False, sort_keys=False)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TomoDatasetTrain(Dataset):
|
|
113
|
+
"""
|
|
114
|
+
Training class for 2.5D N2I
|
|
115
|
+
-This class loads in two lists corresponding to the two sub reconstructions (saved as .tiffs) and normalizes them
|
|
116
|
+
params
|
|
117
|
+
-params (obj) yaml object, essentially a dictionary
|
|
118
|
+
-config_file (str) location of the configuration file
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, params, config_file):
|
|
122
|
+
super(TomoDatasetTrain, self).__init__()
|
|
123
|
+
dataset_params = params["dataset"]
|
|
124
|
+
train_params = params["train"]
|
|
125
|
+
|
|
126
|
+
self.psz = train_params["psz"]
|
|
127
|
+
|
|
128
|
+
# specify augmentations for training
|
|
129
|
+
self.augmentations = A.Compose(
|
|
130
|
+
[
|
|
131
|
+
A.SquareSymmetry(p=1.0),
|
|
132
|
+
# A.RandomGridShuffle(grid=[3,3], p=.5),
|
|
133
|
+
],
|
|
134
|
+
additional_targets={"split1": "image"},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# load in tiff images for training
|
|
138
|
+
|
|
139
|
+
# location to sub reconstructions
|
|
140
|
+
recon_0_path = (
|
|
141
|
+
dataset_params["directory_to_reconstructions"] + "/" + dataset_params["sub_recon_name0"]
|
|
142
|
+
)
|
|
143
|
+
recon_1_path = (
|
|
144
|
+
dataset_params["directory_to_reconstructions"] + "/" + dataset_params["sub_recon_name1"]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# collect tiff files and optionally slice to a subset (avoids OOM on <128GB machines)
|
|
148
|
+
tiffs_collection_0 = tiffs.glob(recon_0_path)
|
|
149
|
+
tiffs_collection_1 = tiffs.glob(recon_1_path)
|
|
150
|
+
|
|
151
|
+
sl_start = dataset_params.get("train_slice_start", None)
|
|
152
|
+
sl_end = dataset_params.get("train_slice_end", None)
|
|
153
|
+
if sl_start is not None and sl_end is not None:
|
|
154
|
+
tiffs_collection_0 = tiffs_collection_0[int(sl_start) : int(sl_end)]
|
|
155
|
+
tiffs_collection_1 = tiffs_collection_1[int(sl_start) : int(sl_end)]
|
|
156
|
+
logging.info(
|
|
157
|
+
f"\nTraining on slice subset [{sl_start}:{sl_end}] ({len(tiffs_collection_0)} slices)"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
self.split0 = tiffs.load_stack(tiffs_collection_0)
|
|
161
|
+
self.split1 = tiffs.load_stack(tiffs_collection_1)
|
|
162
|
+
|
|
163
|
+
# convert any nans to zero
|
|
164
|
+
self.split0 = np.nan_to_num(self.split0)
|
|
165
|
+
self.split1 = np.nan_to_num(self.split1)
|
|
166
|
+
|
|
167
|
+
# normalize the data
|
|
168
|
+
split0_mean = self.split0.mean()
|
|
169
|
+
split0_std = self.split0.std()
|
|
170
|
+
self.split0 = ((self.split0 - split0_mean) / (split0_std)).astype(np.float32)
|
|
171
|
+
logging.info(f"\nSplit 0 is scaled with calculated mean: {split0_mean}, std: {split0_std}")
|
|
172
|
+
|
|
173
|
+
split1_mean = self.split1.mean()
|
|
174
|
+
split1_std = self.split1.std()
|
|
175
|
+
self.split1 = ((self.split1 - split1_mean) / (split1_std)).astype(np.float32)
|
|
176
|
+
logging.info(f"\nSplit 1 is scaled with calculated mean: {split1_mean}, std: {split1_std}")
|
|
177
|
+
|
|
178
|
+
# write mean and std to yaml file
|
|
179
|
+
logging.info(
|
|
180
|
+
"Saving training mean and standard deviation to configuration file to be used for inferencing"
|
|
181
|
+
)
|
|
182
|
+
save_normalization_value(config_file=config_file, mean=split0_mean, std=split0_std)
|
|
183
|
+
|
|
184
|
+
self.samples = self.__len__()
|
|
185
|
+
|
|
186
|
+
def __getitem__(self, idx):
|
|
187
|
+
|
|
188
|
+
# get data using patch size
|
|
189
|
+
xst = np.random.randint(0, self.split0[0].shape[-2] - self.psz)
|
|
190
|
+
yst = np.random.randint(0, self.split0[0].shape[-1] - self.psz)
|
|
191
|
+
|
|
192
|
+
# get stack of images
|
|
193
|
+
view0 = get_5_adjacent_slices(
|
|
194
|
+
idx, self.split0[:, xst : xst + self.psz, yst : yst + self.psz]
|
|
195
|
+
)
|
|
196
|
+
view1 = get_5_adjacent_slices(
|
|
197
|
+
idx, self.split1[:, xst : xst + self.psz, yst : yst + self.psz]
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# perform augmentations
|
|
201
|
+
augmented = self.augmentations(image=view0, split1=view1)
|
|
202
|
+
view0 = augmented["image"]
|
|
203
|
+
view1 = augmented["split1"]
|
|
204
|
+
|
|
205
|
+
return np.transpose(view0, axes=(2, 0, 1)), np.transpose(view1, axes=(2, 0, 1))
|
|
206
|
+
|
|
207
|
+
def __len__(self):
|
|
208
|
+
return self.split0.shape[0]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class TomoDatasetInfer(Dataset):
|
|
212
|
+
"""
|
|
213
|
+
Inference class for 2.5D N2I
|
|
214
|
+
-This class loads in a lists corresponding to the full reconstructions (saved as .tiffs) and normalizes them based on the training data
|
|
215
|
+
params
|
|
216
|
+
-params (obj) yaml object, essentially a dictionary
|
|
217
|
+
-start_slice (int) start slice for processing a portion of the reconstruction
|
|
218
|
+
-end_slice (int) end slice for processing a portion of the reconstruction
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def __init__(self, params, start_slice, end_slice):
|
|
222
|
+
super(TomoDatasetInfer, self).__init__()
|
|
223
|
+
dataset_params = params["dataset"]
|
|
224
|
+
|
|
225
|
+
# location to full reconstruction
|
|
226
|
+
recon_path = (
|
|
227
|
+
dataset_params["directory_to_reconstructions"] + "/" + dataset_params["full_recon_name"]
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# process slice if specified
|
|
231
|
+
if len(start_slice) == 0:
|
|
232
|
+
tiffs_collection = tiffs.glob(recon_path)
|
|
233
|
+
else:
|
|
234
|
+
tiffs_collection = tiffs.glob(recon_path)[int(start_slice) : int(end_slice)]
|
|
235
|
+
|
|
236
|
+
self.reconstruction = tiffs.load_stack(tiffs_collection)
|
|
237
|
+
|
|
238
|
+
# convert any nans to zero
|
|
239
|
+
self.reconstruction = np.nan_to_num(self.reconstruction)
|
|
240
|
+
|
|
241
|
+
mean4norm = dataset_params["mean4norm"]
|
|
242
|
+
std4norm = dataset_params["std4norm"]
|
|
243
|
+
self.reconstruction = ((self.reconstruction - mean4norm) / (std4norm)).astype(np.float32)
|
|
244
|
+
print(f"\nReconstruction is scaled with provided mean: {mean4norm}, std: {std4norm}")
|
|
245
|
+
|
|
246
|
+
self.samples = self.__len__()
|
|
247
|
+
|
|
248
|
+
def __getitem__(self, idx):
|
|
249
|
+
|
|
250
|
+
# get stack of images
|
|
251
|
+
inp = get_5_adjacent_slices(idx, self.reconstruction)
|
|
252
|
+
|
|
253
|
+
return np.transpose(inp, axes=(2, 0, 1))
|
|
254
|
+
|
|
255
|
+
def __len__(self):
|
|
256
|
+
return self.reconstruction.shape[0]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class InferenceBatchSizeOptimizer:
|
|
260
|
+
"""
|
|
261
|
+
Class for determining the optimal batch size to be used for inferencing
|
|
262
|
+
-Differences in GPU memory (32GB V100 vs. 80GB A100), model size, and reconstructed image size can all influence
|
|
263
|
+
how many images can be processed during inference. While we could process 1 image per batch, this is slow and wasteful.
|
|
264
|
+
-This class helps determine the optimal size to be used
|
|
265
|
+
params
|
|
266
|
+
-model (obj) pytorch model to be used for inference
|
|
267
|
+
-input_shape (tuple) size of the images to be denoised
|
|
268
|
+
-device (obj) cuda device
|
|
269
|
+
-max_batch_size (int) maximum batch size to check
|
|
270
|
+
-precision (str) whether to use flaoting point 32 or amp
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
def __init__(
|
|
274
|
+
self,
|
|
275
|
+
model: nn.Module,
|
|
276
|
+
input_shape: tuple,
|
|
277
|
+
device: torch.device = torch.device("cuda"),
|
|
278
|
+
max_batch_size: int = 512,
|
|
279
|
+
precision: str = "fp32",
|
|
280
|
+
):
|
|
281
|
+
self.model = model.eval().to(device)
|
|
282
|
+
self.input_shape = input_shape # (C, H, W) or (C, D, H, W) for 3D
|
|
283
|
+
self.device = device
|
|
284
|
+
self.max_batch_size = max_batch_size
|
|
285
|
+
self.precision = precision.lower()
|
|
286
|
+
|
|
287
|
+
if self.precision not in ["fp32", "amp"]:
|
|
288
|
+
raise ValueError("precision must be either 'fp32' or 'amp'")
|
|
289
|
+
|
|
290
|
+
self.cached_optimal_batch_size = None
|
|
291
|
+
|
|
292
|
+
def get_available_memory(self):
|
|
293
|
+
torch.cuda.empty_cache()
|
|
294
|
+
return torch.cuda.mem_get_info(self.device.index)[0] / 1024**2 # MB
|
|
295
|
+
|
|
296
|
+
def estimate_peak_memory(self, batch_size: int) -> float:
|
|
297
|
+
torch.cuda.empty_cache()
|
|
298
|
+
torch.cuda.reset_peak_memory_stats(self.device)
|
|
299
|
+
|
|
300
|
+
dummy_input = torch.randn((batch_size, 5, *self.input_shape), device=self.device)
|
|
301
|
+
try:
|
|
302
|
+
with torch.no_grad():
|
|
303
|
+
if self.precision == "amp":
|
|
304
|
+
with torch.autocast(device_type="cuda"):
|
|
305
|
+
_ = self.model(dummy_input)
|
|
306
|
+
else:
|
|
307
|
+
_ = self.model(dummy_input)
|
|
308
|
+
except RuntimeError as e:
|
|
309
|
+
raise RuntimeError(f"OOM or other error at batch size {batch_size}: {e}")
|
|
310
|
+
|
|
311
|
+
peak_mem = torch.cuda.max_memory_allocated(self.device) / 1024**2 # MB
|
|
312
|
+
return peak_mem
|
|
313
|
+
|
|
314
|
+
def find_optimal_batch_size(self) -> int:
|
|
315
|
+
if self.cached_optimal_batch_size is not None:
|
|
316
|
+
return self.cached_optimal_batch_size
|
|
317
|
+
|
|
318
|
+
low, high = 1, self.max_batch_size
|
|
319
|
+
best = 1
|
|
320
|
+
|
|
321
|
+
# Binary search for the largest batch size whose forward pass fits in memory:
|
|
322
|
+
# a size that succeeds -> try larger; a size that OOMs (RuntimeError) -> try smaller.
|
|
323
|
+
while low <= high:
|
|
324
|
+
mid = (low + high) // 2
|
|
325
|
+
try:
|
|
326
|
+
_ = self.estimate_peak_memory(mid)
|
|
327
|
+
best = mid
|
|
328
|
+
low = mid + 1
|
|
329
|
+
except RuntimeError:
|
|
330
|
+
high = mid - 1
|
|
331
|
+
|
|
332
|
+
self.cached_optimal_batch_size = best
|
|
333
|
+
return best
|
|
334
|
+
|
|
335
|
+
def profile(self):
|
|
336
|
+
batch_size = self.find_optimal_batch_size()
|
|
337
|
+
peak_memory = self.estimate_peak_memory(batch_size)
|
|
338
|
+
available_memory = self.get_available_memory()
|
|
339
|
+
|
|
340
|
+
# print(f"Optimal batch size: {batch_size}")
|
|
341
|
+
# print(f"Peak memory used: {peak_memory:.2f} MB")
|
|
342
|
+
# print(f"Available GPU memory: {available_memory:.2f} MB")
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
"optimal_batch_size": batch_size,
|
|
346
|
+
"peak_memory_used_MB": peak_memory,
|
|
347
|
+
"available_memory_MB": available_memory,
|
|
348
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
denoise_slice.py — Denoise a single CT slice with a trained 2.5D N2I model.
|
|
3
|
+
|
|
4
|
+
Loads the best edge-score checkpoint produced by training (main.py) and denoises
|
|
5
|
+
ONE axial slice of the full reconstruction, saving it as a TIFF in a
|
|
6
|
+
`denoised_slices/` folder next to the reconstructions. This is a fast way to
|
|
7
|
+
spot-check denoising quality without processing the whole volume.
|
|
8
|
+
|
|
9
|
+
Because the model is 2.5D, the two slices above and below the requested slice are
|
|
10
|
+
also loaded and stacked as input channels; this is handled internally and hidden
|
|
11
|
+
from the caller. Normalization uses the mean/std that training wrote back into the
|
|
12
|
+
config file.
|
|
13
|
+
|
|
14
|
+
Inputs : a YAML config (with mean4norm/std4norm filled in by training) and the
|
|
15
|
+
index of the slice to denoise.
|
|
16
|
+
Outputs : <reconstruction_dir>/denoised_slices/<slice>.tiff
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
python denoise_slice.py -gpus=0 -config=/path/to/config.yaml -slice_number=500
|
|
20
|
+
(normally launched via denoise_slice.sh)
|
|
21
|
+
|
|
22
|
+
Author: Cameron Renteria <crentb23@gmail.com>
|
|
23
|
+
License: Apache-2.0 (see LICENSE)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import warnings
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
import tifffile
|
|
34
|
+
import torch
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
from ct_seg.denoise import tiffs
|
|
38
|
+
from ct_seg.denoise.model import unet_ns_gn
|
|
39
|
+
|
|
40
|
+
warnings.filterwarnings("ignore")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def prepare_stack(tiff_images, slice_number, total_number_of_images):
|
|
44
|
+
"""
|
|
45
|
+
This function prepares the stack of images to be used for denoised similar to the data.py approach
|
|
46
|
+
|
|
47
|
+
params:
|
|
48
|
+
-tiff_images (list) list of tiff images
|
|
49
|
+
-slice_number (int) slice to process
|
|
50
|
+
-total_number_of_image (int) total number of slices
|
|
51
|
+
"""
|
|
52
|
+
images_to_process = []
|
|
53
|
+
# Build the 5-slice input stack [s-2, s-1, s, s+1, s+2]. Near the volume edges
|
|
54
|
+
# there are not two neighbours on one side, so the missing slices are clamped to
|
|
55
|
+
# the nearest valid slice (replicate padding) to keep the channel count at 5.
|
|
56
|
+
if slice_number == 0:
|
|
57
|
+
prev_prev_slice = tiff_images[slice_number]
|
|
58
|
+
prev_slice = tiff_images[slice_number]
|
|
59
|
+
inp_slice = tiff_images[slice_number]
|
|
60
|
+
next_slice = tiff_images[slice_number + 1]
|
|
61
|
+
next_next_slice = tiff_images[slice_number + 2]
|
|
62
|
+
elif slice_number == 1:
|
|
63
|
+
prev_prev_slice = tiff_images[slice_number - 1]
|
|
64
|
+
prev_slice = tiff_images[slice_number - 1]
|
|
65
|
+
inp_slice = tiff_images[slice_number]
|
|
66
|
+
next_slice = tiff_images[slice_number + 1]
|
|
67
|
+
next_next_slice = tiff_images[slice_number + 2]
|
|
68
|
+
elif slice_number == total_number_of_images - 1:
|
|
69
|
+
prev_prev_slice = tiff_images[slice_number - 2]
|
|
70
|
+
prev_slice = tiff_images[slice_number - 1]
|
|
71
|
+
inp_slice = tiff_images[slice_number]
|
|
72
|
+
next_slice = tiff_images[slice_number]
|
|
73
|
+
next_next_slice = tiff_images[slice_number]
|
|
74
|
+
elif slice_number == total_number_of_images - 2:
|
|
75
|
+
prev_prev_slice = tiff_images[slice_number - 2]
|
|
76
|
+
prev_slice = tiff_images[slice_number - 1]
|
|
77
|
+
inp_slice = tiff_images[slice_number]
|
|
78
|
+
next_slice = tiff_images[slice_number + 1]
|
|
79
|
+
next_next_slice = tiff_images[slice_number + 1]
|
|
80
|
+
else:
|
|
81
|
+
prev_prev_slice = tiff_images[slice_number - 2]
|
|
82
|
+
prev_slice = tiff_images[slice_number - 1]
|
|
83
|
+
inp_slice = tiff_images[slice_number]
|
|
84
|
+
next_slice = tiff_images[slice_number + 1]
|
|
85
|
+
next_next_slice = tiff_images[slice_number + 2]
|
|
86
|
+
|
|
87
|
+
images_to_process = [prev_prev_slice, prev_slice, inp_slice, next_slice, next_next_slice]
|
|
88
|
+
return images_to_process
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main(args):
|
|
92
|
+
"""Load the trained model and denoise the single requested slice."""
|
|
93
|
+
|
|
94
|
+
# Read the YAML file
|
|
95
|
+
with open(args.config, "r") as file:
|
|
96
|
+
params = yaml.safe_load(file)
|
|
97
|
+
|
|
98
|
+
# create directory for denoised slices
|
|
99
|
+
out_path = params["dataset"]["directory_to_reconstructions"] + "/" "denoised_slices"
|
|
100
|
+
if not os.path.isdir(out_path):
|
|
101
|
+
os.mkdir(out_path)
|
|
102
|
+
|
|
103
|
+
# setup cuda device
|
|
104
|
+
dev = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
105
|
+
|
|
106
|
+
# load in model
|
|
107
|
+
path_to_mdl = (
|
|
108
|
+
params["dataset"]["directory_to_reconstructions"]
|
|
109
|
+
+ "/"
|
|
110
|
+
+ "TrainOutput"
|
|
111
|
+
+ "/"
|
|
112
|
+
+ "best_edge_model.pth"
|
|
113
|
+
)
|
|
114
|
+
# weights_only=True: restrict deserialization to tensors/containers.
|
|
115
|
+
# torch.load unpickles arbitrary Python by default, so a malicious
|
|
116
|
+
# checkpoint file would execute code on load (CWE-502 / bandit B614).
|
|
117
|
+
checkpoint = torch.load(path_to_mdl, map_location=torch.device("cpu"), weights_only=True)
|
|
118
|
+
model = unet_ns_gn(ich=5, start_filter_size=16, channels_per_group=8)
|
|
119
|
+
model.load_state_dict(checkpoint["model_state_dict"])
|
|
120
|
+
model.to(dev)
|
|
121
|
+
|
|
122
|
+
# get data
|
|
123
|
+
print(f"\nLoading in slice {args.slice_number}.\n")
|
|
124
|
+
|
|
125
|
+
# path to data
|
|
126
|
+
full_recon_path = (
|
|
127
|
+
params["dataset"]["directory_to_reconstructions"]
|
|
128
|
+
+ "/"
|
|
129
|
+
+ params["dataset"]["full_recon_name"]
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# collect tiff files
|
|
133
|
+
tiffs_collection = tiffs.glob(full_recon_path)
|
|
134
|
+
# pull out the requested images to process
|
|
135
|
+
list_of_images_to_process = prepare_stack(
|
|
136
|
+
tiff_images=tiffs_collection,
|
|
137
|
+
slice_number=args.slice_number,
|
|
138
|
+
total_number_of_images=len(tiffs_collection),
|
|
139
|
+
)
|
|
140
|
+
# load in just the images to process
|
|
141
|
+
images = tiffs.load_stack(list_of_images_to_process)[np.newaxis]
|
|
142
|
+
images = torch.from_numpy(images).to(dev)
|
|
143
|
+
|
|
144
|
+
# normalize image stack
|
|
145
|
+
mean4norm = params["dataset"]["mean4norm"]
|
|
146
|
+
std4norm = params["dataset"]["std4norm"]
|
|
147
|
+
# mean4norm = images.mean().item()
|
|
148
|
+
# std4norm = images.std().item()
|
|
149
|
+
images = (images - mean4norm) / std4norm
|
|
150
|
+
|
|
151
|
+
# denoise image
|
|
152
|
+
with torch.no_grad():
|
|
153
|
+
denoised = model(images).cpu().squeeze().numpy()
|
|
154
|
+
|
|
155
|
+
# rescale back to original values
|
|
156
|
+
denoised = denoised * std4norm + mean4norm
|
|
157
|
+
|
|
158
|
+
# save denoised slice
|
|
159
|
+
tifffile.imwrite(f"{out_path}/{args.slice_number:05d}.tiff", denoised)
|
|
160
|
+
|
|
161
|
+
# save2img(images[0, 2].cpu().numpy(), f'_original_{args.slice_number:05d}.png')
|
|
162
|
+
# save2img(denoised, f'_denoised_{args.slice_number:05d}.png')
|
|
163
|
+
# bash denoise_slice.sh FOAM/config.yaml 500
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
|
|
168
|
+
parser = argparse.ArgumentParser(description="Denoise CT slice with 2.5D N2I")
|
|
169
|
+
parser.add_argument("-gpus", type=str, default="0", help="list of visiable GPUs")
|
|
170
|
+
parser.add_argument("-slice_number", type=int, required=True, help="test image")
|
|
171
|
+
parser.add_argument("-config", type=str, required=True, help="path to config yaml file")
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"-verbose", type=int, default=1, help="1:print to terminal; 0: redirect to file"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
args, unparsed = parser.parse_known_args()
|
|
177
|
+
|
|
178
|
+
if len(unparsed) > 0:
|
|
179
|
+
print("Unrecognized argument(s): \n%s \nProgram exiting ... ... " % "\n".join(unparsed))
|
|
180
|
+
exit(0)
|
|
181
|
+
|
|
182
|
+
if len(args.gpus) > 0:
|
|
183
|
+
os.environ["CUDA_VISIBLE_DEVICES"] = args.gpus
|
|
184
|
+
|
|
185
|
+
logging.getLogger("matplotlib.font_manager").disabled = True
|
|
186
|
+
logging.getLogger("matplotlib").setLevel(level=logging.CRITICAL)
|
|
187
|
+
if args.verbose:
|
|
188
|
+
logging.getLogger().addHandler(logging.StreamHandler(sys.stdout))
|
|
189
|
+
|
|
190
|
+
main(args)
|