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 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)