kuko6 commited on
Commit
eab7c24
·
1 Parent(s): 7bcce0f

Improved image cleaning

Browse files
Files changed (5) hide show
  1. README.md +7 -4
  2. app.py +14 -10
  3. cleaning.py +350 -13
  4. main.py +7 -4
  5. tests/test_preview_conversion.py +213 -0
README.md CHANGED
@@ -24,7 +24,8 @@ PNG, JPEG, TIFF, and TIF inputs are supported. Inpainted QC images are written
24
  to `out/cleaned/` with `_cleaned` added before the original extension, and
25
  debris-exclusion masks are written to `out/masks/` as PNG files. Preserve the
26
  original image and exclude masked pixels when quantifying DAB; do not quantify
27
- the synthetic pixels in the inpainted QC image.
 
28
 
29
  An optional Gradio interface is also available:
30
 
@@ -33,9 +34,11 @@ uv run python app.py
33
  ```
34
 
35
  Open the local URL printed in the terminal, upload an input image, and select
36
- **Create mask and QC preview**. TIFF inputs are percentile-scaled to PNG
37
- previews in the interface. The full-resolution mask and inpainted QC file can
38
- both be downloaded; the uploaded source image remains unchanged.
 
 
39
 
40
  For batch processing, open the **Image directory** tab and select a folder.
41
  The app processes all uploaded images, including `.tif` and `.tiff` files, and
 
24
  to `out/cleaned/` with `_cleaned` added before the original extension, and
25
  debris-exclusion masks are written to `out/masks/` as PNG files. Preserve the
26
  original image and exclude masked pixels when quantifying DAB; do not quantify
27
+ the synthetic pixels in the inpainted QC image. TIFF outputs use lossless LZW
28
+ compression.
29
 
30
  An optional Gradio interface is also available:
31
 
 
34
  ```
35
 
36
  Open the local URL printed in the terminal, upload an input image, and select
37
+ **Create mask and QC preview**. TIFF previews and cleaned QC downloads use a
38
+ full-range 8-bit conversion, matching the notebook's display conversion. Debris
39
+ detection retains its separate processing conversion. A conservative background
40
+ fit corrects the cleaned QC image only when it detects meaningful edge falloff;
41
+ the uploaded source image and debris mask remain unchanged.
42
 
43
  For batch processing, open the **Image directory** tab and select a folder.
44
  The app processes all uploaded images, including `.tif` and `.tiff` files, and
app.py CHANGED
@@ -8,10 +8,10 @@ import gradio as gr
8
  import numpy as np
9
 
10
  from cleaning import (
11
- clean_image,
12
- clean_image_and_mask,
13
  cleaned_image_name,
14
- read_image_rgb,
 
15
  write_image_rgb,
16
  )
17
 
@@ -60,7 +60,7 @@ def preview_image(file_path: str | Path | None):
60
  if not file_path:
61
  return None, None, None, None, None
62
 
63
- return _preview_image(read_image_rgb(file_path)), None, None, None, None
64
 
65
 
66
  def clean_uploaded_image(file_path: str | Path | None):
@@ -68,9 +68,12 @@ def clean_uploaded_image(file_path: str | Path | None):
68
  output_dir = Path(_BATCH_OUTPUTS.name) / uuid4().hex
69
  output_dir.mkdir(parents=True)
70
 
71
- image_rgb = read_image_rgb(image_path)
72
- cleaned_rgb, debris_mask = clean_image_and_mask(image_rgb)
73
- input_preview = _preview_image(image_rgb)
 
 
 
74
  preview_mask = debris_mask
75
  if preview_mask.shape != input_preview.shape[:2]:
