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.
- stdpipe/__init__.py +9 -0
- stdpipe/artefacts.py +370 -0
- stdpipe/astrometry.py +2001 -0
- stdpipe/astrometry_quad.py +1643 -0
- stdpipe/astrometry_wcs.py +1141 -0
- stdpipe/catalogs.py +942 -0
- stdpipe/cutouts.py +570 -0
- stdpipe/data/legacysurvey_bricks.fits.gz +0 -0
- stdpipe/data/ps1skycells.txt +199708 -0
- stdpipe/db.py +130 -0
- stdpipe/fringe_removal.py +1086 -0
- stdpipe/lcs.py +440 -0
- stdpipe/photometry.py +2340 -0
- stdpipe/photometry_background.py +998 -0
- stdpipe/photometry_iraf.py +913 -0
- stdpipe/photometry_measure.py +2268 -0
- stdpipe/photometry_model.py +931 -0
- stdpipe/photometry_psf.py +1124 -0
- stdpipe/photometry_quality.py +185 -0
- stdpipe/pipeline.py +1303 -0
- stdpipe/plots.py +1582 -0
- stdpipe/psf.py +1542 -0
- stdpipe/realbogus.py +971 -0
- stdpipe/realbogus_features.py +1657 -0
- stdpipe/reproject.py +943 -0
- stdpipe/resolve.py +267 -0
- stdpipe/sfft.py +1077 -0
- stdpipe/simulation.py +2131 -0
- stdpipe/smoothing.py +488 -0
- stdpipe/subtraction.py +1324 -0
- stdpipe/templates.py +929 -0
- stdpipe/utils.py +365 -0
- stdpipe-0.4.0.dist-info/METADATA +171 -0
- stdpipe-0.4.0.dist-info/RECORD +37 -0
- stdpipe-0.4.0.dist-info/WHEEL +5 -0
- stdpipe-0.4.0.dist-info/licenses/LICENSE.md +21 -0
- stdpipe-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -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
|