stdpipe 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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