76
  preview_mask = cv2.resize(
@@ -81,14 +84,14 @@ def clean_uploaded_image(file_path: str | Path | None):
81
  mask_outline = _mask_outline(input_preview, preview_mask)
82
  cleaned_path = output_dir / cleaned_image_name(image_path)
83
  mask_path = output_dir / f"{image_path.stem}_debris_mask.png"
84
- write_image_rgb(cleaned_path, cleaned_rgb)
85
  if not cv2.imwrite(str(mask_path), debris_mask):
86
  raise OSError(f"Could not write debris mask: {mask_path}")
87
 
88
  return (
89
  input_preview,
90
  mask_outline,
91
- _preview_image(cleaned_rgb),
92
  str(mask_path),
93
  str(cleaned_path),
94
  )
@@ -106,7 +109,8 @@ def clean_directory(image_paths: list[str] | None) -> tuple[str, str]:
106
 
107
  for image_path_string in image_paths:
108
  image_path = Path(image_path_string)
109
- cleaned_rgb = clean_image(read_image_rgb(image_path))
 
110
 
111
  output_name = cleaned_image_name(image_path, used_names)
112
  write_image_rgb(cleaned_dir / output_name, cleaned_rgb)
 
8
  import numpy as np
9
 
10
  from cleaning import (
11
+ clean_display_image_and_mask,
 
12
  cleaned_image_name,
13
+ read_image_preview_rgb,
14
+ read_image_rgb_and_preview,
15
  write_image_rgb,
16
  )
17
 
 
60
  if not file_path:
61
  return None, None, None, None, None
62
 
63
+ return _preview_image(read_image_preview_rgb(file_path)), None, None, None, None
64
 
65
 
66
  def clean_uploaded_image(file_path: str | Path | None):
 
68
  output_dir = Path(_BATCH_OUTPUTS.name) / uuid4().hex
69
  output_dir.mkdir(parents=True)
70
 
71
+ image_rgb, display_rgb = read_image_rgb_and_preview(image_path)
72
+ cleaned_display_rgb, debris_mask = clean_display_image_and_mask(
73
+ image_rgb,
74
+ display_rgb,
75
+ )
76
+ input_preview = _preview_image(display_rgb)
77
  preview_mask = debris_mask
78
  if preview_mask.shape != input_preview.shape[:2]:
79
  preview_mask = cv2.resize(
 
84
  mask_outline = _mask_outline(input_preview, preview_mask)
85
  cleaned_path = output_dir / cleaned_image_name(image_path)
86
  mask_path = output_dir / f"{image_path.stem}_debris_mask.png"
87
+ write_image_rgb(cleaned_path, cleaned_display_rgb)
88
  if not cv2.imwrite(str(mask_path), debris_mask):
89
  raise OSError(f"Could not write debris mask: {mask_path}")
90
 
91
  return (
92
  input_preview,
93
  mask_outline,
94
+ _preview_image(cleaned_display_rgb),
95
  str(mask_path),
96
  str(cleaned_path),
97
  )
 
109
 
110
  for image_path_string in image_paths:
111
  image_path = Path(image_path_string)
112
+ image_rgb, display_rgb = read_image_rgb_and_preview(image_path)
113
+ cleaned_rgb, _ = clean_display_image_and_mask(image_rgb, display_rgb)
114
 
115
  output_name = cleaned_image_name(image_path, used_names)
116
  write_image_rgb(cleaned_dir / output_name, cleaned_rgb)
cleaning.py CHANGED
@@ -2,6 +2,7 @@ from pathlib import Path
2
 
3
  import cv2
4
  import numpy as np
 
5
 
6
  TIFF_EXTENSIONS = {".tif", ".tiff"}
7
  DEBRIS_VALUE_MAX = 60
@@ -9,11 +10,19 @@ DEBRIS_SATURATION_MAX = 15
9
  DEBRIS_MASK_EXPANSION = 10
10
  INPAINT_RADIUS = 5
11
  PREVIEW_PERCENTILES = (1.0, 99.5)
12
-
13
-
14
- def _as_rgb_uint8(image: np.ndarray) -> np.ndarray:
 
 
 
 
 
 
 
 
 
15
  image = np.asarray(image)
16
- source_dtype = image.dtype
17
 
18
  if image.ndim == 3 and image.shape[2] == 1:
19
  image = image[:, :, 0]
@@ -28,6 +37,13 @@ def _as_rgb_uint8(image: np.ndarray) -> np.ndarray:
28
  elif image.shape[2] != 3:
29
  raise ValueError("Expected an image with 1, 3, or 4 channels.")
30
 
 
 
 
 
 
 
 
31
  if image.dtype != np.uint8:
32
  image = image.astype(np.float32)
33
  if image.size:
@@ -54,6 +70,26 @@ def _as_rgb_uint8(image: np.ndarray) -> np.ndarray:
54
  return image
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def _is_tiff_path(image_path: str | Path) -> bool:
58
  return Path(image_path).suffix.lower() in TIFF_EXTENSIONS
59
 
@@ -96,7 +132,7 @@ def _select_tiff_plane(image: np.ndarray) -> np.ndarray:
96
  raise ValueError("Expected a 2D grayscale or RGB TIFF image.")
97
 
98
 
99
- def read_image_rgb(image_path: str | Path) -> np.ndarray:
100
  image_path = Path(image_path)
101
 
102
  if _is_tiff_path(image_path):
@@ -108,7 +144,7 @@ def read_image_rgb(image_path: str | Path) -> np.ndarray:
108
  ) from exc
109
 
110
  image = tifffile.imread(image_path)
111
- return _as_rgb_uint8(_select_tiff_plane(image))
112
 
113
  image = cv2.imread(str(image_path), cv2.IMREAD_UNCHANGED)
114
  if image is None:
@@ -119,7 +155,23 @@ def read_image_rgb(image_path: str | Path) -> np.ndarray:
119
  elif image.ndim == 3 and image.shape[2] == 4:
120
  image = cv2.cvtColor(image, cv2.COLOR_BGRA2RGBA)
121
 
122
- return _as_rgb_uint8(image)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
 
125
  def write_image_rgb(image_path: str | Path, image: np.ndarray) -> None:
@@ -134,7 +186,13 @@ def write_image_rgb(image_path: str | Path, image: np.ndarray) -> None:
134
  "Writing TIFF images requires the tifffile package."
135
  ) from exc
136
 
