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,998 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Background estimation routines for astronomical images.
|
|
3
|
+
|
|
4
|
+
This module provides various methods for estimating and subtracting background
|
|
5
|
+
from astronomical images, including grid-based methods (SEP, photutils) and
|
|
6
|
+
non-grid methods (percentile filtering, morphological opening).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from astropy.stats import mad_std
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_background_percentile(
|
|
15
|
+
image, mask=None, size=51, percentile=25.0, maxiters=3, sigma=3.0, use_mode=False
|
|
16
|
+
):
|
|
17
|
+
"""
|
|
18
|
+
Background estimation via percentile filtering (non-grid method).
|
|
19
|
+
|
|
20
|
+
Uses a sliding window to compute low percentile value at each pixel.
|
|
21
|
+
Stars (positive outliers) are naturally above the percentile and thus rejected.
|
|
22
|
+
|
|
23
|
+
This is a truly non-grid method: operates on all pixels with sliding window,
|
|
24
|
+
not limited by grid cell resolution.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
image : ndarray
|
|
29
|
+
Input image
|
|
30
|
+
mask : ndarray, optional
|
|
31
|
+
Boolean mask (True = masked pixels to exclude)
|
|
32
|
+
size : int
|
|
33
|
+
Filter kernel size (should be larger than typical star size, e.g., 51-101 pixels).
|
|
34
|
+
Larger values are more robust but may miss fine background structure.
|
|
35
|
+
percentile : float
|
|
36
|
+
Percentile value to use (0-100). Typical values:
|
|
37
|
+
- 25: 25th percentile (default, good for moderate star density)
|
|
38
|
+
- 10: More aggressive star rejection (for crowded fields)
|
|
39
|
+
- 50: Median (less star rejection but more robust to noise)
|
|
40
|
+
maxiters : int
|
|
41
|
+
Number of sigma-clipping iterations to refine percentile estimate
|
|
42
|
+
sigma : float
|
|
43
|
+
Sigma threshold for clipping
|
|
44
|
+
use_mode : bool
|
|
45
|
+
If True, estimate mode instead of percentile (experimental)
|
|
46
|
+
|
|
47
|
+
Returns
|
|
48
|
+
-------
|
|
49
|
+
background : ndarray
|
|
50
|
+
Estimated background map
|
|
51
|
+
|
|
52
|
+
Notes
|
|
53
|
+
-----
|
|
54
|
+
- Computationally intensive for large kernels (scales as O(N * kernel^2))
|
|
55
|
+
- Consider using smaller kernels (31-51) for large images
|
|
56
|
+
- For very crowded fields, use lower percentiles (10-15)
|
|
57
|
+
- Handles arbitrary background shapes (not limited to polynomial forms)
|
|
58
|
+
"""
|
|
59
|
+
from scipy.ndimage import percentile_filter, median_filter
|
|
60
|
+
|
|
61
|
+
# Work with a copy to avoid modifying input
|
|
62
|
+
img = image.copy()
|
|
63
|
+
|
|
64
|
+
# Apply mask by replacing with NaN (will be ignored in percentile computation)
|
|
65
|
+
has_nan = False
|
|
66
|
+
if mask is not None:
|
|
67
|
+
img[mask] = np.nan
|
|
68
|
+
has_nan = True
|
|
69
|
+
|
|
70
|
+
# Fix #3: Define masked_percentile closure once before the loop
|
|
71
|
+
def masked_percentile(values):
|
|
72
|
+
valid = values[~np.isnan(values)]
|
|
73
|
+
if len(valid) < 3:
|
|
74
|
+
return np.nan
|
|
75
|
+
return np.percentile(valid, percentile)
|
|
76
|
+
|
|
77
|
+
# Iterative sigma-clipping approach
|
|
78
|
+
for iteration in range(maxiters):
|
|
79
|
+
if use_mode:
|
|
80
|
+
# Mode estimation via histogram (in sliding window)
|
|
81
|
+
# This is expensive, so use median as approximation
|
|
82
|
+
back = median_filter(img, size=size)
|
|
83
|
+
else:
|
|
84
|
+
# Fix #2 & #4: Use fast percentile_filter with NaN-filling approach
|
|
85
|
+
# instead of slow generic_filter with Python callback
|
|
86
|
+
if has_nan:
|
|
87
|
+
# Two-pass approach: fill NaN with local median, then run fast filter
|
|
88
|
+
# Pass 1: Fill NaN pixels with local median
|
|
89
|
+
img_filled = img.copy()
|
|
90
|
+
nan_mask = np.isnan(img_filled)
|
|
91
|
+
if np.any(nan_mask):
|
|
92
|
+
# Use median filter to get fill values
|
|
93
|
+
# Temporarily replace NaN with image median for the filter
|
|
94
|
+
fill_val = np.nanmedian(img_filled)
|
|
95
|
+
img_filled[nan_mask] = fill_val
|
|
96
|
+
local_med = median_filter(img_filled, size=min(size, 15))
|
|
97
|
+
img_filled[nan_mask] = local_med[nan_mask]
|
|
98
|
+
|
|
99
|
+
# Pass 2: Run fast C-based percentile_filter on the filled image
|
|
100
|
+
back = percentile_filter(img_filled, percentile=percentile, size=size)
|
|
101
|
+
else:
|
|
102
|
+
back = percentile_filter(img, percentile=percentile, size=size)
|
|
103
|
+
|
|
104
|
+
# Sigma-clip for next iteration
|
|
105
|
+
if iteration < maxiters - 1:
|
|
106
|
+
residual = img - back
|
|
107
|
+
# Estimate RMS from negative residuals (not contaminated by stars)
|
|
108
|
+
neg_residuals = residual[residual < 0]
|
|
109
|
+
if len(neg_residuals) > 100:
|
|
110
|
+
rms = np.std(neg_residuals) * np.sqrt(2) # Correct for one-sided
|
|
111
|
+
else:
|
|
112
|
+
rms = mad_std(residual[np.isfinite(residual)])
|
|
113
|
+
|
|
114
|
+
# Clip positive outliers (stars)
|
|
115
|
+
outliers = residual > sigma * rms
|
|
116
|
+
img[outliers] = np.nan
|
|
117
|
+
if np.any(outliers):
|
|
118
|
+
has_nan = True
|
|
119
|
+
|
|
120
|
+
# Fill any remaining NaN values
|
|
121
|
+
if np.any(np.isnan(back)):
|
|
122
|
+
valid = np.isfinite(back)
|
|
123
|
+
if np.sum(valid) > 0:
|
|
124
|
+
# Use median of valid pixels
|
|
125
|
+
fill_value = np.median(back[valid])
|
|
126
|
+
back[~valid] = fill_value
|
|
127
|
+
|
|
128
|
+
return back
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_background_morphology(
|
|
132
|
+
image, mask=None, size=25, iterations=1, smooth=True, smooth_size=None
|
|
133
|
+
):
|
|
134
|
+
"""
|
|
135
|
+
Background estimation via morphological opening (non-grid method).
|
|
136
|
+
|
|
137
|
+
Performs morphological opening (erosion followed by dilation) which removes
|
|
138
|
+
structures smaller than the structuring element (stars). Fast and simple.
|
|
139
|
+
|
|
140
|
+
This is a truly non-grid method: operates on all pixels with sliding window.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
image : ndarray
|
|
145
|
+
Input image
|
|
146
|
+
mask : ndarray, optional
|
|
147
|
+
Boolean mask (True = masked pixels to exclude)
|
|
148
|
+
size : int
|
|
149
|
+
Size of structuring element (should be larger than typical star size).
|
|
150
|
+
Typical values: 15-35 pixels depending on seeing.
|
|
151
|
+
iterations : int
|
|
152
|
+
Number of opening iterations (default: 1, more iterations = more aggressive)
|
|
153
|
+
smooth : bool
|
|
154
|
+
If True, apply additional smoothing to reduce kernel-sized artifacts.
|
|
155
|
+
This significantly improves the smoothness of the result.
|
|
156
|
+
smooth_size : int, optional
|
|
157
|
+
Size of smoothing kernel in pixels. If None, automatically set based on
|
|
158
|
+
the structuring element size. Use larger values for smoother results.
|
|
159
|
+
|
|
160
|
+
Returns
|
|
161
|
+
-------
|
|
162
|
+
background : ndarray
|
|
163
|
+
Estimated background map
|
|
164
|
+
|
|
165
|
+
Notes
|
|
166
|
+
-----
|
|
167
|
+
- Very fast (much faster than percentile filtering)
|
|
168
|
+
- Simple and robust
|
|
169
|
+
- Can create kernel-sized artifacts without smoothing
|
|
170
|
+
- May suppress legitimate background features similar in size to stars
|
|
171
|
+
- Best for images with clear point sources on smooth backgrounds
|
|
172
|
+
- Use smooth=True (default) to eliminate spotty kernel artifacts
|
|
173
|
+
"""
|
|
174
|
+
from scipy.ndimage import grey_opening, median_filter, gaussian_filter
|
|
175
|
+
|
|
176
|
+
# Work with a copy
|
|
177
|
+
img = image.copy()
|
|
178
|
+
|
|
179
|
+
# Handle mask by replacing with local median
|
|
180
|
+
if mask is not None:
|
|
181
|
+
# Fill masked regions with local median to avoid artifacts
|
|
182
|
+
if np.any(mask):
|
|
183
|
+
# Simple approach: use median filter to fill masked regions
|
|
184
|
+
img_filled = median_filter(img, size=5)
|
|
185
|
+
img[mask] = img_filled[mask]
|
|
186
|
+
|
|
187
|
+
# Morphological opening with circular structuring element
|
|
188
|
+
# Create circular structuring element
|
|
189
|
+
y, x = np.ogrid[-size // 2 : size // 2 + 1, -size // 2 : size // 2 + 1]
|
|
190
|
+
structure = (x * x + y * y) <= (size / 2) ** 2
|
|
191
|
+
|
|
192
|
+
# Apply opening
|
|
193
|
+
back = grey_opening(img, structure=structure)
|
|
194
|
+
|
|
195
|
+
# Additional iterations if requested
|
|
196
|
+
for i in range(iterations - 1):
|
|
197
|
+
back = grey_opening(back, structure=structure)
|
|
198
|
+
|
|
199
|
+
# Apply smoothing to reduce kernel-sized artifacts
|
|
200
|
+
# Fix #6: smooth_size consistently means filter size in pixels for both paths
|
|
201
|
+
if smooth:
|
|
202
|
+
if mask is not None and np.any(mask):
|
|
203
|
+
# Use median filter for masked images (more robust to artifacts)
|
|
204
|
+
if smooth_size is None:
|
|
205
|
+
filter_size = max(5, size // 5)
|
|
206
|
+
else:
|
|
207
|
+
filter_size = int(smooth_size)
|
|
208
|
+
back = median_filter(back, size=filter_size)
|
|
209
|
+
else:
|
|
210
|
+
# Use Gaussian smoothing for clean images (smoother results)
|
|
211
|
+
if smooth_size is None:
|
|
212
|
+
# Default: adaptive smoothing based on kernel size
|
|
213
|
+
sigma_val = max(2.0, size / 5.0)
|
|
214
|
+
else:
|
|
215
|
+
# Convert filter size to sigma (size ~ 3*sigma is a reasonable heuristic)
|
|
216
|
+
sigma_val = smooth_size / 3.0
|
|
217
|
+
back = gaussian_filter(back, sigma=sigma_val, mode='reflect')
|
|
218
|
+
|
|
219
|
+
return back
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def estimate_background_rms_percentile(image, mask=None, size=51):
|
|
223
|
+
"""
|
|
224
|
+
Estimate background RMS using percentile range.
|
|
225
|
+
|
|
226
|
+
Uses interquartile range (IQR) as robust RMS estimator.
|
|
227
|
+
|
|
228
|
+
Parameters
|
|
229
|
+
----------
|
|
230
|
+
image : ndarray
|
|
231
|
+
Input image
|
|
232
|
+
mask : ndarray, optional
|
|
233
|
+
Boolean mask (True = masked pixels)
|
|
234
|
+
size : int
|
|
235
|
+
Kernel size for local percentile estimation
|
|
236
|
+
|
|
237
|
+
Returns
|
|
238
|
+
-------
|
|
239
|
+
rms : ndarray
|
|
240
|
+
Background RMS map
|
|
241
|
+
"""
|
|
242
|
+
from scipy.ndimage import percentile_filter, median_filter
|
|
243
|
+
|
|
244
|
+
# Fix #4: Replace slow generic_filter with fast two-pass approach
|
|
245
|
+
if mask is not None:
|
|
246
|
+
img = image.copy()
|
|
247
|
+
img[mask] = np.nan
|
|
248
|
+
|
|
249
|
+
# Two-pass: fill NaN with local median, then run fast percentile_filter
|
|
250
|
+
nan_mask = np.isnan(img)
|
|
251
|
+
if np.any(nan_mask):
|
|
252
|
+
fill_val = np.nanmedian(img)
|
|
253
|
+
img_filled = img.copy()
|
|
254
|
+
img_filled[nan_mask] = fill_val
|
|
255
|
+
local_med = median_filter(img_filled, size=min(size, 15))
|
|
256
|
+
img_filled[nan_mask] = local_med[nan_mask]
|
|
257
|
+
else:
|
|
258
|
+
img_filled = img
|
|
259
|
+
|
|
260
|
+
# Fix #9: Always use p25/p75 for IQR regardless of caller's percentile
|
|
261
|
+
p25 = percentile_filter(img_filled, percentile=25, size=size)
|
|
262
|
+
p75 = percentile_filter(img_filled, percentile=75, size=size)
|
|
263
|
+
else:
|
|
264
|
+
p25 = percentile_filter(image, percentile=25, size=size)
|
|
265
|
+
p75 = percentile_filter(image, percentile=75, size=size)
|
|
266
|
+
|
|
267
|
+
# IQR / 1.349 ≈ sigma for Gaussian
|
|
268
|
+
rms = (p75 - p25) / 1.349
|
|
269
|
+
|
|
270
|
+
return rms
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def estimate_background_rms_local(image, background, mask=None, size=51):
|
|
274
|
+
"""
|
|
275
|
+
Estimate background RMS from residuals.
|
|
276
|
+
|
|
277
|
+
Computes local standard deviation of (image - background).
|
|
278
|
+
|
|
279
|
+
Parameters
|
|
280
|
+
----------
|
|
281
|
+
image : ndarray
|
|
282
|
+
Input image
|
|
283
|
+
background : ndarray
|
|
284
|
+
Estimated background
|
|
285
|
+
mask : ndarray, optional
|
|
286
|
+
Boolean mask (True = masked pixels)
|
|
287
|
+
size : int
|
|
288
|
+
Kernel size for local RMS estimation
|
|
289
|
+
|
|
290
|
+
Returns
|
|
291
|
+
-------
|
|
292
|
+
rms : ndarray
|
|
293
|
+
Background RMS map
|
|
294
|
+
"""
|
|
295
|
+
from scipy.ndimage import uniform_filter
|
|
296
|
+
|
|
297
|
+
residual = image - background
|
|
298
|
+
|
|
299
|
+
if mask is not None:
|
|
300
|
+
residual = residual.copy()
|
|
301
|
+
residual[mask] = np.nan
|
|
302
|
+
|
|
303
|
+
# Fix #4: Replace slow generic_filter with fast uniform_filter-based local std
|
|
304
|
+
# For masked data, fill NaN first then use fast filters
|
|
305
|
+
has_nan = np.any(np.isnan(residual))
|
|
306
|
+
|
|
307
|
+
if has_nan:
|
|
308
|
+
nan_mask = np.isnan(residual)
|
|
309
|
+
res_filled = residual.copy()
|
|
310
|
+
fill_val = np.nanmedian(res_filled)
|
|
311
|
+
res_filled[nan_mask] = fill_val
|
|
312
|
+
|
|
313
|
+
# Local mean and mean of squares via uniform_filter
|
|
314
|
+
local_mean = uniform_filter(res_filled, size=size, mode='reflect')
|
|
315
|
+
local_mean_sq = uniform_filter(res_filled**2, size=size, mode='reflect')
|
|
316
|
+
else:
|
|
317
|
+
local_mean = uniform_filter(residual, size=size, mode='reflect')
|
|
318
|
+
local_mean_sq = uniform_filter(residual**2, size=size, mode='reflect')
|
|
319
|
+
|
|
320
|
+
# Var = E[X^2] - E[X]^2
|
|
321
|
+
local_var = local_mean_sq - local_mean**2
|
|
322
|
+
# Clamp negative values from numerical precision
|
|
323
|
+
local_var = np.maximum(local_var, 0.0)
|
|
324
|
+
rms = np.sqrt(local_var)
|
|
325
|
+
|
|
326
|
+
# Fill NaN values
|
|
327
|
+
if np.any(np.isnan(rms)):
|
|
328
|
+
valid = np.isfinite(rms)
|
|
329
|
+
if np.sum(valid) > 0:
|
|
330
|
+
fill_value = np.median(rms[valid])
|
|
331
|
+
rms[~valid] = fill_value
|
|
332
|
+
|
|
333
|
+
return rms
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def get_background_polynomial(
|
|
337
|
+
image,
|
|
338
|
+
mask=None,
|
|
339
|
+
order=1,
|
|
340
|
+
sigma=3.0,
|
|
341
|
+
maxiters=5,
|
|
342
|
+
directional=False,
|
|
343
|
+
direction=None,
|
|
344
|
+
):
|
|
345
|
+
"""Estimate background as a low-order 2D polynomial.
|
|
346
|
+
|
|
347
|
+
Fits a smooth polynomial surface to the image using iterative
|
|
348
|
+
sigma-clipping to reject stars and other outliers. By construction,
|
|
349
|
+
low-order polynomials cannot represent localised features such as
|
|
350
|
+
nebulae, so only the smooth gradient component is captured.
|
|
351
|
+
|
|
352
|
+
Parameters
|
|
353
|
+
----------
|
|
354
|
+
image : 2D ndarray
|
|
355
|
+
Input image.
|
|
356
|
+
mask : 2D bool ndarray, optional
|
|
357
|
+
Pixels to exclude (True = masked).
|
|
358
|
+
order : int
|
|
359
|
+
Polynomial order: 1 = plane, 2 = quadratic, 3 = cubic, etc.
|
|
360
|
+
Default 1.
|
|
361
|
+
sigma : float
|
|
362
|
+
Sigma threshold for iterative outlier clipping. Default 3.0.
|
|
363
|
+
maxiters : int
|
|
364
|
+
Maximum number of sigma-clipping iterations. Default 5.
|
|
365
|
+
directional : bool or float
|
|
366
|
+
Controls directional constraint for higher-order (≥ 2) terms.
|
|
367
|
+
|
|
368
|
+
- ``False`` (default) — use all 2D polynomial terms.
|
|
369
|
+
- ``True`` — auto-detect the gradient direction from a linear
|
|
370
|
+
fit, then restrict order ≥ 2 terms to powers of the
|
|
371
|
+
directional coordinate *t* = *x* cos θ + *y* sin θ.
|
|
372
|
+
This prevents high-order polynomials from fitting localised
|
|
373
|
+
features while still capturing curvature along the gradient.
|
|
374
|
+
- A ``float`` value — same as ``True`` but uses the given angle
|
|
375
|
+
(degrees, 0 = x-axis, 90 = y-axis) instead of auto-detecting.
|
|
376
|
+
|
|
377
|
+
For order ≤ 1 this parameter has no effect (a plane already
|
|
378
|
+
has only 3 coefficients).
|
|
379
|
+
direction : float or None
|
|
380
|
+
*Deprecated* synonym for ``directional=<angle>``. If both are
|
|
381
|
+
given, *directional* takes precedence.
|
|
382
|
+
|
|
383
|
+
Returns
|
|
384
|
+
-------
|
|
385
|
+
background : 2D ndarray
|
|
386
|
+
Fitted polynomial background, same shape as *image*.
|
|
387
|
+
rms : float
|
|
388
|
+
Scalar RMS of the residuals (clipped pixels excluded).
|
|
389
|
+
|
|
390
|
+
Notes
|
|
391
|
+
-----
|
|
392
|
+
**Full 2D mode** (``directional=False``):
|
|
393
|
+
|
|
394
|
+
Number of polynomial coefficients = (order+1)(order+2)/2:
|
|
395
|
+
order 1 → 3, order 2 → 6, order 3 → 10, order 4 → 15.
|
|
396
|
+
|
|
397
|
+
**Directional mode** (``directional=True`` or angle):
|
|
398
|
+
|
|
399
|
+
Number of coefficients = order + 2 (full linear + higher-order
|
|
400
|
+
directional terms): order 2 → 4, order 3 → 5, order 4 → 6.
|
|
401
|
+
|
|
402
|
+
Coordinates are normalised to [-1, 1] internally for numerical
|
|
403
|
+
stability. The fit uses random sub-sampling (100 000 points) for
|
|
404
|
+
images larger than ~300×300 to keep memory and CPU bounded.
|
|
405
|
+
|
|
406
|
+
Examples
|
|
407
|
+
--------
|
|
408
|
+
Remove a linear gradient:
|
|
409
|
+
|
|
410
|
+
>>> bg, rms = get_background_polynomial(image, order=1)
|
|
411
|
+
|
|
412
|
+
Fit a cubic curve along the gradient direction (5 parameters):
|
|
413
|
+
|
|
414
|
+
>>> bg, rms = get_background_polynomial(image, order=3, directional=True)
|
|
415
|
+
|
|
416
|
+
Specify the gradient direction explicitly (e.g. 45°):
|
|
417
|
+
|
|
418
|
+
>>> bg, rms = get_background_polynomial(image, order=3, directional=45.0)
|
|
419
|
+
"""
|
|
420
|
+
ny, nx = image.shape
|
|
421
|
+
|
|
422
|
+
# --- resolve directional / direction arguments -----------------------
|
|
423
|
+
if isinstance(directional, (int, float)) and not isinstance(directional, bool):
|
|
424
|
+
# directional=45.0 → direction angle in degrees
|
|
425
|
+
direction_deg = float(directional)
|
|
426
|
+
directional = True
|
|
427
|
+
elif directional and direction is not None:
|
|
428
|
+
direction_deg = float(direction)
|
|
429
|
+
elif direction is not None:
|
|
430
|
+
direction_deg = float(direction)
|
|
431
|
+
directional = True
|
|
432
|
+
else:
|
|
433
|
+
direction_deg = None # auto-detect later if directional=True
|
|
434
|
+
|
|
435
|
+
# Directional constraint only matters for order >= 2
|
|
436
|
+
if order < 2:
|
|
437
|
+
directional = False
|
|
438
|
+
|
|
439
|
+
# --- build a fixed-size working sample -------------------------------
|
|
440
|
+
# All fitting and sigma-clipping operates on this subsample only;
|
|
441
|
+
# the full-grid evaluation is a single pass at the end.
|
|
442
|
+
MAX_SAMPLE = 100_000
|
|
443
|
+
|
|
444
|
+
data_flat = image.ravel()
|
|
445
|
+
valid_idx = np.where(np.isfinite(data_flat))[0]
|
|
446
|
+
if mask is not None:
|
|
447
|
+
valid_idx = valid_idx[~mask.ravel()[valid_idx]]
|
|
448
|
+
|
|
449
|
+
if len(valid_idx) == 0:
|
|
450
|
+
return np.full_like(image, np.nan, dtype=np.float64), np.nan
|
|
451
|
+
|
|
452
|
+
# Subsample once — reused for every clipping iteration
|
|
453
|
+
if len(valid_idx) > MAX_SAMPLE:
|
|
454
|
+
rng = np.random.RandomState(0)
|
|
455
|
+
sample_idx = rng.choice(valid_idx, MAX_SAMPLE, replace=False)
|
|
456
|
+
else:
|
|
457
|
+
sample_idx = valid_idx
|
|
458
|
+
|
|
459
|
+
# Coordinates and data for the working sample (normalised to [-1, 1])
|
|
460
|
+
x_scale = max(nx / 2, 1)
|
|
461
|
+
y_scale = max(ny / 2, 1)
|
|
462
|
+
sample_y, sample_x = np.divmod(sample_idx, nx)
|
|
463
|
+
xs = (sample_x.astype(np.float64) - nx / 2) / x_scale
|
|
464
|
+
ys = (sample_y.astype(np.float64) - ny / 2) / y_scale
|
|
465
|
+
ds = data_flat[sample_idx].astype(np.float64)
|
|
466
|
+
|
|
467
|
+
# --- helpers ---------------------------------------------------------
|
|
468
|
+
def _poly_terms(ord_):
|
|
469
|
+
"""Return list of (p, q) exponent pairs for full 2D polynomial."""
|
|
470
|
+
return [(p, q) for p in range(ord_ + 1) for q in range(ord_ + 1 - p)]
|
|
471
|
+
|
|
472
|
+
terms = _poly_terms(order)
|
|
473
|
+
|
|
474
|
+
def _build_basis(x, y, theta_rad):
|
|
475
|
+
"""Build design matrix for the current model."""
|
|
476
|
+
if directional:
|
|
477
|
+
t = x * np.cos(theta_rad) + y * np.sin(theta_rad)
|
|
478
|
+
cols = [np.ones_like(x), x, y]
|
|
479
|
+
tk = t.copy()
|
|
480
|
+
for _ in range(2, order + 1):
|
|
481
|
+
tk = tk * t
|
|
482
|
+
cols.append(tk)
|
|
483
|
+
return np.column_stack(cols)
|
|
484
|
+
cols = []
|
|
485
|
+
for p, q in terms:
|
|
486
|
+
col = np.ones_like(x)
|
|
487
|
+
if p:
|
|
488
|
+
col = col * x**p
|
|
489
|
+
if q:
|
|
490
|
+
col = col * y**q
|
|
491
|
+
cols.append(col)
|
|
492
|
+
return np.column_stack(cols)
|
|
493
|
+
|
|
494
|
+
def _eval_2d(coeffs, theta_rad):
|
|
495
|
+
"""Evaluate polynomial on the full pixel grid via broadcasting."""
|
|
496
|
+
x_1d = (np.arange(nx, dtype=np.float64) - nx / 2) / x_scale
|
|
497
|
+
y_1d = (np.arange(ny, dtype=np.float64) - ny / 2) / y_scale
|
|
498
|
+
bg = np.full((ny, nx), coeffs[0], dtype=np.float64)
|
|
499
|
+
if directional:
|
|
500
|
+
bg += coeffs[1] * x_1d[np.newaxis, :]
|
|
501
|
+
bg += coeffs[2] * y_1d[:, np.newaxis]
|
|
502
|
+
t = np.cos(theta_rad) * x_1d[np.newaxis, :] + np.sin(theta_rad) * y_1d[:, np.newaxis]
|
|
503
|
+
tk = t.copy()
|
|
504
|
+
for k in range(2, order + 1):
|
|
505
|
+
tk = tk * t
|
|
506
|
+
bg += coeffs[k + 1] * tk
|
|
507
|
+
else:
|
|
508
|
+
idx = 0
|
|
509
|
+
for p, q in terms:
|
|
510
|
+
if idx == 0:
|
|
511
|
+
idx += 1
|
|
512
|
+
continue # constant already handled
|
|
513
|
+
term = np.ones((ny, nx), dtype=np.float64)
|
|
514
|
+
if p:
|
|
515
|
+
term *= x_1d[np.newaxis, :] ** p
|
|
516
|
+
if q:
|
|
517
|
+
term *= y_1d[:, np.newaxis] ** q
|
|
518
|
+
bg += coeffs[idx] * term
|
|
519
|
+
idx += 1
|
|
520
|
+
return bg
|
|
521
|
+
|
|
522
|
+
# --- auto-detect gradient direction ----------------------------------
|
|
523
|
+
theta_rad = 0.0
|
|
524
|
+
if directional and direction_deg is None:
|
|
525
|
+
A = np.column_stack([np.ones_like(xs), xs, ys])
|
|
526
|
+
c, _, _, _ = np.linalg.lstsq(A, ds, rcond=None)
|
|
527
|
+
theta_rad = np.arctan2(c[2], c[1])
|
|
528
|
+
elif direction_deg is not None:
|
|
529
|
+
theta_rad = np.radians(direction_deg)
|
|
530
|
+
|
|
531
|
+
# --- iterative sigma-clipped polynomial fit --------------------------
|
|
532
|
+
# All iterations work on the fixed subsample (xs, ys, ds).
|
|
533
|
+
# Note: some BLAS implementations (e.g. Apple Accelerate) raise
|
|
534
|
+
# spurious divide-by-zero warnings during matmul/lstsq even when
|
|
535
|
+
# all inputs and outputs are finite.
|
|
536
|
+
good = np.ones(len(xs), dtype=bool)
|
|
537
|
+
coeffs = None
|
|
538
|
+
rms_val = np.nan
|
|
539
|
+
|
|
540
|
+
n_params = (order + 2) if directional else len(terms)
|
|
541
|
+
|
|
542
|
+
with np.errstate(divide='ignore', over='ignore', invalid='ignore'):
|
|
543
|
+
for iteration in range(maxiters + 1):
|
|
544
|
+
n_good = int(np.sum(good))
|
|
545
|
+
if n_good < n_params + 1:
|
|
546
|
+
break
|
|
547
|
+
|
|
548
|
+
A = _build_basis(xs[good], ys[good], theta_rad)
|
|
549
|
+
coeffs, _, _, _ = np.linalg.lstsq(A, ds[good], rcond=None)
|
|
550
|
+
|
|
551
|
+
if iteration == maxiters:
|
|
552
|
+
break
|
|
553
|
+
|
|
554
|
+
# Evaluate model on the subsample for clipping
|
|
555
|
+
model = _build_basis(xs, ys, theta_rad) @ coeffs
|
|
556
|
+
res = ds - model
|
|
557
|
+
|
|
558
|
+
# Robust RMS via MAD
|
|
559
|
+
med_res = np.median(res)
|
|
560
|
+
rms_val = 1.4826 * np.median(np.abs(res - med_res))
|
|
561
|
+
if rms_val <= 0:
|
|
562
|
+
break
|
|
563
|
+
|
|
564
|
+
good = np.abs(res - med_res) < sigma * rms_val
|
|
565
|
+
|
|
566
|
+
# --- final evaluation on the full grid (single pass) -----------------
|
|
567
|
+
if coeffs is None:
|
|
568
|
+
return np.full_like(image, np.nan, dtype=np.float64), np.nan
|
|
569
|
+
|
|
570
|
+
background = _eval_2d(coeffs, theta_rad)
|
|
571
|
+
|
|
572
|
+
# Final RMS from clipped subsample residuals
|
|
573
|
+
if np.any(good) and np.all(np.isfinite(coeffs)):
|
|
574
|
+
with np.errstate(divide='ignore', over='ignore', invalid='ignore'):
|
|
575
|
+
model_good = _build_basis(xs[good], ys[good], theta_rad) @ coeffs
|
|
576
|
+
rms_val = float(mad_std(ds[good] - model_good))
|
|
577
|
+
else:
|
|
578
|
+
rms_val = np.nan
|
|
579
|
+
|
|
580
|
+
return background, rms_val
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def get_background_gp(
|
|
584
|
+
image,
|
|
585
|
+
mask=None,
|
|
586
|
+
max_points=3000,
|
|
587
|
+
grid_step=None,
|
|
588
|
+
clip_sigma=3.5,
|
|
589
|
+
n_clip_iter=2,
|
|
590
|
+
length_scale=64.0,
|
|
591
|
+
length_scale_bounds=(8.0, 512.0),
|
|
592
|
+
matern_nu=1.5,
|
|
593
|
+
white_noise=1.0,
|
|
594
|
+
white_noise_bounds=(1e-3, 1e3),
|
|
595
|
+
normalize_y=True,
|
|
596
|
+
n_restarts_optimizer=1,
|
|
597
|
+
random_state=0,
|
|
598
|
+
get_uncertainty=False,
|
|
599
|
+
chunk_rows=256,
|
|
600
|
+
):
|
|
601
|
+
"""
|
|
602
|
+
Background estimation via Gaussian Process regression (non-grid method).
|
|
603
|
+
|
|
604
|
+
Uses a Gaussian Process with a Matern kernel to model the background as a
|
|
605
|
+
smooth function of spatial coordinates (x, y). This method is very flexible
|
|
606
|
+
and can handle complex background variations including gradients, vignetting,
|
|
607
|
+
and other large-scale structures.
|
|
608
|
+
|
|
609
|
+
Workflow:
|
|
610
|
+
1. Select candidate background pixels (masked + sigma clipping to remove stars)
|
|
611
|
+
2. Subsample them randomly or on a grid (to keep computation tractable)
|
|
612
|
+
3. Fit a GP to the (x, y) -> background mapping
|
|
613
|
+
4. Predict background map everywhere (with optional uncertainty)
|
|
614
|
+
|
|
615
|
+
Parameters
|
|
616
|
+
----------
|
|
617
|
+
image : ndarray
|
|
618
|
+
Input image
|
|
619
|
+
mask : ndarray, optional
|
|
620
|
+
Boolean mask (True = masked pixels to exclude)
|
|
621
|
+
max_points : int
|
|
622
|
+
Maximum number of training points for the GP. More points = better fit
|
|
623
|
+
but much slower (O(N^3) cost). Typical values: 1000-5000.
|
|
624
|
+
grid_step : int, optional
|
|
625
|
+
If set, sample every N pixels on a grid (after masking). If None,
|
|
626
|
+
random sampling is used.
|
|
627
|
+
clip_sigma : float
|
|
628
|
+
Sigma threshold for iterative clipping of outliers (stars) in intensity space
|
|
629
|
+
n_clip_iter : int
|
|
630
|
+
Number of sigma-clipping iterations to remove stars
|
|
631
|
+
length_scale : float
|
|
632
|
+
Initial guess for GP length scale in pixels. Should be larger than
|
|
633
|
+
typical star size. Typical values: 32-128 pixels.
|
|
634
|
+
length_scale_bounds : tuple
|
|
635
|
+
Bounds for length scale optimization (min, max)
|
|
636
|
+
matern_nu : float
|
|
637
|
+
Smoothness parameter for Matern kernel. Typical values:
|
|
638
|
+
- 0.5: Exponential kernel (rough)
|
|
639
|
+
- 1.5: Once differentiable (default, good balance)
|
|
640
|
+
- 2.5: Twice differentiable (very smooth)
|
|
641
|
+
white_noise : float
|
|
642
|
+
Initial guess for white noise level (in image units, e.g., ADU)
|
|
643
|
+
white_noise_bounds : tuple
|
|
644
|
+
Bounds for white noise optimization (min, max)
|
|
645
|
+
normalize_y : bool
|
|
646
|
+
If True, normalize target values during GP training (recommended)
|
|
647
|
+
n_restarts_optimizer : int
|
|
648
|
+
Number of random restarts for hyperparameter optimization (more = better
|
|
649
|
+
but slower)
|
|
650
|
+
random_state : int
|
|
651
|
+
Random seed for reproducibility
|
|
652
|
+
get_uncertainty : bool
|
|
653
|
+
If True, return uncertainty map. If False, return None for RMS.
|
|
654
|
+
chunk_rows : int
|
|
655
|
+
Process prediction in chunks of this many rows to reduce memory usage
|
|
656
|
+
|
|
657
|
+
Returns
|
|
658
|
+
-------
|
|
659
|
+
background : ndarray
|
|
660
|
+
Estimated background map
|
|
661
|
+
uncertainty : ndarray or None
|
|
662
|
+
If get_uncertainty=True: 2D array of GP posterior standard deviation
|
|
663
|
+
If get_uncertainty=False: None
|
|
664
|
+
|
|
665
|
+
Notes
|
|
666
|
+
-----
|
|
667
|
+
- Requires scikit-learn to be installed
|
|
668
|
+
- Computationally expensive for large images (use smaller max_points)
|
|
669
|
+
- Very flexible: can handle arbitrary background shapes
|
|
670
|
+
- Length scale should be >> PSF size (otherwise may fit stars)
|
|
671
|
+
- For very large images, consider using grid_step to reduce training points
|
|
672
|
+
|
|
673
|
+
Examples
|
|
674
|
+
--------
|
|
675
|
+
Basic usage with default parameters:
|
|
676
|
+
|
|
677
|
+
>>> bg = get_background_gp(image, mask=mask)
|
|
678
|
+
|
|
679
|
+
Custom length scale for wide-field imaging:
|
|
680
|
+
|
|
681
|
+
>>> bg = get_background_gp(image, mask=mask, length_scale=128.0)
|
|
682
|
+
|
|
683
|
+
Get uncertainty map:
|
|
684
|
+
|
|
685
|
+
>>> bg, bg_std = get_background_gp(image, mask=mask, get_uncertainty=True)
|
|
686
|
+
|
|
687
|
+
Faster computation with grid sampling:
|
|
688
|
+
|
|
689
|
+
>>> bg = get_background_gp(image, mask=mask, max_points=1000, grid_step=10)
|
|
690
|
+
"""
|
|
691
|
+
try:
|
|
692
|
+
from sklearn.gaussian_process import GaussianProcessRegressor
|
|
693
|
+
from sklearn.gaussian_process.kernels import ConstantKernel, Matern, WhiteKernel
|
|
694
|
+
except ImportError:
|
|
695
|
+
raise ImportError(
|
|
696
|
+
"Gaussian Process background estimation requires scikit-learn. "
|
|
697
|
+
"Install with: pip install scikit-learn"
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
# Prepare image and mask
|
|
701
|
+
img = np.asarray(image, dtype=float)
|
|
702
|
+
if img.ndim != 2:
|
|
703
|
+
raise ValueError("image must be 2D")
|
|
704
|
+
ny, nx = img.shape
|
|
705
|
+
|
|
706
|
+
if mask is None:
|
|
707
|
+
mask = np.zeros(img.shape, dtype=bool)
|
|
708
|
+
else:
|
|
709
|
+
mask = np.asarray(mask, dtype=bool)
|
|
710
|
+
if mask.shape != img.shape:
|
|
711
|
+
raise ValueError(f"mask shape {mask.shape} != image shape {img.shape}")
|
|
712
|
+
|
|
713
|
+
# Step 1: Select candidate background pixels via sigma clipping
|
|
714
|
+
valid = ~mask & np.isfinite(img)
|
|
715
|
+
y_vals = img[valid]
|
|
716
|
+
|
|
717
|
+
if y_vals.size < 50:
|
|
718
|
+
raise RuntimeError("Too few valid pixels to estimate background")
|
|
719
|
+
|
|
720
|
+
# Iterative sigma clipping to remove stars
|
|
721
|
+
# Fix #7: Use mad_std directly instead of inner robust_sigma function
|
|
722
|
+
keep = np.ones(y_vals.shape, dtype=bool)
|
|
723
|
+
for _ in range(max(0, int(n_clip_iter))):
|
|
724
|
+
yy = y_vals[keep]
|
|
725
|
+
if yy.size < 50:
|
|
726
|
+
break
|
|
727
|
+
med = np.median(yy)
|
|
728
|
+
sig = mad_std(yy, ignore_nan=True)
|
|
729
|
+
if not np.isfinite(sig) or sig <= 0:
|
|
730
|
+
break
|
|
731
|
+
lo = med - clip_sigma * sig
|
|
732
|
+
hi = med + clip_sigma * sig
|
|
733
|
+
keep = keep & (y_vals >= lo) & (y_vals <= hi)
|
|
734
|
+
|
|
735
|
+
# Get coordinates for kept pixels
|
|
736
|
+
ys_idx, xs_idx = np.nonzero(valid)
|
|
737
|
+
xs_idx = xs_idx[keep]
|
|
738
|
+
ys_idx = ys_idx[keep]
|
|
739
|
+
y_vals = y_vals[keep]
|
|
740
|
+
|
|
741
|
+
# Step 2: Subsample training points
|
|
742
|
+
rng = np.random.default_rng(random_state)
|
|
743
|
+
|
|
744
|
+
if grid_step is not None and grid_step > 1:
|
|
745
|
+
# Grid-based subsampling
|
|
746
|
+
gx = xs_idx // grid_step
|
|
747
|
+
gy = ys_idx // grid_step
|
|
748
|
+
# Unique grid cells
|
|
749
|
+
key = gx.astype(np.int64) + (gy.astype(np.int64) << 32)
|
|
750
|
+
_, idx = np.unique(key, return_index=True)
|
|
751
|
+
xs_sub, ys_sub, y_sub = xs_idx[idx], ys_idx[idx], y_vals[idx]
|
|
752
|
+
else:
|
|
753
|
+
xs_sub, ys_sub, y_sub = xs_idx, ys_idx, y_vals
|
|
754
|
+
|
|
755
|
+
# Further random subsampling if needed
|
|
756
|
+
n_candidates = y_sub.size
|
|
757
|
+
if n_candidates > max_points:
|
|
758
|
+
idx = rng.choice(n_candidates, size=max_points, replace=False)
|
|
759
|
+
xs_sub, ys_sub, y_sub = xs_sub[idx], ys_sub[idx], y_sub[idx]
|
|
760
|
+
|
|
761
|
+
# Prepare training data
|
|
762
|
+
X_train = np.column_stack([xs_sub, ys_sub]).astype(float)
|
|
763
|
+
y_train = y_sub.astype(float)
|
|
764
|
+
|
|
765
|
+
# Step 3: Build and fit GP
|
|
766
|
+
kernel = ConstantKernel(1.0, (1e-3, 1e3)) * Matern(
|
|
767
|
+
length_scale=length_scale,
|
|
768
|
+
length_scale_bounds=length_scale_bounds,
|
|
769
|
+
nu=matern_nu,
|
|
770
|
+
) + WhiteKernel(
|
|
771
|
+
noise_level=white_noise,
|
|
772
|
+
noise_level_bounds=white_noise_bounds,
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
gp = GaussianProcessRegressor(
|
|
776
|
+
kernel=kernel,
|
|
777
|
+
normalize_y=normalize_y,
|
|
778
|
+
n_restarts_optimizer=n_restarts_optimizer,
|
|
779
|
+
random_state=random_state,
|
|
780
|
+
)
|
|
781
|
+
gp.fit(X_train, y_train)
|
|
782
|
+
|
|
783
|
+
# Step 4: Predict full background map in chunks
|
|
784
|
+
background = np.empty((ny, nx), dtype=float)
|
|
785
|
+
if get_uncertainty:
|
|
786
|
+
uncertainty = np.empty((ny, nx), dtype=float)
|
|
787
|
+
else:
|
|
788
|
+
uncertainty = None
|
|
789
|
+
|
|
790
|
+
x_coords = np.arange(nx, dtype=float)
|
|
791
|
+
|
|
792
|
+
for y0 in range(0, ny, chunk_rows):
|
|
793
|
+
y1 = min(ny, y0 + chunk_rows)
|
|
794
|
+
rows = np.arange(y0, y1, dtype=float)
|
|
795
|
+
|
|
796
|
+
# Create (x, y) coordinate pairs for this chunk
|
|
797
|
+
X_pred = np.column_stack(
|
|
798
|
+
[
|
|
799
|
+
np.tile(x_coords, y1 - y0),
|
|
800
|
+
np.repeat(rows, nx),
|
|
801
|
+
]
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
if get_uncertainty:
|
|
805
|
+
pred, pred_std = gp.predict(X_pred, return_std=True)
|
|
806
|
+
background[y0:y1, :] = pred.reshape((y1 - y0, nx))
|
|
807
|
+
uncertainty[y0:y1, :] = pred_std.reshape((y1 - y0, nx))
|
|
808
|
+
else:
|
|
809
|
+
pred = gp.predict(X_pred, return_std=False)
|
|
810
|
+
background[y0:y1, :] = pred.reshape((y1 - y0, nx))
|
|
811
|
+
|
|
812
|
+
# Fix #8: Always return (background, uncertainty_or_None)
|
|
813
|
+
# When get_uncertainty=False, return None so caller can compute RMS if needed
|
|
814
|
+
return background, uncertainty
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def get_background(image, mask=None, method='sep', size=128, get_rms=False, **kwargs):
|
|
818
|
+
"""
|
|
819
|
+
Estimate background using various methods.
|
|
820
|
+
|
|
821
|
+
This is the main interface for background estimation, supporting multiple
|
|
822
|
+
algorithms optimized for different scenarios.
|
|
823
|
+
|
|
824
|
+
Parameters
|
|
825
|
+
----------
|
|
826
|
+
image : ndarray
|
|
827
|
+
Input image
|
|
828
|
+
mask : ndarray, optional
|
|
829
|
+
Boolean mask (True = masked pixels)
|
|
830
|
+
method : str
|
|
831
|
+
Background estimation method:
|
|
832
|
+
|
|
833
|
+
- 'sep': SEP grid-based (default) - Fast, accurate for flat/linear backgrounds
|
|
834
|
+
- 'photutils': photutils grid-based - Similar to SEP
|
|
835
|
+
- 'polynomial': Low-order polynomial fit - Captures smooth gradients, preserves nebulae
|
|
836
|
+
- 'percentile': Percentile filtering (non-grid, robust to stars) - Slow but very robust
|
|
837
|
+
- 'morphology': Morphological opening (non-grid, fast) - Good for moderate gradients
|
|
838
|
+
- 'gp': Gaussian Process regression (non-grid, very flexible) - Best for complex backgrounds
|
|
839
|
+
size : int
|
|
840
|
+
Grid size for sep/photutils, or kernel size for percentile/morphology.
|
|
841
|
+
For 'gp' method, this parameter is used as the initial length_scale.
|
|
842
|
+
Typical values:
|
|
843
|
+
|
|
844
|
+
- SEP/photutils: 64-128 pixels
|
|
845
|
+
- Percentile: 31-51 pixels (smaller for gradients)
|
|
846
|
+
- Morphology: 15-35 pixels (should be > star size)
|
|
847
|
+
- GP: 32-128 pixels (length scale, should be >> PSF size)
|
|
848
|
+
get_rms : bool
|
|
849
|
+
If True, return (background, background_rms). For GP method with get_rms=True,
|
|
850
|
+
returns the full uncertainty map instead of a scalar RMS.
|
|
851
|
+
**kwargs : dict
|
|
852
|
+
Additional parameters passed to the method:
|
|
853
|
+
|
|
854
|
+
- For polynomial: order, sigma, maxiters, directional, direction
|
|
855
|
+
(see get_background_polynomial for full list)
|
|
856
|
+
- For percentile: percentile, maxiters, sigma
|
|
857
|
+
- For morphology: iterations, smooth, smooth_size
|
|
858
|
+
- For gp: max_points, grid_step, clip_sigma, n_clip_iter, matern_nu, etc.
|
|
859
|
+
(see get_background_gp for full list)
|
|
860
|
+
|
|
861
|
+
Returns
|
|
862
|
+
-------
|
|
863
|
+
background : ndarray
|
|
864
|
+
Estimated background
|
|
865
|
+
background_rms : ndarray or float, optional
|
|
866
|
+
Background RMS (if get_rms=True). For GP method, this is a 2D uncertainty map.
|
|
867
|
+
|
|
868
|
+
Examples
|
|
869
|
+
--------
|
|
870
|
+
Default grid-based background estimation:
|
|
871
|
+
|
|
872
|
+
>>> bg = get_background(image, method='sep', size=128)
|
|
873
|
+
|
|
874
|
+
Non-grid morphological method for moderate gradients:
|
|
875
|
+
|
|
876
|
+
>>> bg = get_background(image, method='morphology', size=25)
|
|
877
|
+
|
|
878
|
+
Morphology with extra smoothing to eliminate kernel artifacts:
|
|
879
|
+
|
|
880
|
+
>>> bg = get_background(image, method='morphology', size=25, smooth_size=15)
|
|
881
|
+
|
|
882
|
+
Percentile method for very crowded fields:
|
|
883
|
+
|
|
884
|
+
>>> bg = get_background(image, method='percentile', size=31, percentile=15.0)
|
|
885
|
+
|
|
886
|
+
Gaussian Process for complex backgrounds with vignetting:
|
|
887
|
+
|
|
888
|
+
>>> bg = get_background(image, method='gp', size=64, max_points=2000)
|
|
889
|
+
|
|
890
|
+
Get both background and RMS:
|
|
891
|
+
|
|
892
|
+
>>> bg, bg_rms = get_background(image, method='sep', get_rms=True)
|
|
893
|
+
|
|
894
|
+
Get GP background with uncertainty map:
|
|
895
|
+
|
|
896
|
+
>>> bg, bg_std = get_background(image, method='gp', get_rms=True)
|
|
897
|
+
|
|
898
|
+
Polynomial fit for gradient removal (preserves nebulae):
|
|
899
|
+
|
|
900
|
+
>>> bg = get_background(image, method='polynomial', order=2)
|
|
901
|
+
|
|
902
|
+
Directional gradient with cubic curvature:
|
|
903
|
+
|
|
904
|
+
>>> bg = get_background(image, method='polynomial', order=3, directional=True)
|
|
905
|
+
|
|
906
|
+
See Also
|
|
907
|
+
--------
|
|
908
|
+
get_background_polynomial : Polynomial fit details
|
|
909
|
+
get_background_percentile : Percentile filtering details
|
|
910
|
+
get_background_morphology : Morphological opening details
|
|
911
|
+
get_background_gp : Gaussian Process regression details
|
|
912
|
+
|
|
913
|
+
Notes
|
|
914
|
+
-----
|
|
915
|
+
**Method Selection Guide**:
|
|
916
|
+
|
|
917
|
+
- **Flat backgrounds**: Use 'sep' (default) - fastest and most accurate
|
|
918
|
+
- **Linear gradients**: Use 'sep' with size=64-128
|
|
919
|
+
- **Smooth gradients with nebulae**: Use 'polynomial' — only captures the
|
|
920
|
+
gradient, preserves localised background features by construction
|
|
921
|
+
- **Directional gradients**: Use 'polynomial' with ``directional=True`` —
|
|
922
|
+
higher-order terms confined to the gradient direction
|
|
923
|
+
- **Quadratic gradients**: Use 'morphology' or local gradient fitting (bkg_order)
|
|
924
|
+
- **Complex backgrounds** (vignetting, strong curvature): Use 'gp'
|
|
925
|
+
- **Very crowded fields**: Use 'percentile' with percentile=10-15
|
|
926
|
+
- **Large images**: Avoid 'percentile' (very slow), use 'sep' or 'morphology'
|
|
927
|
+
|
|
928
|
+
**Performance**:
|
|
929
|
+
|
|
930
|
+
- SEP: ~1-3 ms (512x512 image)
|
|
931
|
+
- Morphology: ~300 ms (512x512 image)
|
|
932
|
+
- Percentile: ~2-5 seconds (512x512 image)
|
|
933
|
+
- GP: ~1-10 seconds (512x512 image, depends on max_points)
|
|
934
|
+
|
|
935
|
+
For complex backgrounds (strong curvature, vignetting), consider using
|
|
936
|
+
local gradient fitting instead (bkg_order parameter in measure_objects),
|
|
937
|
+
or the GP method for maximum flexibility.
|
|
938
|
+
"""
|
|
939
|
+
if method == 'sep':
|
|
940
|
+
# Fix #1: Lazy import
|
|
941
|
+
try:
|
|
942
|
+
import sep_x as sep
|
|
943
|
+
except ImportError:
|
|
944
|
+
import sep
|
|
945
|
+
|
|
946
|
+
# Fix #5: Avoid unnecessary copy if already float64 and contiguous
|
|
947
|
+
image = np.ascontiguousarray(image, dtype=np.double)
|
|
948
|
+
bg = sep.Background(image, mask=mask, bw=size, bh=size, **kwargs)
|
|
949
|
+
|
|
950
|
+
back, backrms = bg.back(), bg.rms()
|
|
951
|
+
elif method == 'photutils':
|
|
952
|
+
# Fix #1: Lazy import
|
|
953
|
+
import photutils.background
|
|
954
|
+
|
|
955
|
+
bg = photutils.background.Background2D(image, size, mask=mask, **kwargs)
|
|
956
|
+
back, backrms = bg.background, bg.background_rms
|
|
957
|
+
elif method == 'percentile':
|
|
958
|
+
back = get_background_percentile(image, mask=mask, size=size, **kwargs)
|
|
959
|
+
# Estimate RMS from percentile range if needed
|
|
960
|
+
# Fix #9: Don't pass **kwargs to RMS estimator (percentile param would
|
|
961
|
+
# incorrectly override the fixed p25/p75 used for IQR)
|
|
962
|
+
if get_rms:
|
|
963
|
+
backrms = estimate_background_rms_percentile(image, mask=mask, size=size)
|
|
964
|
+
else:
|
|
965
|
+
backrms = None
|
|
966
|
+
elif method == 'morphology':
|
|
967
|
+
back = get_background_morphology(image, mask=mask, size=size, **kwargs)
|
|
968
|
+
# Estimate RMS from local statistics if needed
|
|
969
|
+
if get_rms:
|
|
970
|
+
backrms = estimate_background_rms_local(image, back, mask=mask, size=size)
|
|
971
|
+
else:
|
|
972
|
+
backrms = None
|
|
973
|
+
elif method == 'gp':
|
|
974
|
+
# Use size as length_scale if not explicitly provided
|
|
975
|
+
if 'length_scale' not in kwargs:
|
|
976
|
+
kwargs['length_scale'] = float(size)
|
|
977
|
+
# Set get_uncertainty based on get_rms
|
|
978
|
+
kwargs['get_uncertainty'] = get_rms
|
|
979
|
+
back, gp_uncertainty = get_background_gp(image, mask=mask, **kwargs)
|
|
980
|
+
# Fix #8: When get_uncertainty=False, gp_uncertainty is None.
|
|
981
|
+
# Compute RMS via estimate_background_rms_local if get_rms is requested.
|
|
982
|
+
if get_rms:
|
|
983
|
+
if gp_uncertainty is not None:
|
|
984
|
+
backrms = gp_uncertainty
|
|
985
|
+
else:
|
|
986
|
+
backrms = estimate_background_rms_local(image, back, mask=mask, size=size)
|
|
987
|
+
else:
|
|
988
|
+
backrms = None
|
|
989
|
+
elif method == 'polynomial':
|
|
990
|
+
back, poly_rms = get_background_polynomial(image, mask=mask, **kwargs)
|
|
991
|
+
backrms = np.full_like(back, poly_rms) if get_rms else None
|
|
992
|
+
else:
|
|
993
|
+
raise ValueError(f"Unknown background method: {method}")
|
|
994
|
+
|
|
995
|
+
if get_rms:
|
|
996
|
+
return back, backrms
|
|
997
|
+
else:
|
|
998
|
+
return back
|