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/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)