137
- tifffile.imwrite(image_path, image)
 
 
 
 
 
 
138
  return
139
 
140
  image_bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
@@ -179,11 +237,273 @@ def create_debris_mask(image: np.ndarray) -> np.ndarray:
179
  return cv2.dilate(filled_mask, expansion_kernel)
180
 
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  def clean_image(image: np.ndarray | None) -> np.ndarray:
183
  cleaned_image, _ = clean_image_and_mask(image)
184
  return cleaned_image
185
 
186
 
 
 
 
 
 
 
 
 
 
187
  def clean_image_and_mask(
188
  image: np.ndarray | None,
189
  ) -> tuple[np.ndarray, np.ndarray]:
@@ -192,10 +512,27 @@ def clean_image_and_mask(
192
 
193
  image = _as_rgb_uint8(image)
194
  debris_mask = create_debris_mask(image)
195
- cleaned_image = cv2.inpaint(
196
- image,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  debris_mask,
198
- INPAINT_RADIUS,
199
- cv2.INPAINT_TELEA,
200
  )
201
- return cleaned_image, debris_mask
 
2
 
3
  import cv2
4
  import numpy as np
5
+ from skimage.util import img_as_ubyte
6
 
7
  TIFF_EXTENSIONS = {".tif", ".tiff"}
8
  DEBRIS_VALUE_MAX = 60
 
10
  DEBRIS_MASK_EXPANSION = 10
11
  INPAINT_RADIUS = 5
12
  PREVIEW_PERCENTILES = (1.0, 99.5)
13
+ VIGNETTING_SATURATION_MAX = 30
14
+ VIGNETTING_VALUE_MIN = 80
15
+ VIGNETTING_MIN_FALLOFF = 0.03
16
+ VIGNETTING_MAX_GAIN = 1.60
17
+ VIGNETTING_FIT_MAX_DIMENSION = 800
18
+ VIGNETTING_CORRECTION_ROWS = 256
19
+ VIGNETTING_TILE_SIZE = 16
20
+ VIGNETTING_TILE_PERCENTILE = 50
21
+ VIGNETTING_SURFACE_BLUR_SIGMA = 0.8
22
+
23
+
24
+ def _as_rgb_array(image: np.ndarray) -> np.ndarray:
25
  image = np.asarray(image)
 
26
 
27
  if image.ndim == 3 and image.shape[2] == 1:
28
  image = image[:, :, 0]
 
37
  elif image.shape[2] != 3:
38
  raise ValueError("Expected an image with 1, 3, or 4 channels.")
39
 
40
+ return image
41
+
42
+
43
+ def _as_rgb_uint8(image: np.ndarray) -> np.ndarray:
44
+ source_dtype = np.asarray(image).dtype
45
+ image = _as_rgb_array(image)
46
+
47
  if image.dtype != np.uint8:
48
  image = image.astype(np.float32)
49
  if image.size:
 
70
  return image
71
 
72
 
73
+ def _as_display_rgb_uint8(image: np.ndarray) -> np.ndarray:
74
+ """Convert an RGB image for display without changing its encoded contrast."""
75
+ image = _as_rgb_array(image)
76
+
77
+ if image.dtype == np.uint8:
78
+ return image
79
+
80
+ if np.issubdtype(image.dtype, np.integer) or image.dtype == np.bool_:
81
+ return img_as_ubyte(image)
82
+
83
+ finite_values = image[np.isfinite(image)]
84
+ if not finite_values.size:
85
+ raise ValueError("Image contains no finite pixel values.")
86
+ if 0 <= np.min(finite_values) and np.max(finite_values) <= 1:
87
+ finite_image = np.nan_to_num(image, nan=0, posinf=1, neginf=0)
88
+ return img_as_ubyte(finite_image)
89
+
90
+ return _as_rgb_uint8(image)
91
+
92
+
93
  def _is_tiff_path(image_path: str | Path) -> bool:
94
  return Path(image_path).suffix.lower() in TIFF_EXTENSIONS
95
 
 
132
  raise ValueError("Expected a 2D grayscale or RGB TIFF image.")
133
 
134
 
135
+ def _read_image_rgb_values(image_path: str | Path) -> np.ndarray:
136
  image_path = Path(image_path)
137
 
138
  if _is_tiff_path(image_path):
 
144
  ) from exc
145
 
146
  image = tifffile.imread(image_path)
147
+ return _select_tiff_plane(image)
148
 
149
  image = cv2.imread(str(image_path), cv2.IMREAD_UNCHANGED)
150
  if image is None:
 
155
  elif image.ndim == 3 and image.shape[2] == 4:
156
  image = cv2.cvtColor(image, cv2.COLOR_BGRA2RGBA)
157
 
