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/__init__.py +36 -0
- isoview/array.py +11 -0
- isoview/config.py +213 -0
- isoview/corrections.py +135 -0
- isoview/fusion.py +979 -0
- isoview/intensity.py +427 -0
- isoview/io.py +942 -0
- isoview/masks.py +421 -0
- isoview/pipeline.py +913 -0
- isoview/segmentation.py +173 -0
- isoview/temporal.py +373 -0
- isoview/transforms.py +1115 -0
- isoview/viz.py +723 -0
- isoview-0.1.0.dist-info/METADATA +370 -0
- isoview-0.1.0.dist-info/RECORD +17 -0
- isoview-0.1.0.dist-info/WHEEL +4 -0
- isoview-0.1.0.dist-info/entry_points.txt +2 -0
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]
|