isoview 0.1.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.
isoview/masks.py ADDED
@@ -0,0 +1,421 @@
1
+ """Binary mask creation from unsegmented data for fusion."""
2
+
3
+ import numpy as np
4
+ from scipy import ndimage
5
+ from skimage.morphology import remove_small_objects, disk
6
+
7
+ from .corrections import percentile_interp
8
+
9
+
10
+ def create_fusion_mask(
11
+ volume: np.ndarray,
12
+ percentile: float = 5.0,
13
+ mask_factor: float = 1.0,
14
+ min_size: int | None = None,
15
+ morphology_iterations: int = 1
16
+ ) -> np.ndarray:
17
+ """
18
+ Create binary mask from unsegmented volume.
19
+
20
+ Args:
21
+ volume: input volume (Z, Y, X)
22
+ percentile: percentile for background estimation (default: 5.0)
23
+ mask_factor: threshold factor (default: 1.0)
24
+ threshold = min + mask_factor * (mean - min)
25
+ min_size: minimum object size (voxels), None = auto (0.1% of volume)
26
+ morphology_iterations: closing iterations for cleanup (default: 1)
27
+
28
+ Returns:
29
+ binary mask (Z, Y, X)
30
+
31
+ Algorithm:
32
+ 1. Estimate minimum intensity using percentile (avoid true min)
33
+ 2. Compute mean of values above minimum
34
+ 3. Threshold at: min + mask_factor * (mean - min)
35
+ 4. Apply morphological closing to fill holes
36
+ 5. Remove small objects
37
+
38
+ Notes:
39
+ - percentile=5 is typical for noisy data
40
+ - mask_factor=1.0 means threshold at mean
41
+ - mask_factor=0.5 means threshold halfway between min and mean
42
+ """
43
+ # Estimate minimum using percentile to avoid outliers
44
+ min_intensity = percentile_interp(volume.ravel().astype(np.float64), percentile)
45
+
46
+ # Compute mean of values above minimum
47
+ above_min = volume[volume > min_intensity]
48
+ if len(above_min) > 0:
49
+ mean_intensity = above_min.mean()
50
+ else:
51
+ mean_intensity = volume.mean()
52
+
53
+ # Threshold
54
+ threshold = min_intensity + mask_factor * (mean_intensity - min_intensity)
55
+ mask = volume > threshold
56
+
57
+ # Morphological closing to fill small holes
58
+ if morphology_iterations > 0:
59
+ structure = ndimage.generate_binary_structure(3, 1) # 3D connectivity
60
+ mask = ndimage.binary_closing(
61
+ mask,
62
+ structure=structure,
63
+ iterations=morphology_iterations
64
+ )
65
+
66
+ # Remove small objects
67
+ if min_size is None:
68
+ # Auto: 0.1% of volume
69
+ min_size = int(0.001 * volume.size)
70
+
71
+ if min_size > 0:
72
+ # Label connected components
73
+ labeled, num_features = ndimage.label(mask)
74
+
75
+ # Compute sizes
76
+ sizes = ndimage.sum(mask, labeled, range(1, num_features + 1))
77
+
78
+ # Remove small components
79
+ mask_sizes = sizes < min_size
80
+ remove_indices = mask_sizes.nonzero()[0] + 1 # +1 because labels start at 1
81
+ mask[np.isin(labeled, remove_indices)] = 0
82
+
83
+ return mask.astype(np.uint8)
84
+
85
+
86
+ def create_slice_mask(
87
+ volume: np.ndarray,
88
+ binary_mask: np.ndarray
89
+ ) -> np.ndarray:
90
+ """
91
+ Create slice topology mask from binary 3D mask for camera fusion.
92
+
93
+ Args:
94
+ volume: input volume (Z, Y, X)
95
+ binary_mask: binary mask (Z, Y, X)
96
+
97
+ Returns:
98
+ slice mask (Y, X) containing Z-indices of average mask position
99
+
100
+ Algorithm:
101
+ For each (y, x) pixel:
102
+ 1. Extract Z-profile of binary mask
103
+ 2. Compute weighted average Z-position
104
+ 3. Store in (y, x) array
105
+
106
+ Notes:
107
+ This creates the transition plane topology used for adaptive blending.
108
+ For camera fusion: blending along Z, mask is (Y, X) with Z-values.
109
+ Returns 0 where no mask exists.
110
+ """
111
+ Z, Y, X = volume.shape
112
+ slice_mask = np.zeros((Y, X), dtype=np.uint16)
113
+
114
+ for y in range(Y):
115
+ for x in range(X):
116
+ # get Z-profile of mask at this (y, x)
117
+ z_profile = binary_mask[:, y, x]
118
+
119
+ if z_profile.sum() == 0:
120
+ # no mask at this (y, x)
121
+ slice_mask[y, x] = 0
122
+ else:
123
+ # compute weighted average Z-position
124
+ z_indices = np.arange(Z)
125
+ weighted_z = (z_indices * z_profile).sum() / z_profile.sum()
126
+ slice_mask[y, x] = int(round(weighted_z))
127
+
128
+ return slice_mask
129
+
130
+
131
+ def create_channel_slice_mask(
132
+ volume: np.ndarray,
133
+ binary_mask: np.ndarray
134
+ ) -> np.ndarray:
135
+ """
136
+ Create slice topology mask from binary 3D mask for channel fusion.
137
+
138
+ Args:
139
+ volume: input volume (Z, Y, X)
140
+ binary_mask: binary mask (Z, Y, X)
141
+
142
+ Returns:
143
+ slice mask (X, Z) containing Y-indices of average mask position
144
+
145
+ Algorithm:
146
+ For each (x, z) pixel:
147
+ 1. Extract Y-profile of binary mask
148
+ 2. Compute weighted average Y-position
149
+ 3. Store in (x, z) array
150
+
151
+ Notes:
152
+ For channel fusion: blending along Y (XZ plane), mask is (X, Z) with Y-values.
153
+ This matches MATLAB multiFuse behavior for channel-channel fusion.
154
+ Returns 0 where no mask exists.
155
+ """
156
+ Z, Y, X = volume.shape
157
+ slice_mask = np.zeros((X, Z), dtype=np.uint16)
158
+
159
+ # vectorized implementation for speed
160
+ y_indices = np.arange(Y)
161
+
162
+ for x in range(X):
163
+ for z in range(Z):
164
+ # get Y-profile of mask at this (x, z)
165
+ y_profile = binary_mask[z, :, x]
166
+
167
+ if y_profile.sum() == 0:
168
+ slice_mask[x, z] = 0
169
+ else:
170
+ # compute weighted average Y-position
171
+ weighted_y = (y_indices * y_profile).sum() / y_profile.sum()
172
+ slice_mask[x, z] = int(round(weighted_y))
173
+
174
+ return slice_mask
175
+
176
+
177
+ def create_average_mask(
178
+ slice_mask1: np.ndarray,
179
+ slice_mask2: np.ndarray,
180
+ mode: str = "overlap"
181
+ ) -> np.ndarray:
182
+ """
183
+ Create average transition mask from two slice masks.
184
+
185
+ Args:
186
+ slice_mask1: first slice mask with position values
187
+ slice_mask2: second slice mask with position values
188
+ mode: 'overlap' (average only where both exist) or
189
+ 'union' (average where either exists)
190
+
191
+ Returns:
192
+ average mask with same shape as inputs
193
+
194
+ Notes:
195
+ - mode='overlap': conservative, only fuse where both masks exist
196
+ - mode='union': aggressive, fuse wherever signal exists
197
+ - works for both camera masks (Y, X) and channel masks (X, Z)
198
+ """
199
+ average_mask = np.zeros(slice_mask1.shape, dtype=np.uint16)
200
+
201
+ if mode == "overlap":
202
+ # average only where both exist
203
+ both_valid = (slice_mask1 > 0) & (slice_mask2 > 0)
204
+ average_mask[both_valid] = (slice_mask1[both_valid].astype(np.int32) +
205
+ slice_mask2[both_valid].astype(np.int32)) // 2
206
+ elif mode == "union":
207
+ # average where both exist
208
+ both_valid = (slice_mask1 > 0) & (slice_mask2 > 0)
209
+ average_mask[both_valid] = (slice_mask1[both_valid].astype(np.int32) +
210
+ slice_mask2[both_valid].astype(np.int32)) // 2
211
+ # use single value where only one exists
212
+ only1 = (slice_mask1 > 0) & (slice_mask2 == 0)
213
+ only2 = (slice_mask1 == 0) & (slice_mask2 > 0)
214
+ average_mask[only1] = slice_mask1[only1]
215
+ average_mask[only2] = slice_mask2[only2]
216
+ else:
217
+ raise ValueError(f"Unknown mode: {mode}")
218
+
219
+ return average_mask
220
+
221
+
222
+ def remove_mask_anomalies(
223
+ slice_mask: np.ndarray,
224
+ dim_size: int,
225
+ blending_range: int
226
+ ) -> np.ndarray:
227
+ """
228
+ Remove anomalies from slice mask (values too close to dimension edges).
229
+
230
+ Args:
231
+ slice_mask: slice mask with position values
232
+ dim_size: size of the dimension being blended (Z for camera, Y for channel)
233
+ blending_range: blending range in pixels
234
+
235
+ Returns:
236
+ cleaned mask with same shape as input
237
+
238
+ Notes:
239
+ Sets to 0 any mask values that are:
240
+ - Less than effective_range from front (pos=0)
241
+ - Less than effective_range from back (pos=dim_size)
242
+
243
+ Automatically reduces blending range if volume is too small.
244
+ """
245
+ cleaned = slice_mask.copy()
246
+
247
+ # automatically reduce blending range if volume is too small
248
+ # need at least 2*blending_range + 1 for a valid transition zone
249
+ max_blending = (dim_size - 1) // 2
250
+ effective_range = min(blending_range, max_blending)
251
+
252
+ if effective_range <= 0:
253
+ # volume too small for any blending, keep all valid values
254
+ return cleaned
255
+
256
+ # remove values too close to front
257
+ cleaned[cleaned < effective_range] = 0
258
+
259
+ # remove values too close to back
260
+ cleaned[cleaned > (dim_size - effective_range)] = 0
261
+
262
+ return cleaned
263
+
264
+
265
+ def apply_bwareaopen(
266
+ mask: np.ndarray,
267
+ fraction: float = 1e-5
268
+ ) -> np.ndarray:
269
+ """
270
+ Remove small objects from binary mask using area threshold.
271
+
272
+ Parameters
273
+ ----------
274
+ mask : ndarray
275
+ Binary mask (Z, Y, X)
276
+ fraction : float, default=1e-5
277
+ Minimum object size as fraction of total volume
278
+
279
+ Returns
280
+ -------
281
+ ndarray
282
+ Cleaned binary mask
283
+
284
+ Notes
285
+ -----
286
+ Set fraction=0 to disable.
287
+ """
288
+ if fraction <= 0:
289
+ return mask
290
+
291
+ min_size = int(fraction * mask.size)
292
+ if min_size < 1:
293
+ return mask
294
+
295
+ return remove_small_objects(mask.astype(bool), min_size=min_size).astype(mask.dtype)
296
+
297
+
298
+ def pad_mask_to_center(
299
+ mask: np.ndarray,
300
+ mode: int = 0,
301
+ disk_radius: int = 50
302
+ ) -> np.ndarray:
303
+ """
304
+ Pad mask edges toward center coordinate.
305
+
306
+ Parameters
307
+ ----------
308
+ mask : ndarray
309
+ Binary mask (Z, Y, X)
310
+ mode : int, default=0
311
+ 0: no padding
312
+ 1: replace zeros with center coordinate
313
+ 2: smooth padding toward center with disk element
314
+ disk_radius : int, default=50
315
+ Radius for disk structuring element in smooth mode
316
+
317
+ Returns
318
+ -------
319
+ ndarray
320
+ Padded mask
321
+
322
+ Notes
323
+ -----
324
+ Mode 2 creates smooth transition zone using morphological dilation
325
+ with disk structuring element.
326
+ """
327
+ if mode == 0:
328
+ return mask
329
+
330
+ Z, Y, X = mask.shape
331
+ center_z = Z // 2
332
+ center_y = Y // 2
333
+ center_x = X // 2
334
+
335
+ if mode == 1:
336
+ # Replace zeros with center coordinate
337
+ padded = mask.copy()
338
+ padded[padded == 0] = 1
339
+ return padded
340
+
341
+ elif mode == 2:
342
+ # Smooth padding with disk element
343
+ # Create 3D structuring element (disk in each slice)
344
+ struct = np.zeros((3, 2*disk_radius+1, 2*disk_radius+1), dtype=bool)
345
+ disk_elem = disk(disk_radius)
346
+ for i in range(3):
347
+ struct[i, :, :] = disk_elem
348
+
349
+ # Dilate mask
350
+ padded = ndimage.binary_dilation(mask, structure=struct)
351
+ return padded.astype(mask.dtype)
352
+
353
+ else:
354
+ raise ValueError(f"Unknown padding mode: {mode}")
355
+
356
+
357
+ def combine_masks(
358
+ mask1: np.ndarray,
359
+ mask2: np.ndarray,
360
+ mode: int = 1
361
+ ) -> np.ndarray:
362
+ """
363
+ Combine two binary masks for fusion.
364
+
365
+ Parameters
366
+ ----------
367
+ mask1 : ndarray
368
+ First binary mask (Z, Y, X)
369
+ mask2 : ndarray
370
+ Second binary mask (Z, Y, X)
371
+ mode : int, default=1
372
+ 0: use only overlap regions
373
+ 1: combine full information from both masks (union)
374
+
375
+ Returns
376
+ -------
377
+ ndarray
378
+ Combined binary mask
379
+
380
+ Notes
381
+ -----
382
+ Mode 0: conservative, only regions where both masks agree
383
+ Mode 1: aggressive, regions where either mask has signal
384
+ """
385
+ if mode == 0:
386
+ # Overlap only
387
+ return (mask1 & mask2).astype(mask1.dtype)
388
+ elif mode == 1:
389
+ # Union
390
+ return (mask1 | mask2).astype(mask1.dtype)
391
+ else:
392
+ raise ValueError(f"Unknown mask fusion mode: {mode}")
393
+
394
+
395
+ def subsample_for_percentile(
396
+ volume: np.ndarray,
397
+ slice_subsample: int = 1,
398
+ stack_subsample: int = 100
399
+ ) -> np.ndarray:
400
+ """
401
+ Subsample volume for percentile computation.
402
+
403
+ Parameters
404
+ ----------
405
+ volume : ndarray
406
+ Input volume (Z, Y, X)
407
+ slice_subsample : int, default=1
408
+ Take every Nth pixel in Y and X
409
+ stack_subsample : int, default=100
410
+ Take every Nth slice in Z
411
+
412
+ Returns
413
+ -------
414
+ ndarray
415
+ Subsampled volume
416
+
417
+ Notes
418
+ -----
419
+ Reduces memory for percentile calculation on large volumes.
420
+ """
421
+ return volume[::stack_subsample, ::slice_subsample, ::slice_subsample]