158
+ return image
159
+
160
+
161
+ def read_image_rgb(image_path: str | Path) -> np.ndarray:
162
+ return _as_rgb_uint8(_read_image_rgb_values(image_path))
163
+
164
+
165
+ def read_image_preview_rgb(image_path: str | Path) -> np.ndarray:
166
+ return _as_display_rgb_uint8(_read_image_rgb_values(image_path))
167
+
168
+
169
+ def read_image_rgb_and_preview(
170
+ image_path: str | Path,
171
+ ) -> tuple[np.ndarray, np.ndarray]:
172
+ """Return the processing image and its independent display preview."""
173
+ image = _read_image_rgb_values(image_path)
174
+ return _as_rgb_uint8(image), _as_display_rgb_uint8(image)
175
 
176
 
177
  def write_image_rgb(image_path: str | Path, image: np.ndarray) -> None:
 
186
  "Writing TIFF images requires the tifffile package."
187
  ) from exc
188
 
189
+ tifffile.imwrite(
190
+ image_path,
191
+ image,
192
+ photometric="rgb",
193
+ compression="lzw",
194
+ predictor=True,
195
+ )
196
  return
197
 
198
  image_bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
 
237
  return cv2.dilate(filled_mask, expansion_kernel)
238
 
239
 
240
+ def _estimate_vignetting_surface(
241
+ image: np.ndarray,
242
+ background_mask: np.ndarray,
243
+ ) -> np.ndarray | None:
244
+ """Estimate local slide-background colour on a small spatial grid."""
245
+ if np.count_nonzero(background_mask) < 1_000:
246
+ return None
247
+
248
+ height, width = background_mask.shape
249
+ grid_height = max(2, int(np.ceil(height / VIGNETTING_TILE_SIZE)))
250
+ grid_width = max(2, int(np.ceil(width / VIGNETTING_TILE_SIZE)))
251
+ surface = np.full((grid_height, grid_width, 3), np.nan, dtype=np.float32)
252
+
253
+ for grid_y in range(grid_height):
254
+ y_start = grid_y * height // grid_height
255
+ y_stop = (grid_y + 1) * height // grid_height
256
+ for grid_x in range(grid_width):
257
+ x_start = grid_x * width // grid_width
258
+ x_stop = (grid_x + 1) * width // grid_width
259
+ tile_mask = background_mask[y_start:y_stop, x_start:x_stop]
260
+ if np.count_nonzero(tile_mask) < 16:
261
+ continue
262
+ pixels = image[y_start:y_stop, x_start:x_stop][tile_mask]
263
+ surface[grid_y, grid_x] = np.percentile(
264
+ pixels,
265
+ VIGNETTING_TILE_PERCENTILE,
266
+ axis=0,
267
+ )
268
+
269
+ missing = np.isnan(surface[..., 0])
270
+ if np.all(missing):
271
+ return None
272
+
273
+ for channel in range(3):
274
+ channel_surface = surface[..., channel]
275
+ if np.any(missing):
276
+ channel_surface = cv2.inpaint(
277
+ np.nan_to_num(channel_surface, nan=0).astype(np.float32),
278
+ missing.astype(np.uint8),
279
+ 3,
280
+ cv2.INPAINT_TELEA,
281
+ )
282
+ surface[..., channel] = cv2.GaussianBlur(
283
+ channel_surface,
284
+ (0, 0),
285
+ sigmaX=VIGNETTING_SURFACE_BLUR_SIGMA,
286
+ sigmaY=VIGNETTING_SURFACE_BLUR_SIGMA,
287
+ borderType=cv2.BORDER_REPLICATE,
288
+ )
289
+
290
+ return surface / 255
291
+
292
+
293
+ def _largest_connected_region(mask: np.ndarray) -> np.ndarray | None:
294
+ component_count, labels, stats, _ = cv2.connectedComponentsWithStats(
295
+ mask.astype(np.uint8),
296
+ connectivity=8,
297
+ )
298
+ if component_count <= 1:
299
+ return None
300
+
301
+ largest_component = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA])
302
+ region = labels == largest_component
303
+ if np.count_nonzero(region) < 1_000:
304
+ return None
305
+ return region
306
+
307
+
308
+ def create_tissue_mask(
309
+ image: np.ndarray,
310
+ debris_mask: np.ndarray | None = None,
311
+ ) -> np.ndarray:
312
+ """Return the established spheroid segmentation as a filled binary mask."""
313
+ # Imported here to avoid a module-import cycle: the quantification module
314
+ # reuses cleaning helpers before defining its segmentation function.
315
+ from ihc_quantification_simplified import segment_spheroids
316
+
317
+ image = _as_display_rgb_uint8(image)
318
+ segmentation_image = image
319
+ if debris_mask is not None:
320
+ if debris_mask.shape != image.shape[:2]:
321
+ raise ValueError("Debris mask must match the image height and width.")
322
+ segmentation_image = _inpaint_image(
323
+ segmentation_image,
324
+ debris_mask,
325
+ )
326
+
327
+ return (
328
+ (segment_spheroids(segmentation_image) > 0).astype(np.uint8) * 255
329
+ )
330
+
331
+
332
+ def _extend_surface_to_image_edges(surface: np.ndarray) -> np.ndarray:
333
+ """Linearly extrapolate tile-centre estimates to the image boundaries."""
334
+ extended = np.empty(
335
+ (surface.shape[0] + 2, surface.shape[1] + 2, 3),
336
+ dtype=np.float32,
337
+ )
338
+ extended[1:-1, 1:-1] = surface
339
+ extended[0, 1:-1] = 2 * surface[0] - surface[1]
340
+ extended[-1, 1:-1] = 2 * surface[-1] - surface[-2]
341
+ extended[:, 0] = 2 * extended[:, 1] - extended[:, 2]
342
+ extended[:, -1] = 2 * extended[:, -2] - extended[:, -3]
343
+ return np.clip(extended, 0.05, 1.5)
344
+
345
+
346
+ def correct_vignetting(
347
+ image: np.ndarray,
348
+ debris_mask: np.ndarray | None = None,
349
+ tissue_mask: np.ndarray | None = None,
350
+ ) -> tuple[np.ndarray, bool]:
351
+ """Correct smooth edge falloff when a robust background fit detects it."""
352
+ image = _as_display_rgb_uint8(image)
353
+ height, width = image.shape[:2]
354
+ scale = min(1, VIGNETTING_FIT_MAX_DIMENSION / max(height, width))
355
+ if scale < 1:
356
+ fit_size = (round(width * scale), round(height * scale))
357
+ fit_image = cv2.resize(image, fit_size, interpolation=cv2.INTER_AREA)
358
+ else:
359
+ fit_image = image
360
+
361
+ hsv = cv2.cvtColor(fit_image, cv2.COLOR_RGB2HSV)
362
+ background_mask = (
363
+ (hsv[..., 1] <= VIGNETTING_SATURATION_MAX)
364
+ & (hsv[..., 2] >= VIGNETTING_VALUE_MIN)
365
+ )
366
+ if debris_mask is not None:
367
+ if debris_mask.shape != image.shape[:2]:
368
+ raise ValueError("Debris mask must match the image height and width.")
369
+ fit_mask = debris_mask
370
+ if scale < 1:
371
+ fit_mask = cv2.resize(
372
+ debris_mask,
373
+ fit_size,
374
+ interpolation=cv2.INTER_NEAREST,
375
+ )
376
+ background_mask &= fit_mask == 0
377
+
378
+ background_region = _largest_connected_region(background_mask)
379
+ if background_region is None:
380
+ return image, False
381
+
382
+ surface = _estimate_vignetting_surface(
383
+ fit_image,
384
+ background_region,
385
+ )
386
+ if surface is None:
387
+ return image, False
388
+
389
+ grid_y, grid_x = np.meshgrid(
390
+ np.linspace(-1, 1, surface.shape[0]),
391
+ np.linspace(-1, 1, surface.shape[1]),
392
+ indexing="ij",
393
+ )
394
+ grid_luminance = surface @ np.array([0.2126, 0.7152, 0.0722])
395
+ grid_radius = np.maximum(np.abs(grid_x), np.abs(grid_y))
396
+ center_region = grid_radius <= 0.35
397
+ edge_region = grid_radius >= 0.8
398
+ center_level = float(np.median(grid_luminance[center_region]))
399
+ edge_level = float(np.percentile(grid_luminance[edge_region], 20))
400
+ if center_level <= np.finfo(np.float64).eps:
401
+ return image, False
402
+
403
+ falloff = 1 - edge_level / center_level
404
+ if falloff < VIGNETTING_MIN_FALLOFF:
405
+ return image, False
406
+
407
+ references = np.percentile(
408
+ fit_image[background_region].astype(np.float32) / 255,
409
+ 90,
410
+ axis=0,
411
+ )
412
+ extended_surface = _extend_surface_to_image_edges(surface)
413
+ corrected = image.copy()
414
+ correction_region = cv2.resize(
415
+ background_region.astype(np.uint8),
416
+ (width, height),
417
+ interpolation=cv2.INTER_NEAREST,
418
+ ).astype(bool)
419
+ full_hsv = cv2.cvtColor(image, cv2.COLOR_RGB2HSV)
420
+ correction_region &= (
421
+ (full_hsv[..., 1] <= VIGNETTING_SATURATION_MAX)
422
+ & (full_hsv[..., 2] >= VIGNETTING_VALUE_MIN)
423
+ )
424
+ if tissue_mask is None:
425
+ tissue_mask = create_tissue_mask(image, debris_mask)
426
+ elif tissue_mask.shape != image.shape[:2]:
427
+ raise ValueError("Tissue mask must match the image height and width.")
428
+ correction_region &= tissue_mask == 0
429
+ if debris_mask is not None:
430
+ correction_region &= debris_mask == 0
431
+ surface_y = np.clip(
432
+ (np.arange(height, dtype=np.float32) + 0.5)
433
+ * surface.shape[0]
434
+ / height
435
+ + 0.5,
436
+ 0,
437
+ extended_surface.shape[0] - 1,
438
+ )
439
+ surface_x = np.clip(
440
+ (np.arange(width, dtype=np.float32) + 0.5)
441
+ * surface.shape[1]
442
+ / width
443
+ + 0.5,
444
+ 0,
445
+ extended_surface.shape[1] - 1,
446
+ )
447
+ source_x = np.arange(extended_surface.shape[1], dtype=np.float32)
448
+
449
+ for channel in range(3):
450
+ horizontal_surface = np.vstack(
451
+ [
452
+ np.interp(surface_x, source_x, row)
453
+ for row in extended_surface[..., channel]
454
+ ]
455
+ ).astype(np.float32)
456
+ for row_start in range(0, height, VIGNETTING_CORRECTION_ROWS):
457
+ row_stop = min(row_start + VIGNETTING_CORRECTION_ROWS, height)
458
+ y = surface_y[row_start:row_stop]
459
+ y_low = np.floor(y).astype(np.int32)
460
+ y_high = np.minimum(y_low + 1, extended_surface.shape[0] - 1)
461
+ y_fraction = (y - y_low)[:, None]
462
+ strip_surface = (
463
+ horizontal_surface[y_low] * (1 - y_fraction)
464
+ + horizontal_surface[y_high] * y_fraction
465
+ )
466
+ strip_surface = np.clip(strip_surface, 0.05, 1.5)
467
+ gain = np.clip(
468
+ references[channel] / strip_surface,
469
+ 1,
470
+ VIGNETTING_MAX_GAIN,
471
+ )
472
+ corrected_channel = corrected[
473
+ row_start:row_stop,
474
+ :,
475
+ channel,
476
+ ].astype(np.float32)
477
+ corrected_values = np.clip(
478
+ np.rint(corrected_channel * gain),
479
+ 0,
480
+ 255,
481
+ ).astype(np.uint8)
482
+ strip_region = correction_region[row_start:row_stop]
483
+ corrected_channel = corrected[
484
+ row_start:row_stop,
485
+ :,
486
+ channel,
487
+ ]
488
+ corrected_channel[strip_region] = corrected_values[strip_region]
489
+
490
+ return corrected, True
491
+
492
+
493
  def clean_image(image: np.ndarray | None) -> np.ndarray:
