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/intensity.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Intensity correction estimation and application for multi-view fusion."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from typing import Literal, Tuple
|
|
5
|
+
|
|
6
|
+
from .corrections import percentile_interp
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def estimate_background(
|
|
10
|
+
view1: np.ndarray,
|
|
11
|
+
view2: np.ndarray,
|
|
12
|
+
percentile: float = 5.0,
|
|
13
|
+
subsample: int = 100
|
|
14
|
+
) -> float:
|
|
15
|
+
"""
|
|
16
|
+
Estimate combined background from two views.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
view1 : ndarray
|
|
21
|
+
First view (Z, Y, X)
|
|
22
|
+
view2 : ndarray
|
|
23
|
+
Second view (Z, Y, X)
|
|
24
|
+
percentile : float, default=5.0
|
|
25
|
+
Percentile for background estimation
|
|
26
|
+
subsample : int, default=100
|
|
27
|
+
Subsampling factor (use every Nth pixel)
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
float
|
|
32
|
+
Background intensity value
|
|
33
|
+
"""
|
|
34
|
+
# subsample (flatten and take every Nth)
|
|
35
|
+
flat1 = view1.ravel()[::subsample]
|
|
36
|
+
flat2 = view2.ravel()[::subsample]
|
|
37
|
+
|
|
38
|
+
# combine nonzero pixels from both views
|
|
39
|
+
valid1 = flat1[flat1 > 0]
|
|
40
|
+
valid2 = flat2[flat2 > 0]
|
|
41
|
+
combined = np.concatenate([valid1, valid2])
|
|
42
|
+
|
|
43
|
+
if len(combined) == 0:
|
|
44
|
+
return 0.0
|
|
45
|
+
|
|
46
|
+
return float(percentile_interp(combined.astype(np.float64), percentile))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def subtract_background(
|
|
50
|
+
volume: np.ndarray,
|
|
51
|
+
background: float
|
|
52
|
+
) -> np.ndarray:
|
|
53
|
+
"""
|
|
54
|
+
Subtract background from volume, clipping to zero.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
volume : ndarray
|
|
59
|
+
Input volume (Z, Y, X)
|
|
60
|
+
background : float
|
|
61
|
+
Background value to subtract
|
|
62
|
+
|
|
63
|
+
Returns
|
|
64
|
+
-------
|
|
65
|
+
ndarray
|
|
66
|
+
Background-subtracted volume, same dtype as input
|
|
67
|
+
"""
|
|
68
|
+
result = volume.astype(np.float32) - background
|
|
69
|
+
result = np.maximum(result, 0)
|
|
70
|
+
|
|
71
|
+
# preserve original dtype
|
|
72
|
+
if np.issubdtype(volume.dtype, np.integer):
|
|
73
|
+
dtype_info = np.iinfo(volume.dtype)
|
|
74
|
+
result = np.clip(result, dtype_info.min, dtype_info.max)
|
|
75
|
+
|
|
76
|
+
return result.astype(volume.dtype)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def subtract_background_pair(
|
|
80
|
+
view1: np.ndarray,
|
|
81
|
+
view2: np.ndarray,
|
|
82
|
+
percentile: float = 5.0,
|
|
83
|
+
subsample: int = 100
|
|
84
|
+
) -> Tuple[np.ndarray, np.ndarray, float]:
|
|
85
|
+
"""
|
|
86
|
+
Estimate and subtract background from two views.
|
|
87
|
+
|
|
88
|
+
Convenience function combining estimate_background and subtract_background.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
view1 : ndarray
|
|
93
|
+
First view (Z, Y, X)
|
|
94
|
+
view2 : ndarray
|
|
95
|
+
Second view (Z, Y, X)
|
|
96
|
+
percentile : float, default=5.0
|
|
97
|
+
Percentile for background estimation
|
|
98
|
+
subsample : int, default=100
|
|
99
|
+
Subsampling factor
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
view1_sub : ndarray
|
|
104
|
+
Background-subtracted first view
|
|
105
|
+
view2_sub : ndarray
|
|
106
|
+
Background-subtracted second view
|
|
107
|
+
background : float
|
|
108
|
+
The subtracted background value
|
|
109
|
+
"""
|
|
110
|
+
background = estimate_background(view1, view2, percentile, subsample)
|
|
111
|
+
view1_sub = subtract_background(view1, background)
|
|
112
|
+
view2_sub = subtract_background(view2, background)
|
|
113
|
+
return view1_sub, view2_sub, background
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def estimate_intensity_correction(
|
|
117
|
+
ref_volume: np.ndarray,
|
|
118
|
+
moving_volume: np.ndarray,
|
|
119
|
+
ref_mask: np.ndarray,
|
|
120
|
+
moving_mask: np.ndarray,
|
|
121
|
+
percentile: float = 5.0,
|
|
122
|
+
method: Literal["percentile_ratio", "mean_ratio"] = "percentile_ratio"
|
|
123
|
+
) -> dict:
|
|
124
|
+
"""
|
|
125
|
+
Estimate intensity correction factor between two views.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
ref_volume : ndarray
|
|
130
|
+
Reference view (Z, Y, X)
|
|
131
|
+
moving_volume : ndarray
|
|
132
|
+
View to correct (Z, Y, X)
|
|
133
|
+
ref_mask : ndarray
|
|
134
|
+
Binary mask defining valid regions
|
|
135
|
+
moving_mask : ndarray
|
|
136
|
+
Binary mask
|
|
137
|
+
percentile : float, default=5.0
|
|
138
|
+
Background estimation percentile
|
|
139
|
+
method : str, default='percentile_ratio'
|
|
140
|
+
'percentile_ratio' (robust) or 'mean_ratio' (simple)
|
|
141
|
+
|
|
142
|
+
Returns
|
|
143
|
+
-------
|
|
144
|
+
dict
|
|
145
|
+
Contains 'factor', 'operation', 'overlap_correlation', 'method',
|
|
146
|
+
'ref_background', 'moving_background'
|
|
147
|
+
|
|
148
|
+
Notes
|
|
149
|
+
-----
|
|
150
|
+
percentile_ratio: find overlap, compute percentile backgrounds,
|
|
151
|
+
subtract, compute ratio, invert if < 1.
|
|
152
|
+
Percentile method more robust to outliers.
|
|
153
|
+
Mean ratio faster but sensitive to background.
|
|
154
|
+
"""
|
|
155
|
+
if method == "percentile_ratio":
|
|
156
|
+
return _estimate_correction_percentile(
|
|
157
|
+
ref_volume, moving_volume, ref_mask, moving_mask, percentile
|
|
158
|
+
)
|
|
159
|
+
elif method == "mean_ratio":
|
|
160
|
+
return _estimate_correction_mean(
|
|
161
|
+
ref_volume, moving_volume, ref_mask, moving_mask
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
raise ValueError(f"Unknown method: {method}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _estimate_correction_percentile(
|
|
168
|
+
ref_volume: np.ndarray,
|
|
169
|
+
moving_volume: np.ndarray,
|
|
170
|
+
ref_mask: np.ndarray,
|
|
171
|
+
moving_mask: np.ndarray,
|
|
172
|
+
percentile: float
|
|
173
|
+
) -> dict:
|
|
174
|
+
"""
|
|
175
|
+
Estimate correction using percentile-based background subtraction.
|
|
176
|
+
|
|
177
|
+
MATLAB-equivalent: computes factor to multiply moving volume by.
|
|
178
|
+
If ref is brighter, factor > 1 (multiply moving to match).
|
|
179
|
+
If moving is brighter, factor < 1 (reduce moving to match).
|
|
180
|
+
"""
|
|
181
|
+
# get valid (nonzero) pixels from each masked region
|
|
182
|
+
ref_valid = ref_volume[(ref_mask > 0) & (ref_volume > 0)].astype(np.float64)
|
|
183
|
+
mov_valid = moving_volume[(moving_mask > 0) & (moving_volume > 0)].astype(np.float64)
|
|
184
|
+
|
|
185
|
+
if len(ref_valid) == 0 or len(mov_valid) == 0:
|
|
186
|
+
return {
|
|
187
|
+
'factor': 1.0,
|
|
188
|
+
'operation': 'multiply',
|
|
189
|
+
'overlap_correlation': 0.0,
|
|
190
|
+
'method': 'percentile_ratio',
|
|
191
|
+
'ref_background': 0.0,
|
|
192
|
+
'moving_background': 0.0
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
combined = np.concatenate([ref_valid, mov_valid])
|
|
196
|
+
background = percentile_interp(combined, percentile)
|
|
197
|
+
|
|
198
|
+
# subtract background
|
|
199
|
+
ref_fg = np.maximum(ref_valid - background, 0)
|
|
200
|
+
mov_fg = np.maximum(mov_valid - background, 0)
|
|
201
|
+
|
|
202
|
+
ref_sum = ref_fg[ref_fg > 0].sum()
|
|
203
|
+
mov_sum = mov_fg[mov_fg > 0].sum()
|
|
204
|
+
|
|
205
|
+
if mov_sum == 0 or ref_sum == 0:
|
|
206
|
+
return {
|
|
207
|
+
'factor': 1.0,
|
|
208
|
+
'operation': 'multiply',
|
|
209
|
+
'overlap_correlation': 0.0,
|
|
210
|
+
'method': 'percentile_ratio',
|
|
211
|
+
'ref_background': float(background),
|
|
212
|
+
'moving_background': float(background)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# factor to apply to moving volume to match reference
|
|
216
|
+
# if ref brighter: factor > 1 (scale moving up)
|
|
217
|
+
# if moving brighter: factor < 1 (scale moving down)
|
|
218
|
+
factor = ref_sum / mov_sum
|
|
219
|
+
|
|
220
|
+
# correlation metric
|
|
221
|
+
overlap_mask = (ref_mask > 0) & (moving_mask > 0)
|
|
222
|
+
if overlap_mask.sum() > 0:
|
|
223
|
+
ref_ovl = ref_volume[overlap_mask].astype(np.float64) - background
|
|
224
|
+
mov_ovl = moving_volume[overlap_mask].astype(np.float64) - background
|
|
225
|
+
ref_ovl = np.maximum(ref_ovl, 0)
|
|
226
|
+
mov_ovl = np.maximum(mov_ovl, 0)
|
|
227
|
+
if ref_ovl.std() > 0 and mov_ovl.std() > 0:
|
|
228
|
+
correlation = np.corrcoef(ref_ovl, mov_ovl)[0, 1]
|
|
229
|
+
else:
|
|
230
|
+
correlation = 0.0
|
|
231
|
+
else:
|
|
232
|
+
correlation = 0.0
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
'factor': float(factor),
|
|
236
|
+
'operation': 'multiply', # always multiply moving by factor
|
|
237
|
+
'overlap_correlation': float(correlation),
|
|
238
|
+
'method': 'percentile_ratio',
|
|
239
|
+
'ref_background': float(background),
|
|
240
|
+
'moving_background': float(background)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _estimate_correction_mean(
|
|
245
|
+
ref_volume: np.ndarray,
|
|
246
|
+
moving_volume: np.ndarray,
|
|
247
|
+
ref_mask: np.ndarray,
|
|
248
|
+
moving_mask: np.ndarray
|
|
249
|
+
) -> dict:
|
|
250
|
+
"""
|
|
251
|
+
Simple mean ratio correction (no background subtraction).
|
|
252
|
+
|
|
253
|
+
Faster but less robust than percentile method.
|
|
254
|
+
Returns factor to multiply moving volume by to match reference.
|
|
255
|
+
"""
|
|
256
|
+
# Find overlap region - exclude low intensity pixels
|
|
257
|
+
# use 5th percentile as threshold to exclude background/edge regions
|
|
258
|
+
bg_pct = 5.0
|
|
259
|
+
ref_thresh = percentile_interp(ref_volume[ref_mask > 0].ravel().astype(np.float64), bg_pct) if (ref_mask > 0).any() else 0
|
|
260
|
+
mov_thresh = percentile_interp(moving_volume[moving_mask > 0].ravel().astype(np.float64), bg_pct) if (moving_mask > 0).any() else 0
|
|
261
|
+
overlap_mask = (ref_mask > 0) & (moving_mask > 0) & \
|
|
262
|
+
(ref_volume > ref_thresh) & (moving_volume > mov_thresh)
|
|
263
|
+
|
|
264
|
+
if overlap_mask.sum() == 0:
|
|
265
|
+
return {
|
|
266
|
+
'factor': 1.0,
|
|
267
|
+
'operation': 'multiply',
|
|
268
|
+
'overlap_correlation': 0.0,
|
|
269
|
+
'method': 'mean_ratio',
|
|
270
|
+
'ref_background': 0.0,
|
|
271
|
+
'moving_background': 0.0
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
# Extract overlap regions
|
|
275
|
+
ref_overlap = ref_volume[overlap_mask].astype(np.float64)
|
|
276
|
+
moving_overlap = moving_volume[overlap_mask].astype(np.float64)
|
|
277
|
+
|
|
278
|
+
# Compute means
|
|
279
|
+
ref_mean = ref_overlap.mean()
|
|
280
|
+
moving_mean = moving_overlap.mean()
|
|
281
|
+
|
|
282
|
+
# factor to apply to moving volume to match reference
|
|
283
|
+
if moving_mean > 0:
|
|
284
|
+
factor = ref_mean / moving_mean
|
|
285
|
+
else:
|
|
286
|
+
factor = 1.0
|
|
287
|
+
|
|
288
|
+
# Correlation
|
|
289
|
+
if ref_overlap.std() > 0 and moving_overlap.std() > 0:
|
|
290
|
+
ref_norm = (ref_overlap - ref_mean) / ref_overlap.std()
|
|
291
|
+
moving_norm = (moving_overlap - moving_mean) / moving_overlap.std()
|
|
292
|
+
correlation = np.mean(ref_norm * moving_norm)
|
|
293
|
+
else:
|
|
294
|
+
correlation = 0.0
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
'factor': float(factor),
|
|
298
|
+
'operation': 'multiply', # always multiply moving by factor
|
|
299
|
+
'overlap_correlation': float(correlation),
|
|
300
|
+
'method': 'mean_ratio',
|
|
301
|
+
'ref_background': 0.0,
|
|
302
|
+
'moving_background': 0.0
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def apply_intensity_correction(
|
|
307
|
+
volume: np.ndarray,
|
|
308
|
+
correction: dict
|
|
309
|
+
) -> np.ndarray:
|
|
310
|
+
"""
|
|
311
|
+
Apply intensity correction to volume.
|
|
312
|
+
|
|
313
|
+
Parameters
|
|
314
|
+
----------
|
|
315
|
+
volume : ndarray
|
|
316
|
+
Input volume (Z, Y, X) - the moving volume to correct
|
|
317
|
+
correction : dict
|
|
318
|
+
Dict from estimate_intensity_correction with 'factor'
|
|
319
|
+
|
|
320
|
+
Returns
|
|
321
|
+
-------
|
|
322
|
+
ndarray
|
|
323
|
+
Corrected volume, same dtype as input
|
|
324
|
+
|
|
325
|
+
Notes
|
|
326
|
+
-----
|
|
327
|
+
Multiplies volume by factor. Factor > 1 brightens, < 1 dims.
|
|
328
|
+
"""
|
|
329
|
+
factor = correction['factor']
|
|
330
|
+
|
|
331
|
+
# Convert to float for computation
|
|
332
|
+
corrected = volume.astype(np.float32) * factor
|
|
333
|
+
|
|
334
|
+
# Clip to valid range for dtype
|
|
335
|
+
if np.issubdtype(volume.dtype, np.integer):
|
|
336
|
+
dtype_info = np.iinfo(volume.dtype)
|
|
337
|
+
corrected = np.clip(corrected, dtype_info.min, dtype_info.max)
|
|
338
|
+
|
|
339
|
+
return corrected.astype(volume.dtype)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def apply_median_filter(
|
|
343
|
+
parameters: np.ndarray,
|
|
344
|
+
window: int = 100
|
|
345
|
+
) -> np.ndarray:
|
|
346
|
+
"""
|
|
347
|
+
Apply median filter to parameters across time.
|
|
348
|
+
|
|
349
|
+
Parameters
|
|
350
|
+
----------
|
|
351
|
+
parameters : ndarray
|
|
352
|
+
Parameter values (N,) or (N, M)
|
|
353
|
+
window : int, default=100
|
|
354
|
+
Filter window size, 0 to disable
|
|
355
|
+
|
|
356
|
+
Returns
|
|
357
|
+
-------
|
|
358
|
+
ndarray
|
|
359
|
+
Filtered parameters
|
|
360
|
+
|
|
361
|
+
Notes
|
|
362
|
+
-----
|
|
363
|
+
Uses scipy median filter with reflect mode at boundaries.
|
|
364
|
+
"""
|
|
365
|
+
if window <= 0:
|
|
366
|
+
return parameters
|
|
367
|
+
|
|
368
|
+
from scipy.signal import medfilt
|
|
369
|
+
|
|
370
|
+
if parameters.ndim == 1:
|
|
371
|
+
# Ensure window is odd
|
|
372
|
+
w = window if window % 2 == 1 else window + 1
|
|
373
|
+
return medfilt(parameters, kernel_size=w)
|
|
374
|
+
else:
|
|
375
|
+
# Filter each column independently
|
|
376
|
+
filtered = np.zeros_like(parameters)
|
|
377
|
+
w = window if window % 2 == 1 else window + 1
|
|
378
|
+
for i in range(parameters.shape[1]):
|
|
379
|
+
filtered[:, i] = medfilt(parameters[:, i], kernel_size=w)
|
|
380
|
+
return filtered
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def apply_gauss_filter(
|
|
384
|
+
volume: np.ndarray,
|
|
385
|
+
kernel_size: int = 3,
|
|
386
|
+
sigma: float = 1.0,
|
|
387
|
+
precise: bool = True
|
|
388
|
+
) -> np.ndarray:
|
|
389
|
+
"""
|
|
390
|
+
Apply Gaussian filter to volume.
|
|
391
|
+
|
|
392
|
+
Parameters
|
|
393
|
+
----------
|
|
394
|
+
volume : ndarray
|
|
395
|
+
Input volume (Z, Y, X)
|
|
396
|
+
kernel_size : int, default=3
|
|
397
|
+
Kernel size, 0 to disable
|
|
398
|
+
sigma : float, default=1.0
|
|
399
|
+
Gaussian sigma
|
|
400
|
+
precise : bool, default=True
|
|
401
|
+
True for float64 precision, False for uint16 (faster)
|
|
402
|
+
|
|
403
|
+
Returns
|
|
404
|
+
-------
|
|
405
|
+
ndarray
|
|
406
|
+
Filtered volume, same dtype as input
|
|
407
|
+
|
|
408
|
+
Notes
|
|
409
|
+
-----
|
|
410
|
+
Precise mode recommended for low-intensity data to avoid rounding errors.
|
|
411
|
+
"""
|
|
412
|
+
if kernel_size <= 0:
|
|
413
|
+
return volume
|
|
414
|
+
|
|
415
|
+
from scipy.ndimage import gaussian_filter
|
|
416
|
+
|
|
417
|
+
if precise:
|
|
418
|
+
filtered = gaussian_filter(volume.astype(np.float64), sigma=sigma)
|
|
419
|
+
else:
|
|
420
|
+
filtered = gaussian_filter(volume.astype(np.float32), sigma=sigma)
|
|
421
|
+
|
|
422
|
+
# Convert back to original dtype
|
|
423
|
+
if np.issubdtype(volume.dtype, np.integer):
|
|
424
|
+
dtype_info = np.iinfo(volume.dtype)
|
|
425
|
+
filtered = np.clip(filtered, dtype_info.min, dtype_info.max)
|
|
426
|
+
|
|
427
|
+
return filtered.astype(volume.dtype)
|