Coverage for src/crackmnist_eccv/utility.py: 53%
131 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-19 08:00 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-19 08:00 +0000
1# SPDX-FileCopyrightText: 2026 German Aerospace Center (DLR)
2# SPDX-License-Identifier: GPL-3.0-or-later
4"""Pollution utility functions for CrackMNIST experiments.
6Provides per-image noise injection (Gaussian, Salt & Pepper, Gaussian Blur,
7Blob/MVS, Uniform) and crack-tip mask enlargement used during training and
8evaluation.
9"""
11from __future__ import annotations
13import math
14import random
16import numpy as np
17from scipy.interpolate import griddata
18import torch
19import torch.nn.functional as F
22def add_gaussian_noise(
23 tensor: torch.Tensor, mean: float = 0.0, intensity: float = 0.5
24) -> torch.Tensor:
25 """Add Gaussian noise to a tensor.
27 Args:
28 tensor: Input tensor.
29 mean: Mean of the Gaussian noise.
30 intensity: Standard deviation of the noise.
32 Returns:
33 Noisy tensor of the same shape.
34 """
35 noise = torch.randn_like(tensor) * intensity + mean
36 return tensor + noise
39def add_salt_and_pepper_noise(
40 tensor: torch.Tensor,
41 intensity: float = 0.25,
42 salt_to_pepper_ratio: float = 0.5,
43) -> torch.Tensor:
44 """Add salt-and-pepper noise to a tensor.
46 Args:
47 tensor: Input tensor.
48 intensity: Fraction of pixels to corrupt.
49 salt_to_pepper_ratio: Proportion of corrupted pixels set to 1.0
50 (salt) vs 0.0 (pepper).
52 Returns:
53 Noisy tensor of the same shape.
54 """
55 noisy_tensor = tensor.clone()
56 total_pixels = tensor.numel()
57 num_noisy_pixels = int(intensity * total_pixels)
59 num_salt = int(salt_to_pepper_ratio * num_noisy_pixels)
60 num_pepper = num_noisy_pixels - num_salt
62 salt_indices = torch.randint(0, total_pixels, (num_salt,))
63 pepper_indices = torch.randint(0, total_pixels, (num_pepper,))
65 noisy_tensor.view(-1)[salt_indices] = 1.0
66 noisy_tensor.view(-1)[pepper_indices] = 0.0
68 return noisy_tensor
71def add_uniform_noise(tensor: torch.Tensor, intensity: float = 0.1) -> torch.Tensor:
72 """Add zero-centred uniform noise in [-intensity, +intensity).
74 Args:
75 tensor: Input tensor.
76 intensity: Half-range of the uniform distribution. Must be >= 0.
78 Returns:
79 Noisy tensor of the same shape.
81 Raises:
82 ValueError: If intensity is negative.
83 """
84 if intensity < 0:
85 raise ValueError("intensity must be non-negative")
87 noise = torch.empty_like(tensor).uniform_(-intensity, intensity)
88 return tensor + noise
91def apply_gaussian_blur(tensor: torch.Tensor, intensity: float = 4.0) -> torch.Tensor:
92 """Apply channel-wise Gaussian blur to a (B, C, H, W) tensor.
94 Kernel size is derived from sigma following Yoshihara et al. (2023):
95 ``kernel_size = round(8 * intensity + 1)``.
97 Args:
98 tensor: Input tensor of shape (B, C, H, W).
99 intensity: Standard deviation (sigma) of the Gaussian kernel.
101 Returns:
102 Blurred tensor of the same shape.
103 """
104 kernel_size = round(8 * intensity + 1)
106 if kernel_size % 2 == 0: 106 ↛ 107line 106 didn't jump to line 107 because the condition on line 106 was never true
107 kernel_size += 1
109 def gaussian_kernel(kernel_size: int, intensity: float) -> torch.Tensor:
110 """Build a normalised 2-D Gaussian kernel.
112 Args:
113 kernel_size: Size of the square kernel (must be odd).
114 intensity: Standard deviation (sigma) of the Gaussian.
116 Returns:
117 Normalised kernel tensor of shape (1, 1, kernel_size, kernel_size).
118 """
119 kernel = torch.arange(kernel_size).float() - (kernel_size - 1) / 2
120 x_grid, y_grid = torch.meshgrid(kernel, kernel, indexing="ij")
121 kernel_2d = torch.exp(-(x_grid**2 + y_grid**2) / (2 * intensity**2))
122 kernel_2d = kernel_2d / kernel_2d.sum()
123 return kernel_2d.unsqueeze(0).unsqueeze(0)
125 kernel = gaussian_kernel(kernel_size, intensity).to(tensor.device)
127 blurred_tensor = tensor.clone()
128 for channel in range(tensor.shape[1]):
129 tensor_channel = tensor[:, channel : channel + 1, :, :]
130 blurred_tensor_channel = F.conv2d(
131 tensor_channel, kernel, padding=kernel_size // 2
132 )
133 blurred_tensor[:, channel : channel + 1, :, :] = blurred_tensor_channel
135 return blurred_tensor
138# BLOB POLLUTION SEGMENT (MVS) ###
139def generate_blob_mask_to_match_oar(
140 shape: tuple[int, int],
141 *,
142 radius: int = 4,
143 target_min_oar: float = 0.10,
144 target_max_oar: float = 0.30,
145 max_draw_attempts: int = 2_000,
146 rng: np.random.Generator | None = None,
147) -> tuple[np.ndarray, float]:
148 """Create a binary mask whose OAR lies in [target_min_oar, target_max_oar].
150 Keeps stamping discs until the lower bound is reached, skipping discs
151 that would push the OAR past ``target_max_oar``.
153 Args:
154 shape: (H, W) resolution of the mask.
155 radius: Intended radius of each disc in pixels.
156 target_min_oar: Lower bound of the desired OAR interval.
157 target_max_oar: Upper bound of the desired OAR interval.
158 max_draw_attempts: Safety cap on total centre-samples tried.
159 rng: Optional RNG for deterministic tests.
161 Returns:
162 Tuple of (mask, oar) where mask is a uint8 array and oar is
163 ``mask.mean()``.
165 Raises:
166 ValueError: If target_min_oar > target_max_oar.
167 RuntimeError: If max_draw_attempts is exceeded.
168 """
169 if target_min_oar > target_max_oar:
170 raise ValueError(f"Invalid OAR range: {target_min_oar} > {target_max_oar}")
172 rng = rng or np.random.default_rng()
173 H, W = shape
174 total_px = H * W
176 mask = np.zeros((H, W), dtype=np.uint8)
177 current_px = 0
179 Y, X = np.ogrid[:H, :W]
181 for _ in range(max_draw_attempts): 181 ↛ 208line 181 didn't jump to line 208 because the loop on line 181 didn't complete
182 current_oar = current_px / total_px
183 if current_oar >= target_min_oar:
184 break
186 remaining_px_max = target_max_oar * total_px - current_px
187 if remaining_px_max < 1: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 break
190 r_use = min(
191 radius,
192 math.floor(math.sqrt(remaining_px_max / math.pi)) or 1,
193 )
195 cx = rng.integers(0, W)
196 cy = rng.integers(0, H)
198 disc = (X - cx) ** 2 + (Y - cy) ** 2 <= r_use**2
199 new_pixels = disc & (mask == 0)
201 added = int(new_pixels.sum())
202 if added == 0: 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true
203 continue
205 mask[new_pixels] = 1
206 current_px += added
207 else:
208 raise RuntimeError(
209 "Exceeded max_draw_attempts while trying to reach the OAR band "
210 f"[{target_min_oar}, {target_max_oar}]. "
211 "Consider increasing `max_draw_attempts`, enlarging `radius`, "
212 "or relaxing the OAR constraints."
213 )
215 return mask, mask.mean()
218def add_blob_pollution_and_interp(
219 tensor: torch.Tensor,
220 *,
221 radius: int = 4,
222 target_min_oar: float = 0.10,
223 target_max_oar: float = 0.20,
224 interp_method: str = "linear",
225) -> tuple[torch.Tensor, np.ndarray, float]:
226 """Mask a (1, C, H, W) tensor with blob occlusion and in-paint the holes.
228 Args:
229 tensor: Input tensor of shape (1, C, H, W).
230 radius: Disc radius for blob generation.
231 target_min_oar: Lower OAR bound.
232 target_max_oar: Upper OAR bound.
233 interp_method: Interpolation method passed to scipy.griddata.
235 Returns:
236 Tuple of (tensor_filled, blob_mask, oar) where tensor_filled has NaNs
237 replaced by interpolated values, blob_mask is a (H, W) uint8 array,
238 and oar is the final Occluded Area Ratio.
240 Raises:
241 ValueError: If tensor shape is not (1, C, H, W).
242 RuntimeError: If fewer than 4 valid points remain for interpolation.
243 """
244 if tensor.ndim != 4 or tensor.size(0) != 1:
245 raise ValueError("Expected tensor shape (1, C, H, W).")
247 dev = tensor.device
248 _, C, H, W = tensor.shape
250 blob_mask, oar = generate_blob_mask_to_match_oar(
251 (H, W),
252 radius=radius,
253 target_min_oar=target_min_oar,
254 target_max_oar=target_max_oar,
255 )
256 blob_mask_t = torch.from_numpy(blob_mask).to(dev)
258 polluted = tensor.clone()
259 nan_val = torch.tensor(float("nan"), device=dev)
260 polluted[:, :, blob_mask_t.bool()] = nan_val
262 yy, xx = np.mgrid[0:H, 0:W]
263 xx_flat, yy_flat = xx.ravel(), yy.ravel()
265 filled = polluted.clone()
266 for c in range(C):
267 img = polluted[0, c].cpu().numpy()
268 valid = ~np.isnan(img)
269 n_valid = int(valid.sum())
271 if n_valid < 4:
272 raise RuntimeError(
273 f"Channel {c}: only {n_valid} valid points left; cannot interpolate."
274 )
276 pts = np.column_stack((xx_flat[valid.ravel()], yy_flat[valid.ravel()]))
277 vals = img[valid]
279 method = interp_method if n_valid >= 30 else "nearest"
280 interp = griddata(pts, vals, (xx, yy), method=method)
282 if np.isnan(interp).any():
283 nearest = griddata(pts, vals, (xx, yy), method="nearest")
284 interp = np.where(np.isnan(interp), nearest, interp)
286 filled[0, c] = torch.from_numpy(interp).to(dev)
288 return filled, blob_mask, oar
291def apply_per_image_pollution(
292 inputs: torch.Tensor,
293 pollution_ratio: float = 0.5,
294 pollution_type: str = "gaussian",
295 **kwargs: float | str,
296) -> torch.Tensor:
297 """Apply noise to a random subset of images in a batch.
299 Args:
300 inputs: Batch of input tensors (B, C, H, W).
301 pollution_ratio: Proportion of images to pollute.
302 pollution_type: One of 'gaussian', 'salt_pepper', 'gaussian_blur',
303 'blob', 'uniform'.
304 kwargs: Extra keyword arguments forwarded to the pollution function.
305 Values must be float, int, or str.
307 Returns:
308 Tensor with selected images polluted, same shape as inputs.
310 Raises:
311 ValueError: If pollution_type is not recognised.
312 """
313 polluted_inputs = inputs.clone()
314 batch_size = inputs.size(0)
315 num_polluted = int(batch_size * pollution_ratio)
317 indices_to_pollute = sorted(random.sample(range(batch_size), num_polluted))
319 for i in indices_to_pollute:
320 img = inputs[i].unsqueeze(0)
322 if pollution_type == "gaussian":
323 img = add_gaussian_noise(img, **kwargs) # ty: ignore[invalid-argument-type]
324 elif pollution_type == "salt_pepper":
325 img = add_salt_and_pepper_noise(img, **kwargs) # ty: ignore[invalid-argument-type]
326 elif pollution_type == "gaussian_blur":
327 img = apply_gaussian_blur(img, **kwargs) # ty: ignore[invalid-argument-type]
328 elif pollution_type == "blob":
329 img, _blob_mask, _oar = add_blob_pollution_and_interp(img, **kwargs) # ty: ignore[invalid-argument-type]
330 elif pollution_type == "uniform":
331 img = add_uniform_noise(img, **kwargs) # ty: ignore[invalid-argument-type]
332 else:
333 raise ValueError(f"Unknown pollution type: {pollution_type}")
335 polluted_inputs[i] = img.squeeze(0)
337 return polluted_inputs
340def enlarge_crack_tip(mask: np.ndarray, kernel_size: int = 3) -> np.ndarray:
341 """Enlarge the annotated crack tip region in a binary mask.
343 Expands each foreground pixel into a square neighbourhood of size
344 ``kernel_size x kernel_size``.
346 Args:
347 mask: 2D numpy array of shape (H, W) with foreground pixels encoded
348 as 255 and background as 0.
349 kernel_size: Size of the square neighbourhood. Must be an odd integer
350 (e.g. 1, 3, 5). Default is 3.
352 Returns:
353 New 2D mask of the same shape with the crack tip region enlarged.
354 """
355 pad = kernel_size // 2
356 height, width = mask.shape
357 new_mask = np.copy(mask)
358 y, x = np.where(mask == 255)
359 for i in range(len(x)):
360 y_start = max(y[i] - pad, 0)
361 y_end = min(y[i] + pad + 1, height)
362 x_start = max(x[i] - pad, 0)
363 x_end = min(x[i] + pad + 1, width)
364 new_mask[y_start:y_end, x_start:x_end] = 255
365 return new_mask