494
  cleaned_image, _ = clean_image_and_mask(image)
495
  return cleaned_image
496
 
497
 
498
+ def _inpaint_image(image: np.ndarray, debris_mask: np.ndarray) -> np.ndarray:
499
+ return cv2.inpaint(
500
+ image,
501
+ debris_mask,
502
+ INPAINT_RADIUS,
503
+ cv2.INPAINT_TELEA,
504
+ )
505
+
506
+
507
  def clean_image_and_mask(
508
  image: np.ndarray | None,
509
  ) -> tuple[np.ndarray, np.ndarray]:
 
512
 
513
  image = _as_rgb_uint8(image)
514
  debris_mask = create_debris_mask(image)
515
+ cleaned_image = _inpaint_image(image, debris_mask)
516
+ return cleaned_image, debris_mask
517
+
518
+
519
+ def clean_display_image_and_mask(
520
+ processing_image: np.ndarray | None,
521
+ display_image: np.ndarray | None,
522
+ ) -> tuple[np.ndarray, np.ndarray]:
523
+ if processing_image is None or display_image is None:
524
+ raise ValueError("Processing and display images are required.")
525
+
526
+ processing_image = _as_rgb_uint8(processing_image)
527
+ display_image = _as_display_rgb_uint8(display_image)
528
+ if processing_image.shape != display_image.shape:
529
+ raise ValueError("Processing and display images must have the same shape.")
530
+
531
+ debris_mask = create_debris_mask(processing_image)
532
+ tissue_mask = create_tissue_mask(processing_image, debris_mask)
533
+ corrected_display, _ = correct_vignetting(
534
+ display_image,
535
  debris_mask,
536
+ tissue_mask,
 
537
  )
