stdpipe 0.4.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.
@@ -0,0 +1,1086 @@
1
+ """
2
+ Fringe removal for astronomical images.
3
+
4
+ The main routine, :func:`remove_fringes`, models the fringe pattern as a
5
+ smooth two-dimensional field at intermediate spatial scales (between stellar
6
+ FWHM and the coarse sky background) and estimates it directly in image space:
7
+ sources are masked, and the masked pixels are transparently "inpainted" by
8
+ NaN-aware Gaussian smoothing, so the fringe structure is continued under the
9
+ stars. This works for realistic curved, non-stationary fringes where
10
+ frequency-domain methods fail.
11
+
12
+ The module also provides Fourier notch filtering
13
+ (:func:`remove_fringes_fourier`) for genuinely *periodic* patterns such as
14
+ electronic pickup noise, and :func:`visualize_fourier_spectrum` for
15
+ diagnostics.
16
+ """
17
+
18
+ import numpy as np
19
+ from astropy.stats import mad_std
20
+
21
+
22
+ __all__ = [
23
+ 'remove_fringes',
24
+ 'remove_fringes_fourier',
25
+ 'detect_fringe_peaks_fft',
26
+ 'create_notch_filter',
27
+ 'create_bandpass_filter',
28
+ 'visualize_fourier_spectrum',
29
+ ]
30
+
31
+
32
+ def _find_bright_sources(sn, core, halo_sn=20.0, min_rmax=0.0, max_sources=100):
33
+ """Find halo-bearing sources: components that both have a bright peak
34
+ (> `halo_sn` sigmas) and an extended thresholded footprint (internal
35
+ radius > `min_rmax`). A footprint much wider than the PSF means the wings
36
+ are above the threshold, implying yet fainter wings extend proportionally
37
+ further below it.
38
+
39
+ The cores are labeled at the `halo_sn` level: fringe crests never reach
40
+ it, so bright stars are not merged with the crest networks they touch.
41
+
42
+ Parameters
43
+ ----------
44
+ sn : ndarray
45
+ Per-pixel significance map (residual / rms)
46
+ core : ndarray of bool
47
+ Low-threshold outlier mask used to measure the footprint extent
48
+ halo_sn : float
49
+ Peak significance above which a component may be halo-bearing
50
+ min_rmax : float
51
+ Minimum footprint internal radius (pixels)
52
+ max_sources : int
53
+ Keep at most this many sources (brightest first)
54
+
55
+ Returns
56
+ -------
57
+ stars : list of (cy, cx, rmax) tuples, sorted by decreasing peak
58
+ """
59
+ from scipy import ndimage
60
+
61
+ lab, n = ndimage.label(sn > halo_sn)
62
+ if n == 0:
63
+ return []
64
+
65
+ idx = np.arange(1, n + 1)
66
+ # Local footprint radius of each bright core: max internal distance to
67
+ # the edge of the low-threshold mask over the core pixels. Measures how
68
+ # far the wings of this particular source stay above the threshold,
69
+ # regardless of what other structures the footprint is connected to
70
+ edt_in = ndimage.distance_transform_edt(core)
71
+ rmax = ndimage.maximum(edt_in, lab, idx)
72
+ keep = np.where(rmax > min_rmax)[0]
73
+ if len(keep) == 0:
74
+ return []
75
+
76
+ peaks = ndimage.maximum(sn, lab, idx)
77
+ keep = keep[np.argsort(peaks[keep])[::-1]][:max_sources]
78
+ centers = ndimage.maximum_position(sn, lab, [ci + 1 for ci in keep])
79
+
80
+ return [(cy, cx, rmax[ci]) for (cy, cx), ci in zip(centers, keep)]
81
+
82
+
83
+ def _radial_geometry(stars, shape, max_radius=None, dr=2.0):
84
+ """Precompute the per-star ring geometry (bounding box, radii, ring-sorted
85
+ pixel order and ring boundaries) used by :func:`_radial_profile_field`.
86
+ The geometry only depends on the star positions, so it can be reused for
87
+ profiling different arrays."""
88
+ H, W = shape
89
+ if max_radius is None:
90
+ max_radius = min(H, W) / 4
91
+
92
+ geos = []
93
+ for cy, cx, rm in stars:
94
+ R = min(max_radius, 16 * rm)
95
+ y0, y1 = max(0, int(cy - R)), min(H, int(cy + R + 1))
96
+ x0, x1 = max(0, int(cx - R)), min(W, int(cx + R + 1))
97
+ yy, xx = np.mgrid[y0:y1, x0:x1]
98
+ rr = np.hypot(yy - cy, xx - cx).astype(np.float32)
99
+
100
+ rbin = (rr / dr).astype(np.int32).ravel()
101
+ nb = int(R / dr) + 1
102
+ inside = np.flatnonzero(rbin < nb)
103
+ order = inside[np.argsort(rbin[inside], kind='stable')]
104
+ edges = np.searchsorted(rbin[order], np.arange(nb + 1))
105
+
106
+ geos.append({
107
+ 'sly': slice(y0, y1),
108
+ 'slx': slice(x0, x1),
109
+ 'rr': rr,
110
+ 'rm': rm,
111
+ 'order': order,
112
+ 'edges': edges,
113
+ 'rgrid': (np.arange(nb) + 0.5) * dr,
114
+ })
115
+
116
+ return geos
117
+
118
+
119
+ def _pava_decreasing(y):
120
+ """Isotonic regression for a non-increasing sequence (pool adjacent
121
+ violators)."""
122
+ vals, wts = [], []
123
+ for v in y:
124
+ vals.append(float(v))
125
+ wts.append(1)
126
+ while len(vals) > 1 and vals[-2] < vals[-1]:
127
+ vals[-2] = (vals[-1] * wts[-1] + vals[-2] * wts[-2]) / (wts[-1] + wts[-2])
128
+ wts[-2] += wts[-1]
129
+ vals.pop()
130
+ wts.pop()
131
+ return np.repeat(vals, wts)
132
+
133
+
134
+ def _radial_profile_field(data, geos, mask=None):
135
+ """Sum of the non-monotone parts of azimuthal-median radial profiles
136
+ around the stars whose geometry was precomputed by
137
+ :func:`_radial_geometry`.
138
+
139
+ For each star, ring medians of `data` (minus the already accumulated
140
+ contributions of brighter stars) capture the azimuthally symmetric
141
+ circumstellar structure while averaging the non-symmetric fringes
142
+ passing through to nearly zero. The monotone-decreasing positive
143
+ envelope of the profile - the stellar wings and the smooth halo - is
144
+ then removed from it (isotonic fit), leaving only the detached
145
+ structures: ghost reflection rings, background estimation bowls. Those
146
+ are exactly what isotropic smoothing cannot model with circular
147
+ fidelity; the smooth envelope part is left for the smoothing to absorb,
148
+ so the field carries no imprint of the star itself.
149
+
150
+ If `mask` is given (source mask: pixels above the detection threshold),
151
+ masked pixels are excluded from the ring medians, and rings dominated by
152
+ them are bridged by interpolation from the unmasked zones.
153
+ """
154
+ from scipy.ndimage import gaussian_filter1d
155
+
156
+ out = np.zeros_like(data)
157
+
158
+ for g in geos:
159
+ vals = (data[g['sly'], g['slx']] - out[g['sly'], g['slx']]).ravel()[g['order']]
160
+ if mask is not None:
161
+ mvals = mask[g['sly'], g['slx']].ravel()[g['order']]
162
+ edges = g['edges']
163
+ nb = len(g['rgrid'])
164
+ prof = np.full(nb, np.nan)
165
+ for b in range(nb):
166
+ v = vals[edges[b]:edges[b + 1]]
167
+ if mask is not None:
168
+ v = v[~mvals[edges[b]:edges[b + 1]]]
169
+ # Require a mostly unmasked ring for a trustworthy median
170
+ if len(v) > 0.5 * (edges[b + 1] - edges[b]):
171
+ prof[b] = np.median(v)
172
+
173
+ valid = np.isfinite(prof)
174
+ if not np.any(valid):
175
+ continue
176
+ # Bridge masked rings by linear interpolation / constant extension
177
+ prof = np.interp(np.arange(nb), np.flatnonzero(valid), prof[valid])
178
+
179
+ # Outer baseline: the profile must approach zero far from the star
180
+ prof = prof - np.median(prof[int(0.75 * nb):])
181
+
182
+ # Mild radial smoothing against per-ring median noise that would
183
+ # otherwise imprint concentric ring patterns on the image
184
+ prof = gaussian_filter1d(prof, 1.0, mode='nearest')
185
+
186
+ # Remove the monotone-decreasing positive envelope (stellar wings
187
+ # and smooth halo), keeping only the detached non-monotone
188
+ # structures. The envelope is smooth and sub-threshold, so the
189
+ # multi-scale smoothing absorbs it instead, with no edge anywhere
190
+ prof = prof - np.clip(_pava_decreasing(prof), 0.0, None)
191
+
192
+ # Smooth taper to zero over the outer quarter to avoid a circular
193
+ # edge at the field boundary
194
+ prof = prof * np.clip((nb - 1 - np.arange(nb)) / (0.25 * nb), 0.0, 1.0)
195
+
196
+ out[g['sly'], g['slx']] += np.interp(g['rr'], g['rgrid'], prof, right=0.0)
197
+
198
+ return out
199
+
200
+
201
+ def _binned_gaussian(data, sigma, binning):
202
+ """Gaussian smoothing computed on a block-averaged copy of the array and
203
+ interpolated back - much faster for wide kernels, accurate when `sigma`
204
+ is well above `binning`."""
205
+ from scipy.ndimage import gaussian_filter, zoom
206
+
207
+ H, W = data.shape
208
+ Hp = (H + binning - 1) // binning * binning
209
+ Wp = (W + binning - 1) // binning * binning
210
+ padded = np.zeros((Hp, Wp), dtype=np.float64)
211
+ padded[:H, :W] = data
212
+ binned = padded.reshape(Hp // binning, binning, Wp // binning, binning).mean(axis=(1, 3))
213
+ smoothed = gaussian_filter(binned, sigma / binning, mode='constant', cval=0.0)
214
+ return zoom(smoothed, binning, order=1)[:H, :W]
215
+
216
+
217
+ def _smooth_inpaint(flat, mask, scale, levels=(1, 2, 4, 8)):
218
+ """Mask-aware Gaussian smoothing with seamless multi-scale inpainting.
219
+
220
+ Computes the normalized masked convolution (smoothed data divided by
221
+ smoothed mask support) on a ladder of scales `levels` * `scale`. Each
222
+ pixel is filled by the finest scale that still has meaningful unmasked
223
+ support there - so masked star footprints are smoothly continued from
224
+ the surrounding fringe structure rather than replaced by a flat
225
+ large-scale average. The blending weight is a smooth function of the
226
+ local support, so no seams appear at the transitions. Regions beyond
227
+ the reach of the coarsest kernel smoothly approach zero (no correction).
228
+ """
229
+ from scipy.ndimage import gaussian_filter
230
+
231
+ data0 = np.where(mask, 0.0, flat)
232
+ support = (~mask).astype(np.float64)
233
+
234
+ out = np.zeros_like(data0)
235
+ wsum = np.zeros_like(data0)
236
+
237
+ for level in levels:
238
+ sigma = level * scale
239
+ if sigma < 16:
240
+ num = gaussian_filter(data0, sigma, mode='constant', cval=0.0)
241
+ den = gaussian_filter(support, sigma, mode='constant', cval=0.0)
242
+ else:
243
+ # Wide kernels: compute on binned arrays, they have no fine
244
+ # structure by construction
245
+ binning = max(1, int(sigma / 8))
246
+ num = _binned_gaussian(data0, sigma, binning)
247
+ den = _binned_gaussian(support, sigma, binning)
248
+
249
+ est = num / np.maximum(den, 1e-10)
250
+ # Trust the estimate where >~15% of the kernel sees unmasked pixels;
251
+ # the Gaussian tails still provide a meaningful weighted average of
252
+ # the surrounding structure well inside masked holes
253
+ w = np.clip(den / 0.15, 0.0, 1.0) ** 2
254
+ out = out + est * w * (1.0 - wsum)
255
+ wsum = wsum + w * (1.0 - wsum)
256
+
257
+ return out
258
+
259
+
260
+ def _auto_scale(resid, mask_src, rms_med, candidates=(6, 8, 10, 12, 16, 20, 24),
261
+ noise_frac=0.25):
262
+ """Pick the smoothing scale automatically from the fringe-to-noise ratio.
263
+
264
+ For each candidate scale, a single mask-aware normalized smoothing pass
265
+ estimates the model. The variance of that model is the sum of a fringe
266
+ (signal) part and a predictable noise floor that the smoothing leaves
267
+ behind: smoothing white noise of per-pixel ``rms`` with a normalized
268
+ Gaussian of width ``s`` over an unmasked fraction ``f`` reduces it to
269
+ ``rms / (2 s sqrt(pi f))``. Subtracting that floor in quadrature gives
270
+ the fringe amplitude, and the smallest scale at which the noise floor
271
+ falls below ``noise_frac`` of it is chosen.
272
+
273
+ Strong, sharp fringes thus keep a small scale (little smoothing, good
274
+ fidelity); faint, broad fringes get a larger scale that averages the
275
+ noise down instead of leaking it into the model.
276
+ """
277
+ from scipy.ndimage import gaussian_filter
278
+
279
+ data0 = np.where(mask_src, 0.0, resid)
280
+ support = (~mask_src).astype(np.float64)
281
+ f = max(support.mean(), 1e-3)
282
+
283
+ chosen = candidates[-1]
284
+ for s in candidates:
285
+ num = gaussian_filter(data0, s, mode='constant')
286
+ den = gaussian_filter(support, s, mode='constant')
287
+ est = num / np.maximum(den, 1e-10)
288
+ var = np.var(est[den > 0.3])
289
+ noise = rms_med / (2.0 * s * np.sqrt(np.pi * f))
290
+ signal = np.sqrt(max(var - noise**2, 0.0))
291
+ if noise < noise_frac * signal:
292
+ chosen = s
293
+ break
294
+ return float(chosen)
295
+
296
+
297
+ def remove_fringes(
298
+ image,
299
+ mask=None,
300
+ scale='auto',
301
+ noise_frac=0.25,
302
+ bg_size=256,
303
+ threshold=2.0,
304
+ dilate=2,
305
+ iterations=3,
306
+ halo_sn=20.0,
307
+ get_fringe_model=False,
308
+ verbose=False,
309
+ ):
310
+ """
311
+ Remove fringes by estimating a smooth intermediate-scale background map
312
+ with sources masked and inpainted.
313
+
314
+ The algorithm exploits the scale separation between stars (~FWHM, a few
315
+ pixels), fringes (tens of pixels) and the sky background (hundreds of
316
+ pixels):
317
+
318
+ 1. Subtract a coarse sky background (SEP mesh of `bg_size` pixels)
319
+ 2. Around bright stars (peaks above `halo_sn` sigmas with footprints
320
+ wider than the smoothing kernel), model the non-monotone azimuthally
321
+ symmetric circumstellar structure - ghost reflection rings,
322
+ background estimation bowls - as azimuthal-median radial profiles
323
+ with their smooth monotone envelope (the stellar wings and halo)
324
+ removed, and subtract it from the working residual. This captures
325
+ circular artifacts with full circular fidelity, which isotropic
326
+ smoothing cannot do
327
+ 3. Mask pixels deviating by more than `threshold` sigma (sources and
328
+ outliers), grow the mask by `dilate` pixels
329
+ 4. Smooth the masked residual with a Gaussian of sigma `scale` pixels
330
+ using mask-aware normalized convolution - masked pixels are filled by
331
+ the weighted average of the surrounding fringe structure (inpainting),
332
+ each region by the finest scale of a multi-scale ladder that still
333
+ has unmasked support there. The smoothing also absorbs the smooth
334
+ sub-threshold envelope of stellar halos and wings
335
+ 5. Iterate: re-mask on the fringe-subtracted residual (so that fringe
336
+ crests are not mistaken for sources), smooth it again and add to the
337
+ fringe map. The additive refinement also recovers the part of the
338
+ fringe structure attenuated by the smoothing, so narrower fringes are
339
+ progressively captured with each iteration
340
+
341
+ The radial circumstellar fields are included in the returned fringe map
342
+ and thus subtracted from the image along with the fringes.
343
+
344
+ The resulting fringe map is defined everywhere, including under the
345
+ stars, and is subtracted from the original image. The coarse sky
346
+ background is left in the image.
347
+
348
+ Parameters
349
+ ----------
350
+ image : ndarray
351
+ Input image
352
+ mask : ndarray, optional
353
+ Boolean mask (True = masked pixels to exclude from fringe estimation)
354
+ scale : float or 'auto'
355
+ Sigma of the Gaussian smoothing kernel in pixels. Should be of order
356
+ the stellar FWHM or somewhat larger, and well below the narrowest
357
+ fringe period. Smaller values track narrower fringes at the cost of
358
+ slightly more noise and source flux absorbed into the map.
359
+ If ``'auto'`` (default), the scale is selected from the measured
360
+ fringe-to-noise ratio (6 px for strong, sharp fringes up to a few
361
+ tens of px for faint, broad ones), so that noise does not leak into
362
+ the fringe model. Pass a number to set it explicitly.
363
+ noise_frac : float
364
+ Aggressiveness of the automatic scale selection (used only when
365
+ ``scale='auto'``). The smallest scale whose predicted noise floor
366
+ falls below ``noise_frac`` times the fringe amplitude is chosen.
367
+ Smaller values give cleaner (less noisy) models but larger kernels,
368
+ which can over-smooth very faint, broad fringes; larger values keep
369
+ smaller kernels at the cost of more noise in the model. Default: 0.25
370
+ bg_size : int or None
371
+ Mesh size in pixels for the coarse sky background subtracted before
372
+ fringe estimation. Should be much larger than the fringe scale.
373
+ If None, the global median is used instead. Default: 256
374
+ threshold : float
375
+ Source masking threshold in sigmas of the local background RMS.
376
+ Applied symmetrically (both positive and negative outliers are
377
+ masked) to avoid biasing the fringe map. Default: 2.0
378
+ dilate : int
379
+ Number of binary dilation iterations applied to the source mask to
380
+ cover the wings of the sources. Default: 2
381
+ iterations : int
382
+ Number of refinement iterations. Each iteration re-derives the
383
+ source mask from the fringe-subtracted residual (the first one has
384
+ to mask on the raw residual where fringe crests may exceed the
385
+ threshold) and accumulates the smoothed residual into the fringe
386
+ map. More iterations capture narrower fringes but absorb more noise.
387
+ Default: 3
388
+ halo_sn : float or None
389
+ Peak significance (in sigmas) above which a source is considered
390
+ bright enough to have halos and gets the radial halo treatment.
391
+ Should be well above the peak significance of the fringe crests.
392
+ Only sources whose thresholded footprint radius also exceeds `scale`
393
+ are treated (ordinary stars have compact footprints and are not
394
+ affected). None disables the halo modeling. Default: 20
395
+ get_fringe_model : bool
396
+ If True, return (corrected_image, fringe_model). Default: False
397
+ verbose : bool or callable
398
+ Whether to show verbose messages during the run. May be either
399
+ boolean, or a `print`-like function.
400
+
401
+ Returns
402
+ -------
403
+ corrected_image : ndarray
404
+ Image with the fringe map subtracted
405
+ fringe_model : ndarray, optional
406
+ The subtracted fringe map (if get_fringe_model=True)
407
+
408
+ Notes
409
+ -----
410
+ Circumstellar structures - halos, ghost rings, background bowls - are
411
+ locally indistinguishable from fringes (smooth structures at similar
412
+ scales). The smooth ones are absorbed by the masked multi-scale
413
+ smoothing along with the fringes; sharply circular ones (ghost donuts,
414
+ bowls) are additionally captured with full circular fidelity by
415
+ azimuthal-median radial profiles around bright stars (the `halo_sn`
416
+ mechanism), which average the oscillating fringes passing through to
417
+ nearly zero. Everything smooth or circular around bright stars is thus
418
+ subtracted, including the sub-threshold part of their PSF wings; the
419
+ star cores and everything above the masking threshold stay in the
420
+ image. Use halo_sn=None to disable the radial modeling (isotropic
421
+ smoothing only).
422
+
423
+ Faint extended sources (low surface brightness galaxies, nebulae) whose
424
+ peaks stay below `halo_sn` remain partially degenerate with the fringe
425
+ pattern: the part below the masking threshold gets absorbed into the
426
+ fringe map and over-subtracted. For fields dominated by such sources,
427
+ lower `halo_sn`, increase `scale`, or mask the sources explicitly via
428
+ the `mask` argument.
429
+
430
+ Examples
431
+ --------
432
+ >>> corrected = remove_fringes(image, mask=mask, scale=6)
433
+ >>> corrected, fringes = remove_fringes(image, get_fringe_model=True)
434
+ """
435
+ try:
436
+ import sep_x as sep
437
+ except ImportError:
438
+ import sep
439
+
440
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
441
+
442
+ image = np.asarray(image, dtype=np.float64)
443
+
444
+ # Base mask: user mask plus non-finite pixels
445
+ mask_bad = ~np.isfinite(image)
446
+ if mask is not None:
447
+ mask_bad |= mask.astype(bool)
448
+
449
+ img = image.copy()
450
+ if np.any(mask_bad):
451
+ img[mask_bad] = np.median(image[~mask_bad])
452
+
453
+ # Coarse sky background, to be preserved in the output
454
+ if bg_size is not None:
455
+ bg = sep.Background(
456
+ np.ascontiguousarray(img, dtype=np.float32),
457
+ mask=np.ascontiguousarray(mask_bad),
458
+ bw=bg_size,
459
+ bh=bg_size,
460
+ )
461
+ resid = img - bg.back().astype(np.float64)
462
+ rms = bg.rms().astype(np.float64)
463
+ log(
464
+ 'Coarse background: %d px mesh, global level %.2f rms %.2f'
465
+ % (bg_size, bg.globalback, bg.globalrms)
466
+ )
467
+ else:
468
+ resid = img - np.median(img[~mask_bad])
469
+ rms = np.full_like(img, mad_std(resid[~mask_bad]))
470
+ log('Coarse background: global median, rms %.2f' % rms.flat[0])
471
+
472
+ from scipy.ndimage import binary_dilation
473
+
474
+ # Auto-select the smoothing scale from the fringe-to-noise ratio so that
475
+ # faint, broad fringes get more noise averaging than sharp, strong ones
476
+ if isinstance(scale, str):
477
+ if scale != 'auto':
478
+ raise ValueError("scale must be a number or 'auto'")
479
+ scale = _auto_scale(resid, (np.abs(resid) > threshold * rms) | mask_bad,
480
+ float(np.median(rms)), noise_frac=noise_frac)
481
+ log('Auto-selected smoothing scale: %.0f px' % scale)
482
+
483
+ fringe_model = np.zeros_like(img)
484
+ geos = None
485
+
486
+ for i in range(max(1, int(iterations))):
487
+ flat = resid - fringe_model
488
+ sn = np.abs(flat) / rms
489
+
490
+ # Radial models of the circumstellar structure around bright stars
491
+ # (halos, ghost rings, background bowls), which is locally degenerate
492
+ # with the fringes. Ring medians capture it while averaging the
493
+ # fringes to nearly zero; the fringes under it remain measurable.
494
+ # The star list is detected once: the halo_sn gate is far above the
495
+ # fringe crests, so it is stable across iterations
496
+ if halo_sn is not None and geos is None:
497
+ stars = _find_bright_sources(
498
+ sn, sn > threshold, halo_sn=halo_sn, min_rmax=scale
499
+ )
500
+ geos = _radial_geometry(stars, img.shape) if stars else []
501
+ log('Bright stars with circumstellar structure modeled: %d' % len(stars))
502
+
503
+ rings = None
504
+ if geos:
505
+ # The radial fields carry only the non-monotone circumstellar
506
+ # structure (ghost rings, background bowls), which the isotropic
507
+ # smoothing cannot model with circular fidelity. The smooth
508
+ # wings and halos are absorbed by the smoothing itself
509
+ rings = _radial_profile_field(
510
+ flat, geos, mask=(sn > threshold) | mask_bad
511
+ )
512
+ flat = flat - rings
513
+
514
+ # Symmetric outlier masking on the ring- and fringe-subtracted
515
+ # residual
516
+ mask_src = (np.abs(flat) > threshold * rms) | mask_bad
517
+ if dilate > 0:
518
+ mask_src = binary_dilation(mask_src, iterations=dilate)
519
+ mask_src |= mask_bad
520
+
521
+ log(
522
+ 'Iteration %d: %.1f%% pixels masked' % (i + 1, 100 * np.mean(mask_src))
523
+ )
524
+
525
+ # Additive refinement: smooth the current residual and accumulate.
526
+ # A single Gaussian pass attenuates structure of period P by
527
+ # exp(-2 pi^2 scale^2 / P^2); repeating on the residual recovers the
528
+ # attenuated part (effective response 1 - (1 - G)^n).
529
+ # The ring fields are part of the model and get subtracted as well
530
+ fringe_model = fringe_model + _smooth_inpaint(flat, mask_src, scale)
531
+ if rings is not None:
532
+ fringe_model = fringe_model + rings
533
+
534
+ log(
535
+ 'Iteration %d: fringe map amplitude (mad_std) %.2f'
536
+ % (i + 1, mad_std(fringe_model))
537
+ )
538
+
539
+ corrected = np.ascontiguousarray(image - fringe_model)
540
+
541
+ if get_fringe_model:
542
+ return corrected, np.ascontiguousarray(fringe_model)
543
+ else:
544
+ return corrected
545
+
546
+
547
+ def detect_fringe_peaks_fft(
548
+ image,
549
+ mask=None,
550
+ sigma_threshold=5.0,
551
+ period_range=(30, 200),
552
+ fwhm=None,
553
+ protect_low_freq=True,
554
+ protect_high_freq=True,
555
+ verbose=False,
556
+ ):
557
+ """
558
+ Detect peaks in the Fourier power spectrum corresponding to periodic
559
+ patterns (e.g. electronic pickup noise).
560
+
561
+ Note that realistic sky fringes are usually *not* periodic - their power
562
+ is spread over a broad annulus in frequency space with no distinct peaks.
563
+ Use :func:`remove_fringes` for those.
564
+
565
+ Parameters
566
+ ----------
567
+ image : ndarray
568
+ Input image
569
+ mask : ndarray, optional
570
+ Boolean mask (True = masked pixels)
571
+ sigma_threshold : float
572
+ Detection threshold in sigma above median power
573
+ period_range : tuple
574
+ (min_period, max_period) in pixels
575
+ fwhm : float, optional
576
+ Stellar FWHM for protecting high frequencies
577
+ protect_low_freq : bool
578
+ Exclude very low frequencies (large-scale background)
579
+ protect_high_freq : bool
580
+ Exclude high frequencies (stellar content); requires `fwhm`
581
+ verbose : bool
582
+ Print diagnostic information
583
+
584
+ Returns
585
+ -------
586
+ peaks : list of tuples
587
+ List of (fy, fx, period) peak positions in Fourier space (pixel
588
+ offsets relative to the spectrum center) and the corresponding
589
+ spatial period in pixels
590
+ """
591
+ img = image.copy()
592
+ ny, nx = img.shape
593
+
594
+ # Handle masked pixels by filling with global median (robust for clustered NaNs)
595
+ if mask is not None and np.any(mask):
596
+ median_val = np.nanmedian(img)
597
+ img[mask] = median_val
598
+
599
+ # Compute FFT and power spectrum
600
+ fft = np.fft.fft2(img)
601
+ fft_shifted = np.fft.fftshift(fft)
602
+ power = np.abs(fft_shifted) ** 2
603
+
604
+ # Log scale for better detection
605
+ log_power = np.log10(power + 1)
606
+
607
+ # Create frequency grids (cycles per pixel, per-axis normalization)
608
+ fy = np.fft.fftshift(np.fft.fftfreq(ny))
609
+ fx = np.fft.fftshift(np.fft.fftfreq(nx))
610
+ FY, FX = np.meshgrid(fy, fx, indexing='ij')
611
+ freq_radius = np.sqrt(FY**2 + FX**2)
612
+
613
+ # Convert period_range to frequency range
614
+ freq_min = 1.0 / period_range[1] # Large period -> low frequency
615
+ freq_max = 1.0 / period_range[0] # Small period -> high frequency
616
+
617
+ # Create search mask for peak detection
618
+ search_mask = np.ones_like(power, dtype=bool)
619
+
620
+ # Exclude DC component (central 5x5 pixels)
621
+ search_mask[ny // 2 - 2 : ny // 2 + 3, nx // 2 - 2 : nx // 2 + 3] = False
622
+
623
+ # Exclude frequencies outside period_range
624
+ search_mask[freq_radius < freq_min] = False
625
+ search_mask[freq_radius > freq_max] = False
626
+
627
+ # Protect high frequencies (stellar content) if FWHM provided
628
+ if protect_high_freq and fwhm is not None:
629
+ freq_stellar = 1.0 / (2.0 * fwhm) # Nyquist for FWHM
630
+ search_mask[freq_radius > freq_stellar] = False
631
+
632
+ # Protect very low frequencies (large-scale background)
633
+ if protect_low_freq:
634
+ freq_background = 4.0 / min(ny, nx) # Period > image_size/4
635
+ search_mask[freq_radius < freq_background] = False
636
+
637
+ # Find peaks in search region
638
+ search_power = log_power.copy()
639
+ search_power[~search_mask] = np.nan
640
+
641
+ # Compute threshold: median + sigma_threshold * MAD
642
+ valid_power = search_power[np.isfinite(search_power)]
643
+ if len(valid_power) == 0:
644
+ if verbose:
645
+ print("No valid frequency range to search")
646
+ return []
647
+
648
+ median_power = np.median(valid_power)
649
+ mad_power = mad_std(valid_power)
650
+ threshold = median_power + sigma_threshold * mad_power
651
+
652
+ if verbose:
653
+ print(f"Power spectrum statistics:")
654
+ print(f" Median: {median_power:.2f}")
655
+ print(f" MAD: {mad_power:.2f}")
656
+ print(f" Threshold: {threshold:.2f}")
657
+
658
+ # Detect local maxima above threshold
659
+ from scipy.ndimage import maximum_filter
660
+
661
+ local_max = maximum_filter(search_power, size=5)
662
+ peaks_mask = (
663
+ (search_power == local_max) & (search_power > threshold) & np.isfinite(search_power)
664
+ )
665
+
666
+ # Extract peak positions
667
+ peak_coords = np.argwhere(peaks_mask)
668
+
669
+ if verbose:
670
+ print(f"Detected {len(peak_coords)} peaks before symmetry")
671
+
672
+ # Convert to frequency coordinates (relative to center)
673
+ peaks = []
674
+ for py, px in peak_coords:
675
+ fy_peak = py - ny // 2
676
+ fx_peak = px - nx // 2
677
+
678
+ # Compute period for this peak (per-axis frequency normalization)
679
+ freq = np.sqrt((fy_peak / ny) ** 2 + (fx_peak / nx) ** 2)
680
+ if freq > 0:
681
+ period = 1.0 / freq
682
+ else:
683
+ period = np.inf
684
+
685
+ peaks.append((fy_peak, fx_peak, period))
686
+
687
+ if verbose:
688
+ print(f" Peak at ({fy_peak:4d}, {fx_peak:4d}): period = {period:.1f} px")
689
+
690
+ # Add symmetric peaks (Fourier transform of real signal is conjugate symmetric)
691
+ symmetric_peaks = []
692
+ for fy, fx, period in peaks:
693
+ symmetric_peaks.append((-fy, -fx, period))
694
+
695
+ peaks.extend(symmetric_peaks)
696
+
697
+ # Remove duplicates
698
+ peaks = list(set(peaks))
699
+
700
+ if verbose:
701
+ print(f"Total peaks (with symmetry): {len(peaks)}")
702
+
703
+ return peaks
704
+
705
+
706
+ def create_notch_filter(shape, peaks, width=3.0, smooth=True):
707
+ """
708
+ Create notch filter to suppress specific Fourier peaks.
709
+
710
+ Parameters
711
+ ----------
712
+ shape : tuple
713
+ Image shape (ny, nx)
714
+ peaks : list of tuples
715
+ Peak positions [(fy1, fx1), (fy2, fx2), ...] or [(fy1, fx1, period1), ...]
716
+ width : float
717
+ Notch width in frequency pixels
718
+ smooth : bool
719
+ If True, use Gaussian notch; if False, use hard cutoff
720
+
721
+ Returns
722
+ -------
723
+ filter : ndarray
724
+ 2D filter mask (1 = pass, 0 = suppress)
725
+ """
726
+ ny, nx = shape
727
+ filter_mask = np.ones((ny, nx), dtype=float)
728
+
729
+ # Create coordinate grids (centered at ny//2, nx//2)
730
+ y = np.arange(ny) - ny // 2
731
+ x = np.arange(nx) - nx // 2
732
+ Y, X = np.meshgrid(y, x, indexing='ij')
733
+
734
+ # Create notch for each peak
735
+ for peak in peaks:
736
+ # Handle both (fy, fx) and (fy, fx, period) formats
737
+ if len(peak) == 3:
738
+ fy, fx, period = peak
739
+ else:
740
+ fy, fx = peak
741
+
742
+ if smooth:
743
+ # Gaussian notch: gradually suppress around peak
744
+ r2 = (Y - fy) ** 2 + (X - fx) ** 2
745
+ notch = 1.0 - np.exp(-r2 / (2 * width**2))
746
+ else:
747
+ # Hard circular notch
748
+ r = np.sqrt((Y - fy) ** 2 + (X - fx) ** 2)
749
+ notch = np.where(r < width, 0.0, 1.0)
750
+
751
+ filter_mask *= notch
752
+
753
+ return filter_mask
754
+
755
+
756
+ def create_bandpass_filter(shape, period_range, smooth=True):
757
+ """
758
+ Create band-stop (notch) filter to suppress specific period range.
759
+
760
+ This is actually a band-STOP filter that suppresses frequencies
761
+ corresponding to the given period range while passing others.
762
+
763
+ Parameters
764
+ ----------
765
+ shape : tuple
766
+ Image shape (ny, nx)
767
+ period_range : tuple
768
+ (min_period, max_period) in pixels to SUPPRESS
769
+ smooth : bool
770
+ If True, use smooth Gaussian rolloff; if False, hard cutoff
771
+
772
+ Returns
773
+ -------
774
+ filter : ndarray
775
+ 2D band-stop filter (1 = pass, 0 = suppress, smooth transition if smooth=True)
776
+ """
777
+ ny, nx = shape
778
+
779
+ # Create frequency grids
780
+ fy = np.fft.fftshift(np.fft.fftfreq(ny))
781
+ fx = np.fft.fftshift(np.fft.fftfreq(nx))
782
+ FY, FX = np.meshgrid(fy, fx, indexing='ij')
783
+ freq_radius = np.sqrt(FY**2 + FX**2)
784
+
785
+ # Convert periods to frequencies
786
+ freq_center = 0.5 * (1.0 / period_range[0] + 1.0 / period_range[1])
787
+ freq_width = abs(1.0 / period_range[0] - 1.0 / period_range[1])
788
+
789
+ if smooth:
790
+ # Gaussian notch centered on freq_center
791
+ # Width chosen so 3-sigma spans the frequency range
792
+ sigma = freq_width / 3.0
793
+ bandstop = 1.0 - np.exp(-((freq_radius - freq_center) ** 2) / (2 * sigma**2))
794
+ else:
795
+ # Hard cutoff: suppress frequencies in range
796
+ freq_min = 1.0 / period_range[1] # Large period -> low frequency
797
+ freq_max = 1.0 / period_range[0] # Small period -> high frequency
798
+
799
+ bandstop = np.where(
800
+ (freq_radius >= freq_min) & (freq_radius <= freq_max),
801
+ 0.0, # Suppress in band
802
+ 1.0, # Pass outside band
803
+ )
804
+
805
+ return bandstop
806
+
807
+
808
+ def remove_fringes_fourier(
809
+ image,
810
+ mask=None,
811
+ fwhm=None,
812
+ method='global',
813
+ period_range=(30, 200),
814
+ sigma_threshold=2.0,
815
+ notch_width=3.0,
816
+ protect_low_freq=True,
817
+ protect_high_freq=True,
818
+ get_fringe_model=False,
819
+ verbose=False,
820
+ ):
821
+ """
822
+ Remove periodic patterns from image using Fourier notch filtering.
823
+
824
+ Suitable for genuinely periodic, stationary patterns (electronic pickup
825
+ noise, readout banding) that produce sharp peaks in Fourier space.
826
+ Realistic sky fringes are usually curved and non-stationary, with power
827
+ spread over a broad frequency annulus - use :func:`remove_fringes` for
828
+ those instead.
829
+
830
+ Parameters
831
+ ----------
832
+ image : ndarray
833
+ Input image
834
+ mask : ndarray, optional
835
+ Boolean mask (True = masked pixels to exclude from analysis)
836
+ fwhm : float, optional
837
+ Stellar FWHM (pixels) for protecting high-frequency stellar content.
838
+ If None, no high-frequency protection is applied.
839
+ method : str
840
+ Pattern removal method:
841
+
842
+ - 'global': Detect and notch out power spectrum peaks (default)
843
+ - 'bandpass': Suppress a whole frequency range given by period_range
844
+ period_range : tuple
845
+ Expected pattern period range (min, max) in pixels.
846
+ Default: (30, 200)
847
+ sigma_threshold : float
848
+ Threshold for automatic peak detection (in sigma above median power).
849
+ Higher = more conservative. Default: 2.0
850
+ notch_width : float
851
+ Width of notch filter in frequency pixels.
852
+ Larger = more aggressive. Default: 3.0
853
+ protect_low_freq : bool
854
+ If True, don't filter very low frequencies (large-scale background).
855
+ Default: True
856
+ protect_high_freq : bool
857
+ If True, protect high frequencies based on stellar FWHM.
858
+ Requires `fwhm`. Default: True
859
+ get_fringe_model : bool
860
+ If True, return (corrected_image, fringe_model).
861
+ Default: False
862
+ verbose : bool or callable
863
+ Print diagnostic information
864
+
865
+ Returns
866
+ -------
867
+ corrected_image : ndarray
868
+ Image with the periodic pattern removed
869
+ fringe_model : ndarray, optional
870
+ Removed pattern (if get_fringe_model=True)
871
+
872
+ Examples
873
+ --------
874
+ >>> # Automatic peak detection and notching
875
+ >>> corrected = remove_fringes_fourier(image, fwhm=3.0)
876
+ >>> # Bandpass filtering for known period range
877
+ >>> corrected = remove_fringes_fourier(
878
+ ... image, method='bandpass', period_range=(50, 150), fwhm=3.0)
879
+ """
880
+ # Setup logging
881
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
882
+
883
+ log("Fourier notch pattern removal, method: %s" % method)
884
+
885
+ # Prepare image
886
+ img = image.copy()
887
+ ny, nx = img.shape
888
+
889
+ # Handle NaNs and masked pixels
890
+ if mask is not None and np.any(mask):
891
+ from scipy.ndimage import median_filter
892
+
893
+ size = int(max(3, 2 * fwhm)) if fwhm is not None else 5
894
+ img_filled = median_filter(img, size=size)
895
+ img[mask] = img_filled[mask]
896
+
897
+ # Compute FFT
898
+ log("Computing FFT...")
899
+ fft = np.fft.fft2(img)
900
+ fft_shifted = np.fft.fftshift(fft)
901
+
902
+ # Create filter based on method
903
+ if method == 'global':
904
+ log(f"Detecting pattern peaks (threshold={sigma_threshold:.1f} sigma)...")
905
+
906
+ # Detect peaks in power spectrum
907
+ peaks = detect_fringe_peaks_fft(
908
+ image,
909
+ mask=mask,
910
+ sigma_threshold=sigma_threshold,
911
+ period_range=period_range,
912
+ fwhm=fwhm,
913
+ protect_low_freq=protect_low_freq,
914
+ protect_high_freq=protect_high_freq,
915
+ verbose=verbose,
916
+ )
917
+
918
+ if len(peaks) == 0:
919
+ log("No pattern peaks detected.")
920
+ if get_fringe_model:
921
+ return image, np.zeros_like(image)
922
+ else:
923
+ return image
924
+
925
+ log(f"Creating notch filter for {len(peaks)} peaks...")
926
+ notch_filter = create_notch_filter(img.shape, peaks, width=notch_width, smooth=True)
927
+
928
+ elif method == 'bandpass':
929
+ log(f"Creating bandpass filter for period range {period_range} pixels...")
930
+ notch_filter = create_bandpass_filter(img.shape, period_range, smooth=True)
931
+
932
+ else:
933
+ raise ValueError(f"Unknown method: {method}")
934
+
935
+ # Apply filter
936
+ log("Applying filter...")
937
+ fft_filtered = fft_shifted * notch_filter
938
+
939
+ # Inverse FFT
940
+ log("Computing inverse FFT...")
941
+ fft_unshifted = np.fft.ifftshift(fft_filtered)
942
+ img_corrected = np.real(np.fft.ifft2(fft_unshifted))
943
+
944
+ # Restore masked pixels from original
945
+ if mask is not None:
946
+ img_corrected[mask] = image[mask]
947
+
948
+ log("Done.")
949
+
950
+ # Ensure C-contiguous array for compatibility with SEP and other tools
951
+ img_corrected = np.ascontiguousarray(img_corrected)
952
+
953
+ if get_fringe_model:
954
+ fringe_model = image - img_corrected
955
+ return img_corrected, fringe_model
956
+ else:
957
+ return img_corrected
958
+
959
+
960
+ def visualize_fourier_spectrum(
961
+ image,
962
+ peaks=None,
963
+ period_range=None,
964
+ fwhm=None,
965
+ log_scale=True,
966
+ vmax_percentile=99.9,
967
+ save_path=None,
968
+ ):
969
+ """
970
+ Visualize 2D Fourier power spectrum with detected peaks.
971
+
972
+ Useful for debugging and parameter tuning.
973
+
974
+ Parameters
975
+ ----------
976
+ image : ndarray
977
+ Input image
978
+ peaks : list of tuples, optional
979
+ Detected peak positions [(fy, fx), ...]
980
+ period_range : tuple, optional
981
+ If provided, overlay search region
982
+ fwhm : float, optional
983
+ If provided, overlay high-frequency protection boundary
984
+ log_scale : bool
985
+ Use log scale for power spectrum
986
+ vmax_percentile : float
987
+ Percentile for color scale maximum
988
+ save_path : str, optional
989
+ If provided, save plot to this path
990
+
991
+ Returns
992
+ -------
993
+ fig : matplotlib.figure.Figure
994
+ The figure object
995
+ """
996
+ import matplotlib.pyplot as plt
997
+
998
+ # Compute FFT and power
999
+ fft = np.fft.fft2(image)
1000
+ fft_shifted = np.fft.fftshift(fft)
1001
+ power = np.abs(fft_shifted) ** 2
1002
+
1003
+ if log_scale:
1004
+ power_plot = np.log10(power + 1)
1005
+ label = 'Log₁₀(Power + 1)'
1006
+ else:
1007
+ power_plot = power
1008
+ label = 'Power'
1009
+
1010
+ # Create figure
1011
+ fig, ax = plt.subplots(figsize=(10, 10))
1012
+
1013
+ # Plot power spectrum
1014
+ im = ax.imshow(
1015
+ power_plot,
1016
+ origin='lower',
1017
+ cmap='viridis',
1018
+ vmin=0,
1019
+ vmax=np.percentile(power_plot, vmax_percentile),
1020
+ )
1021
+ plt.colorbar(im, ax=ax, label=label)
1022
+
1023
+ ny, nx = image.shape
1024
+ center_y, center_x = ny // 2, nx // 2
1025
+
1026
+ # Overlay period_range as circle
1027
+ if period_range is not None:
1028
+ # Convert periods to frequency radius (in pixels)
1029
+ freq_min = ny / period_range[1] # Large period → small radius
1030
+ freq_max = ny / period_range[0] # Small period → large radius
1031
+
1032
+ circle_inner = plt.Circle(
1033
+ (center_x, center_y),
1034
+ freq_min,
1035
+ fill=False,
1036
+ edgecolor='red',
1037
+ linestyle='--',
1038
+ linewidth=2,
1039
+ label=f'Period range: {period_range[0]}-{period_range[1]} px',
1040
+ )
1041
+ circle_outer = plt.Circle(
1042
+ (center_x, center_y), freq_max, fill=False, edgecolor='red', linestyle='--', linewidth=2
1043
+ )
1044
+ ax.add_patch(circle_inner)
1045
+ ax.add_patch(circle_outer)
1046
+
1047
+ # Overlay FWHM protection boundary
1048
+ if fwhm is not None:
1049
+ freq_stellar = ny / (2.0 * fwhm)
1050
+ circle_fwhm = plt.Circle(
1051
+ (center_x, center_y),
1052
+ freq_stellar,
1053
+ fill=False,
1054
+ edgecolor='yellow',
1055
+ linestyle=':',
1056
+ linewidth=2,
1057
+ label=f'FWHM protection (FWHM={fwhm:.1f} px)',
1058
+ )
1059
+ ax.add_patch(circle_fwhm)
1060
+
1061
+ # Mark detected peaks
1062
+ if peaks is not None and len(peaks) > 0:
1063
+ for peak in peaks:
1064
+ if len(peak) == 3:
1065
+ fy, fx, period = peak
1066
+ label_text = f'{period:.0f}px'
1067
+ else:
1068
+ fy, fx = peak
1069
+ label_text = ''
1070
+
1071
+ ax.plot(center_x + fx, center_y + fy, 'r+', markersize=15, markeredgewidth=2)
1072
+ if label_text:
1073
+ ax.text(center_x + fx + 5, center_y + fy + 5, label_text, color='red', fontsize=10)
1074
+
1075
+ ax.set_title('2D Fourier Power Spectrum')
1076
+ ax.set_xlabel('Frequency X')
1077
+ ax.set_ylabel('Frequency Y')
1078
+
1079
+ if period_range or fwhm:
1080
+ ax.legend()
1081
+
1082
+ if save_path:
1083
+ plt.savefig(save_path, dpi=150, bbox_inches='tight')
1084
+ print(f"Saved to {save_path}")
1085
+
1086
+ return fig