Coverage for supercat/apps.py: 100.00%
170 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-09-11 13:26 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-09-11 13:26 +0000
1from pathlib import Path
2from widitapp import WiDiTApp
3import cluey
4from cluey import main, method, tool
5from contextlib import nullcontext
6from rich.progress import Progress
8from .data import build_datasets3D, build_datasets2D
11class Supercat(WiDiTApp):
12 @method
13 def datasets(
14 self,
15 dim: int = cluey.Option(3, help="Number of spatial dimensions (2 or 3)"),
16 deeprock: Path = cluey.Option(
17 None, help="Path to the DeepRock dataset directory"
18 ),
19 scale: int = cluey.Option(4, help="Scale factor for downsampling the images"),
20 augment: bool = cluey.Option(
21 True, help="Apply data augmentation to the training images"
22 ),
23 **kwargs,
24 ) -> tuple:
25 """Build training and validation datasets for 2D or 3D super-resolution."""
26 build_function = build_datasets2D if dim == 2 else build_datasets3D
27 return build_function(deeprock=deeprock, scale=scale, train_augment=augment)
29 @main
30 def predict(
31 self,
32 input: Path = cluey.Option(None, help="Path to the input image"),
33 output: Path = cluey.Option(None, help="Path to save the predicted image"),
34 size: int = cluey.Option(
35 100, help="Default prediction tile size for all spatial dimensions"
36 ),
37 size_i: int = cluey.Option(
38 0, help="Prediction tile size along the i dimension (0 uses size)"
39 ),
40 size_j: int = cluey.Option(
41 0, help="Prediction tile size along the j dimension (0 uses size)"
42 ),
43 size_k: int = cluey.Option(
44 0, help="Prediction tile size along the k dimension (0 uses size)"
45 ),
46 overlap: int = cluey.Option(
47 10, help="Default overlap between prediction tiles in pixels or voxels"
48 ),
49 overlap_i: int = cluey.Option(
50 0, help="Tile overlap along the i dimension (0 uses overlap)"
51 ),
52 overlap_j: int = cluey.Option(
53 0, help="Tile overlap along the j dimension (0 uses overlap)"
54 ),
55 overlap_k: int = cluey.Option(
56 0, help="Tile overlap along the k dimension (0 uses overlap)"
57 ),
58 checkpoint: Path = cluey.Option(None, help="Path to the model checkpoint"),
59 num_sampling_steps: int = cluey.Option(
60 250, help="Number of diffusion sampling steps per prediction"
61 ),
62 seed: int = cluey.Option(42, help="Random seed for diffusion sampling"),
63 single_crop: bool = cluey.Option(False, help="Predict only the center tile"),
64 **kwargs,
65 ):
66 """Generate and save a super-resolution prediction for an input image."""
67 import torch
68 from widit import load_model
70 from .data import read_image_as_tensor
71 from .models import DiffusionPredictionModel
72 from .utils import write_image
74 torch.set_grad_enabled(False)
75 device = "cuda" if torch.cuda.is_available() else "cpu"
77 assert output is not None, "Must provide output path"
78 output_path = Path(output)
79 output_path.parent.mkdir(parents=True, exist_ok=True)
81 assert checkpoint is not None, "Must provide path to model checkpoint"
83 size_i = size_i or size
84 size_j = size_j or size
85 size_k = size_k or size
87 overlap_i = overlap_i or overlap
88 overlap_j = overlap_j or overlap
89 overlap_k = overlap_k or overlap
91 assert input is not None, "Must provide input path"
92 input_image = read_image_as_tensor(input, size=(size_k, size_j, size_i))
93 spatial_dims = input_image.ndim - 1
94 assert spatial_dims in (
95 2,
96 3,
97 ), f"Input image must have 3 or 4 dimensions (C, [D], H, W), got {input_image.shape}"
99 assert (
100 input_image.shape[-1] == size_i
101 ), f"Input image size in i dimension ({input_image.shape[-1]}) does not match specified size_i ({size_i})"
102 assert (
103 input_image.shape[-2] == size_j
104 ), f"Input image size in j dimension ({input_image.shape[-2]}) does not match specified size_j ({size_j})"
105 if spatial_dims == 3:
106 assert (
107 input_image.shape[-3] == size_k
108 ), f"Input image size in k dimension ({input_image.shape[-3]}) does not match specified size_k ({size_k})"
110 # Load checkpoint
111 model = load_model(checkpoint)
113 diffusion = model.out_channels == 2
114 if diffusion:
115 model = DiffusionPredictionModel(model, num_sampling_steps)
116 torch.manual_seed(seed)
118 model.to(device=device)
119 model.eval()
121 prediction = self.generate_prediction(
122 input_image=input_image,
123 model=model,
124 size_i=size_i,
125 size_j=size_j,
126 size_k=size_k,
127 overlap_i=overlap_i,
128 overlap_j=overlap_j,
129 overlap_k=overlap_k,
130 spatial_dims=spatial_dims,
131 device=device,
132 single_crop=single_crop,
133 )
134 write_image(prediction, output_path)
136 def generate_prediction(
137 self,
138 input_image: "torch.Tensor",
139 model: "torch.nn.Module",
140 size_i: int,
141 size_j: int,
142 size_k: int,
143 overlap_i: int,
144 overlap_j: int,
145 overlap_k: int,
146 spatial_dims: int,
147 device: str,
148 single_crop: bool,
149 ) -> "torch.Tensor":
150 import torch
151 import math
153 from .utils import generate_overlapping_intervals, distance_to_boundary
155 episilon = 0.01 # small number so that we do not have a zero weight
156 weight = episilon + distance_to_boundary(
157 size_i=size_i, size_j=size_j, size_k=size_k if spatial_dims == 3 else 1
158 )
159 if spatial_dims == 2:
160 weight = weight[:, :, 0]
162 dtype = torch.float32
164 prediction = torch.zeros_like(input_image, dtype=dtype)
165 summed_weights = torch.zeros_like(input_image, dtype=dtype)
166 input_image = input_image.to(dtype=dtype).unsqueeze(0) # add batch dimension
168 intervals_i = generate_overlapping_intervals(
169 prediction.shape[-spatial_dims], size_i, overlap_i
170 )
171 intervals_j = generate_overlapping_intervals(
172 prediction.shape[1 - spatial_dims], size_j, overlap_j
173 )
174 if spatial_dims == 3:
175 intervals_k = generate_overlapping_intervals(
176 prediction.shape[2 - spatial_dims], size_k, overlap_k
177 )
178 else:
179 intervals_k = [(0, None)]
181 if single_crop:
182 # choose the center crop only
183 intervals_i = [intervals_i[len(intervals_i) // 2]]
184 intervals_j = [intervals_j[len(intervals_j) // 2]]
186 if spatial_dims == 3:
187 intervals_k = [intervals_k[len(intervals_k) // 2]]
189 total_tiles = len(intervals_i) * len(intervals_j) * len(intervals_k)
190 progress_context = Progress() if total_tiles else nullcontext()
192 with torch.no_grad():
193 with progress_context as progress_bar:
194 task_id = None
195 if total_tiles:
196 task_id = progress_bar.add_task(
197 "Predicting tiles", total=total_tiles
198 )
200 for start_i, end_i in intervals_i:
201 for start_j, end_j in intervals_j:
202 for start_k, end_k in intervals_k:
203 spatial_ranges = [
204 slice(start_i, end_i),
205 slice(start_j, end_j),
206 ]
207 if spatial_dims == 3:
208 spatial_ranges.append(slice(start_k, end_k))
210 spatial_ranges = tuple(spatial_ranges)
212 cropped = input_image[
213 (slice(None), slice(None), *spatial_ranges)
214 ]
215 if torch.isnan(cropped).all():
216 continue
218 cropped = cropped.to(device=device)
220 result = model(cropped)
221 prediction[spatial_ranges] += (
222 result[0, 0].to("cpu") * weight
223 )
224 summed_weights[spatial_ranges] += weight
226 if task_id is not None:
227 progress_bar.advance(task_id)
229 non_zero_voxels = summed_weights > 0
230 prediction[non_zero_voxels] /= summed_weights[non_zero_voxels]
231 prediction[~non_zero_voxels] = math.nan
233 return prediction.squeeze(0) # remove batch dimension
235 @tool
236 def porosity(
237 self,
238 input: Path = cluey.Option(None, help="Path to the input image"),
239 ):
240 """Calculate and print the porosity of an input image."""
241 from .data import read_image_as_tensor
242 from .metrics import calc_porosity
244 input_image = read_image_as_tensor(input)
245 porosity = calc_porosity(input_image)
246 print(f"Porosity: {porosity}")
247 return porosity
249 @tool
250 def porosity_distribution(
251 self,
252 input: Path = cluey.Option(None, help="Path to the input image"),
253 output: Path = cluey.Option(
254 None, help="Path to the output CSV containing seeds and porosities"
255 ),
256 size: int = cluey.Option(
257 100, help="Default prediction tile size for all spatial dimensions"
258 ),
259 size_i: int = cluey.Option(
260 0, help="Prediction tile size along the i dimension (0 uses size)"
261 ),
262 size_j: int = cluey.Option(
263 0, help="Prediction tile size along the j dimension (0 uses size)"
264 ),
265 size_k: int = cluey.Option(
266 0, help="Prediction tile size along the k dimension (0 uses size)"
267 ),
268 overlap: int = cluey.Option(
269 10, help="Default overlap between prediction tiles in pixels or voxels"
270 ),
271 overlap_i: int = cluey.Option(
272 0, help="Tile overlap along the i dimension (0 uses overlap)"
273 ),
274 overlap_j: int = cluey.Option(
275 0, help="Tile overlap along the j dimension (0 uses overlap)"
276 ),
277 overlap_k: int = cluey.Option(
278 0, help="Tile overlap along the k dimension (0 uses overlap)"
279 ),
280 checkpoint: Path = cluey.Option(None, help="Path to the model checkpoint"),
281 num_sampling_steps: int = cluey.Option(
282 250, help="Number of diffusion sampling steps per prediction"
283 ),
284 seed: int = cluey.Option(
285 42, help="Starting random seed for consecutive diffusion samples"
286 ),
287 single_crop: bool = cluey.Option(False, help="Predict only the center tile"),
288 count: int = cluey.Option(
289 50,
290 help="Number of consecutive seeds to sample, skipping seeds already saved",
291 ),
292 overwrite: bool = cluey.Option(
293 False, help="Replace the existing output CSV instead of appending results"
294 ),
295 **kwargs,
296 ):
297 """Sample diffusion predictions and save their seeds and porosities to a CSV file."""
298 import torch
299 from widit import load_model
301 from .data import read_image_as_tensor
302 from .models import DiffusionPredictionModel
303 from .metrics import calc_porosity
305 torch.set_grad_enabled(False)
306 device = "cuda" if torch.cuda.is_available() else "cpu"
308 assert input is not None, "Must provide input path"
309 input_image = read_image_as_tensor(input)
310 spatial_dims = input_image.ndim - 1
311 assert spatial_dims in (
312 2,
313 3,
314 ), f"Input image must have 3 or 4 dimensions (C, [D], H, W), got {input_image.shape}"
316 assert output is not None, "Must provide output path"
317 output_path = Path(output)
318 output_path.parent.mkdir(parents=True, exist_ok=True)
320 assert checkpoint is not None, "Must provide path to model checkpoint"
322 size_i = size_i or size
323 size_j = size_j or size
324 size_k = size_k or size
326 overlap_i = overlap_i or overlap
327 overlap_j = overlap_j or overlap
328 overlap_k = overlap_k or overlap
330 # TODO scale the image to (size_k, size_j, size_i) if it is not already that size
332 assert (
333 input_image.shape[-1] == size_i
334 ), f"Input image size in i dimension ({input_image.shape[-1]}) does not match specified size_i ({size_i})"
335 assert (
336 input_image.shape[-2] == size_j
337 ), f"Input image size in j dimension ({input_image.shape[-2]}) does not match specified size_j ({size_j})"
338 if spatial_dims == 3:
339 assert (
340 input_image.shape[-3] == size_k
341 ), f"Input image size in k dimension ({input_image.shape[-3]}) does not match specified size_k ({size_k})"
343 # Load checkpoint
344 model = load_model(checkpoint)
346 diffusion = model.out_channels == 2
347 assert (
348 diffusion
349 ), "Model must be a diffusion model with 2 output channels for porosity distribution tool"
350 if diffusion:
351 model = DiffusionPredictionModel(model, num_sampling_steps)
353 model.to(device=device)
354 model.eval()
356 if overwrite and output_path.exists():
357 output_path.unlink()
359 existing_seeds = set()
360 write_header = True
361 if output_path.exists():
362 write_header = False
363 with open(output_path) as existing_file:
364 for line in existing_file:
365 line = line.strip()
366 if not line or line == "seed,porosity":
367 continue
368 seed_value, _, _ = line.partition(",")
369 try:
370 existing_seeds.add(int(seed_value))
371 except ValueError:
372 continue
374 with open(output_path, "a") as f:
375 if write_header:
376 f.write("seed,porosity\n")
377 f.flush()
379 for index in range(count):
380 current_seed = seed + index
381 if current_seed in existing_seeds:
382 print(
383 f"Skipping seed {current_seed}; already present in {output_path}"
384 )
385 continue
387 torch.manual_seed(current_seed)
388 prediction = self.generate_prediction(
389 input_image=input_image,
390 model=model,
391 size_i=size_i,
392 size_j=size_j,
393 size_k=size_k,
394 overlap_i=overlap_i,
395 overlap_j=overlap_j,
396 overlap_k=overlap_k,
397 spatial_dims=spatial_dims,
398 device=device,
399 single_crop=single_crop,
400 )
401 porosity = calc_porosity(prediction)
402 print(f"Seed: {current_seed}, Porosity: {porosity}")
403 f.write(f"{current_seed},{porosity}\n")
404 f.flush()