538
+ return _inpaint_image(corrected_display, debris_mask), debris_mask
main.py CHANGED
@@ -4,9 +4,9 @@ from pathlib import Path
4
  import cv2
5
 
6
  from cleaning import (
7
- clean_image_and_mask,
8
  cleaned_image_name,
9
- read_image_rgb,
10
  write_image_rgb,
11
  )
12
 
@@ -23,8 +23,11 @@ def main(
23
 
24
  for image_path_string in image_paths:
25
  image_path = Path(image_path_string)
26
- image_rgb = read_image_rgb(image_path)
27
- cleaned_rgb, debris_mask = clean_image_and_mask(image_rgb)
 
 
 
28
 
29
  cv2.imwrite(str(masks_dir / f"{image_path.stem}_mask.png"), debris_mask)
30
  cleaned_path = cleaned_dir / cleaned_image_name(image_path)
 
4
  import cv2
5
 
6
  from cleaning import (
7
+ clean_display_image_and_mask,
8
  cleaned_image_name,
9
+ read_image_rgb_and_preview,
10
  write_image_rgb,
11
  )
12
 
 
23
 
24
  for image_path_string in image_paths:
25
  image_path = Path(image_path_string)
26
+ image_rgb, display_rgb = read_image_rgb_and_preview(image_path)
27
+ cleaned_rgb, debris_mask = clean_display_image_and_mask(
28
+ image_rgb,
29
+ display_rgb,
30
+ )
31
 
32
  cv2.imwrite(str(masks_dir / f"{image_path.stem}_mask.png"), debris_mask)
33
  cleaned_path = cleaned_dir / cleaned_image_name(image_path)
tests/test_preview_conversion.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import tempfile
2
+ import unittest
3
+ from pathlib import Path
4
+
5
+ import cv2
6
+ import numpy as np
7
+ import tifffile
8
+ from skimage.util import img_as_ubyte
9
+
10
+ from cleaning import (
11
+ INPAINT_RADIUS,
12
+ _as_display_rgb_uint8,
13
+ _as_rgb_uint8,
14
+ clean_display_image_and_mask,
15
+ correct_vignetting,
16
+ create_debris_mask,
17
+ create_tissue_mask,
18
+ read_image_rgb_and_preview,
19
+ write_image_rgb,
20
+ )
21
+
22
+
23
+ class PreviewConversionTests(unittest.TestCase):
24
+ def test_uint16_preview_uses_full_dtype_range(self):
25
+ image = np.array([[0, 32768, 65535]], dtype=np.uint16)
26
+
27
+ preview = _as_display_rgb_uint8(image)
28
+
29
+ expected = np.array([0, 128, 255], dtype=np.uint8)
30
+ np.testing.assert_array_equal(preview[:, :, 0], expected[None, :])
31
+ np.testing.assert_array_equal(preview[:, :, 1], expected[None, :])
32
+ np.testing.assert_array_equal(preview[:, :, 2], expected[None, :])
33
+
34
+ def test_uint8_preview_is_unchanged(self):
35
+ image = np.array([[[10, 120, 240]]], dtype=np.uint8)
36
+
37
+ preview = _as_display_rgb_uint8(image)
38
+
39
+ np.testing.assert_array_equal(preview, image)
40
+
41
+ def test_reader_returns_separate_processing_and_display_views(self):
42
+ image = np.full((10, 10, 3), 32768, dtype=np.uint16)
43
+ image[0, 0] = 0
44
+ image[-1, -1] = 65535
45
+
46
+ with tempfile.TemporaryDirectory() as directory:
47
+ image_path = Path(directory) / "image.tiff"
48
+ tifffile.imwrite(image_path, image, photometric="rgb")
49
+
50
+ processing, display = read_image_rgb_and_preview(image_path)
51
+
52
+ np.testing.assert_array_equal(processing, _as_rgb_uint8(image))
53
+ np.testing.assert_array_equal(display, img_as_ubyte(image))
54
+
55
+ def test_cleaned_output_uses_display_brightness_and_processing_mask(self):
56
+ raw = np.full((32, 32, 3), 32768, dtype=np.uint16)
57
+ raw[12:20, 12:20] = 0
58
+ processing = _as_rgb_uint8(raw)
59
+ display = _as_display_rgb_uint8(raw)
60
+
61
+ cleaned, debris_mask = clean_display_image_and_mask(
62
+ processing,
63
+ display,
64
+ )
65
+
66
+ expected_mask = create_debris_mask(processing)
67
+ expected_cleaned = cv2.inpaint(
68
+ display,
69
+ expected_mask,
70
+ INPAINT_RADIUS,
71
+ cv2.INPAINT_TELEA,
72
+ )
73
+ np.testing.assert_array_equal(debris_mask, expected_mask)
74
+ np.testing.assert_array_equal(cleaned, expected_cleaned)
75
+
76
+ def test_vignetting_correction_flattens_detected_edge_falloff(self):
77
+ size = 160
78
+ axis = np.linspace(-1, 1, size, dtype=np.float32)
79
+ x, y = np.meshgrid(axis, axis)
80
+ illumination = 1 - 0.18 * (x * x + y * y)
81
+ base_color = np.array([230, 220, 210], dtype=np.float32)
82
+ image = np.clip(
83
+ illumination[..., None] * base_color,
84
+ 0,
85
+ 255,
86
+ ).astype(np.uint8)
87
+
88
+ corrected, was_corrected = correct_vignetting(image)
89
+
90
+ center = np.s_[60:100, 60:100]
91
+ border = np.zeros((size, size), dtype=bool)
92
+ border[:20] = True
93
+ border[-20:] = True
94
+ border[:, :20] = True
95
+ border[:, -20:] = True
96
+ before_gap = image[center].mean() - image[border].mean()
97
+ after_gap = corrected[center].mean() - corrected[border].mean()
98
+ self.assertTrue(was_corrected)
99
+ self.assertLess(abs(after_gap), abs(before_gap) * 0.25)
100
+
101
+ def test_uniform_image_skips_vignetting_correction(self):
102
+ image = np.full((128, 128, 3), 220, dtype=np.uint8)
103
+
104
+ corrected, was_corrected = correct_vignetting(image)
105
+
106
+ self.assertFalse(was_corrected)
107
+ np.testing.assert_array_equal(corrected, image)
108
+
109
+ def test_vignetting_correction_handles_steep_one_sided_falloff(self):
110
+ height, width = 180, 240
111
+ x = np.linspace(0, 1, width, dtype=np.float32)
112
+ illumination = 1 - 0.35 * np.clip((x - 0.72) / 0.28, 0, 1)
113
+ base_color = np.array([245, 240, 235], dtype=np.float32)
114
+ image = np.clip(
115
+ illumination[None, :, None] * base_color,
116
+ 0,
117
+ 255,
118
+ ).astype(np.uint8)
119
+ image = np.repeat(image, height, axis=0)
120
+
121
+ corrected, was_corrected = correct_vignetting(image)
122
+
123
+ center_level = image[:, 80:140].mean()
124
+ edge_level = image[:, -12:].mean()
125
+ corrected_center_level = corrected[:, 80:140].mean()
126
+ corrected_edge_level = corrected[:, -12:].mean()
127
+ before_gap = center_level - edge_level
128
+ after_gap = corrected_center_level - corrected_edge_level
129
+ self.assertTrue(was_corrected)
130
+ self.assertLess(after_gap, before_gap * 0.15)
131
+
132
+ def test_vignetting_correction_preserves_stained_tissue(self):
133
+ height, width = 180, 240
134
+ x = np.linspace(0, 1, width, dtype=np.float32)
135
+ illumination = 1 - 0.30 * np.clip((x - 0.65) / 0.35, 0, 1)
136
+ background_color = np.array([245, 240, 235], dtype=np.float32)
137
+ image = np.clip(
138
+ illumination[None, :, None] * background_color,
139
+ 0,
140
+ 255,
141
+ ).astype(np.uint8)
142
+ image = np.repeat(image, height, axis=0)
143
+ tissue_mask = np.zeros((height, width), dtype=np.uint8)
144
+ cv2.circle(tissue_mask, (190, 90), 35, 255, cv2.FILLED)
145
+ tissue_color = np.array([155, 90, 110], dtype=np.uint8)
146
+ image[tissue_mask > 0] = tissue_color
147
+
148
+ corrected, was_corrected = correct_vignetting(image)
149
+
150
+ self.assertTrue(was_corrected)
151
+ np.testing.assert_array_equal(
152
+ corrected[tissue_mask > 0],
153
+ image[tissue_mask > 0],
154
+ )
155
+ self.assertGreater(
156
+ corrected[:, -12:][tissue_mask[:, -12:] == 0].mean(),
157
+ image[:, -12:][tissue_mask[:, -12:] == 0].mean() + 40,
158
+ )
159
+
160
+ def test_tissue_mask_fills_and_preserves_pale_spheroid_interior(self):
161
+ height, width = 240, 320
162
+ x = np.linspace(0, 1, width, dtype=np.float32)
163
+ illumination = 1 - 0.30 * np.clip((x - 0.65) / 0.35, 0, 1)
164
+ background_color = np.array([245, 240, 235], dtype=np.float32)
165
+ image = np.clip(
166
+ illumination[None, :, None] * background_color,
167
+ 0,
168
+ 255,
169
+ ).astype(np.uint8)
170
+ image = np.repeat(image, height, axis=0)
171
+
172
+ spheroid = np.zeros((height, width), dtype=np.uint8)
173
+ cv2.circle(spheroid, (250, 120), 55, 255, cv2.FILLED)
174
+ image[spheroid > 0] = [220, 205, 205]
175
+ cv2.circle(image, (250, 120), 55, (150, 85, 105), 10)
176
+ image[110:130, 240:260] = 0
177
+
178
+ tissue_mask = create_tissue_mask(image)
179
+ corrected, was_corrected = correct_vignetting(image)
180
+ cleaned, debris_mask = clean_display_image_and_mask(image, image)
181
+
182
+ self.assertTrue(was_corrected)
183
+ self.assertEqual(tissue_mask[120, 250], 255)
184
+ np.testing.assert_array_equal(
185
+ corrected[spheroid > 0],
186
+ image[spheroid > 0],
187
+ )
188
+ self.assertTrue(np.any(debris_mask[110:130, 240:260]))
189
+ self.assertFalse(
190
+ np.array_equal(
191
+ cleaned[110:130, 240:260],
192
+ image[110:130, 240:260],
193
+ )
194
+ )
195
+
196
+ def test_tiff_output_uses_lossless_lzw_compression(self):
197
+ image = np.full((128, 128, 3), 127, dtype=np.uint8)
198
+
199
+ with tempfile.TemporaryDirectory() as directory:
200
+ image_path = Path(directory) / "cleaned.tiff"
201
+ write_image_rgb(image_path, image)
202
+
203
+ with tifffile.TiffFile(image_path) as tiff:
204
+ page = tiff.pages[0]
205
+ self.assertEqual(page.compression.name, "LZW")
206
+ self.assertEqual(page.tags["Predictor"].value, 2)
207
+ np.testing.assert_array_equal(page.asarray(), image)
208
+
209
+ self.assertLess(image_path.stat().st_size, image.nbytes)
210
+
211
+
212
+ if __name__ == "__main__":
213
+ unittest.main()