SpecProc 1.0.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.
Files changed (61) hide show
  1. specproc/__init__.py +9 -0
  2. specproc/calib_data/linelists/FeAr.dat +375 -0
  3. specproc/calib_data/linelists/README +79 -0
  4. specproc/calib_data/linelists/airvac.cl +61 -0
  5. specproc/calib_data/linelists/argon.dat +161 -0
  6. specproc/calib_data/linelists/ctiohear.dat +106 -0
  7. specproc/calib_data/linelists/ctiohenear.dat +146 -0
  8. specproc/calib_data/linelists/cuar.dat +459 -0
  9. specproc/calib_data/linelists/henear.dat +93 -0
  10. specproc/calib_data/linelists/henearhres.dat +300 -0
  11. specproc/calib_data/linelists/idhenear.dat +127 -0
  12. specproc/calib_data/linelists/krypton.dat +109 -0
  13. specproc/calib_data/linelists/lowskylines.dat +577 -0
  14. specproc/calib_data/linelists/ohlines.dat +268 -0
  15. specproc/calib_data/linelists/skylines.dat +1385 -0
  16. specproc/calib_data/linelists/thar-noao.dat +4169 -0
  17. specproc/calib_data/linelists/thar.dat +3057 -0
  18. specproc/calib_data/linelists/thorium.dat +3057 -0
  19. specproc/calib_data/linelists/vacidhenear.dat +127 -0
  20. specproc/calib_data/linelists/vacthar.dat +3057 -0
  21. specproc/calib_data/linelists/vacthorium.dat +3057 -0
  22. specproc/calib_data/linelists/xenon.dat +89 -0
  23. specproc/calib_data/telescopes/lijiang24hrs/lijiang24_hrs_thar_lines.csv +550 -0
  24. specproc/calib_data/telescopes/xinglong216bfosc/BFOSC/345/256/232/346/240/207/347/201/257/350/260/261/350/256/244/350/257/201/346/211/213/345/206/214v171118.pdf +0 -0
  25. specproc/calib_data/telescopes/xinglong216bfosc//346/212/245/345/221/212/345/233/276.docx +0 -0
  26. specproc/calib_data/telescopes/xinglong216hrs/216HRS /347/201/257/350/260/261/350/257/201/350/256/244/346/226/207/344/273/266.pdf +0 -0
  27. specproc/calib_data/telescopes/xinglong216hrs/wlcalib_20211123011_A.png +0 -0
  28. specproc/calib_data/telescopes/xinglong216hrs/xinglong216_thar_lines.csv +517 -0
  29. specproc/config/__init__.py +1 -0
  30. specproc/config/config_manager.py +431 -0
  31. specproc/core/__init__.py +1 -0
  32. specproc/core/basic_reduction.py +615 -0
  33. specproc/core/data_structures.py +525 -0
  34. specproc/core/de_blazing.py +210 -0
  35. specproc/core/extraction.py +375 -0
  36. specproc/core/flat_correction.py +1255 -0
  37. specproc/core/line_identification.py +302 -0
  38. specproc/core/order_stitching.py +187 -0
  39. specproc/core/order_tracing.py +1587 -0
  40. specproc/core/processing_pipeline.py +1183 -0
  41. specproc/core/scattered_light.py +654 -0
  42. specproc/core/slit_pipeline.py +762 -0
  43. specproc/core/wave_calibration.py +961 -0
  44. specproc/gui/__init__.py +1 -0
  45. specproc/gui/line_identifier.py +731 -0
  46. specproc/gui/main_window.py +2315 -0
  47. specproc/gui/settings_dialog.py +946 -0
  48. specproc/main.py +388 -0
  49. specproc/plotting/__init__.py +1 -0
  50. specproc/plotting/spectra_plotter.py +483 -0
  51. specproc/utils/__init__.py +1 -0
  52. specproc/utils/airvac.py +123 -0
  53. specproc/utils/fits_io.py +269 -0
  54. specproc/utils/image_processing.py +426 -0
  55. specproc/utils/overscan.py +600 -0
  56. specproc-1.0.0.dist-info/METADATA +668 -0
  57. specproc-1.0.0.dist-info/RECORD +61 -0
  58. specproc-1.0.0.dist-info/WHEEL +5 -0
  59. specproc-1.0.0.dist-info/entry_points.txt +5 -0
  60. specproc-1.0.0.dist-info/licenses/LICENSE +674 -0
  61. specproc-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,654 @@
1
+ """
2
+ Scattered light subtraction stage of spectral reduction pipeline.
3
+
4
+ Estimates and removes inter-order scattered light (stray light) from
5
+ echelle spectrograph CCD images. Equivalent to IRAF's ``apscatter`` task.
6
+ """
7
+ # 估算并扣除光谱级次间的散射光背景。
8
+
9
+ import numpy as np
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Tuple, Optional
13
+ from scipy.ndimage import median_filter
14
+ import matplotlib.pyplot as plt
15
+ import matplotlib.cm as mcm
16
+ from astropy.convolution import convolve_fft, Gaussian2DKernel
17
+ from specproc.utils.image_processing import estimate_background_2d, estimate_background_region
18
+ from specproc.config.config_manager import ConfigManager
19
+ from specproc.core.data_structures import ApertureSet
20
+ from specproc.utils.fits_io import write_fits_image
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # 负责估算和移除散射光背景
26
+ class BackgroundRemover:
27
+ """Handles background/stray light estimation and correction."""
28
+
29
+ # 初始化对象状态
30
+ def __init__(self):
31
+ """
32
+ Initialize background remover.
33
+ """
34
+ self.background_2d = None
35
+
36
+ # 通过二维卷积估算散射光背景
37
+ @staticmethod
38
+ def _estimate_background_convolution(image: np.ndarray, order_mask: np.ndarray,
39
+ kernel_sigma_x: float = 15.0,
40
+ kernel_sigma_y: float = 15.0,
41
+ sigma_clip: float = 3.0,
42
+ maxiters: int = 3,
43
+ clip_mode: str = 'upper'
44
+ ) -> np.ndarray:
45
+ """Estimate scattered-light background via astropy NaN-aware 2D Gaussian convolution.
46
+
47
+ Masked (order) pixels are set to NaN and astropy.convolution.convolve
48
+ automatically ignores them, using only valid inter-order pixels for the
49
+ weighted average under the kernel. This naturally reproduces local
50
+ structures (ripples, fringes) that global polynomial fits cannot capture.
51
+
52
+ Parameters
53
+ ----------
54
+ image : 2D array — science or flat image.
55
+ order_mask : bool mask, True = inside an order (to be ignored).
56
+ kernel_sigma_x, kernel_sigma_y : Gaussian kernel std-dev in pixels
57
+ (x = dispersion direction, y = cross-dispersion). Use an
58
+ anisotropic kernel: large x (dispersion, ~40) for broad smoothing,
59
+ smaller y (cross-dispersion, ~15) to preserve ripple structure.
60
+ sigma_clip : iterative outlier rejection threshold (MAD-based).
61
+ maxiters : max sigma-clipping iterations.
62
+ clip_mode : 'upper' / 'both' / 'lower'.
63
+ """
64
+ kernel = Gaussian2DKernel(x_stddev=kernel_sigma_x, y_stddev=kernel_sigma_y)
65
+ # Pad size = kernel half-extent so edge-padding produces the same
66
+ # result as boundary='extend' (not available in convolve_fft).
67
+ pad_y = kernel.shape[0] // 2
68
+ pad_x = kernel.shape[1] // 2
69
+
70
+ current_mask = order_mask.copy()
71
+ model = None
72
+ for it in range(max(1, maxiters)):
73
+ data = image.astype(np.float64).copy()
74
+ data[current_mask] = np.nan
75
+
76
+ # Pre-fill masked regions (orders) using column-wise linear interpolation.
77
+ # This perfectly solves the "dark square mosaics" artifact caused by
78
+ # kernel weight starvation when wide orders exceed the kernel's reach.
79
+ rows, cols = data.shape
80
+ y_idx = np.arange(rows)
81
+ for x in range(cols):
82
+ col = data[:, x]
83
+ valid = ~np.isnan(col)
84
+ if valid.any() and not valid.all():
85
+ data[~valid, x] = np.interp(y_idx[~valid], y_idx[valid], col[valid])
86
+
87
+ # Fallback for any completely masked columns
88
+ if np.any(np.isnan(data)):
89
+ for y in range(rows):
90
+ row = data[y, :]
91
+ valid = ~np.isnan(row)
92
+ if valid.any() and not valid.all():
93
+ x_idx = np.arange(cols)
94
+ data[y, ~valid] = np.interp(x_idx[~valid], x_idx[valid], row[valid])
95
+
96
+ global_med = np.nanmedian(data) if np.any(np.isfinite(data)) else 0.0
97
+ data[np.isnan(data)] = global_med
98
+
99
+ # Edge-pad image, then FFT-convolve, then crop back.
100
+ data_pad = np.pad(data, ((pad_y, pad_y), (pad_x, pad_x)), mode='edge')
101
+ model_pad = convolve_fft(data_pad, kernel, boundary='fill',
102
+ fill_value=global_med,
103
+ nan_treatment='interpolate',
104
+ allow_huge=True)
105
+ model = model_pad[pad_y:-pad_y, pad_x:-pad_x]
106
+
107
+ if sigma_clip <= 0:
108
+ break
109
+
110
+ # Sigma-clip on the valid (non-order) pixels only.
111
+ valid = ~order_mask
112
+ resid = image.astype(np.float64) - model
113
+ valid_resid = resid[valid]
114
+ med = float(np.nanmedian(valid_resid))
115
+ mad = float(np.nanmedian(np.abs(valid_resid - med)))
116
+ sig = 1.4826 * mad if mad > 0 else float(np.nanstd(valid_resid))
117
+ if sig <= 0:
118
+ break
119
+
120
+ if clip_mode == 'upper':
121
+ outlier = (resid - med) > sigma_clip * sig
122
+ elif clip_mode == 'lower':
123
+ outlier = (resid - med) < -sigma_clip * sig
124
+ else:
125
+ outlier = np.abs(resid - med) > sigma_clip * sig
126
+
127
+ new_mask = order_mask | outlier
128
+ if np.array_equal(new_mask, current_mask):
129
+ break
130
+ current_mask = new_mask
131
+
132
+ return model
133
+
134
+ # 按列样条拟合散射光背景
135
+ @staticmethod
136
+ def _estimate_background_column_spline(image: np.ndarray, order_mask: np.ndarray,
137
+ spline_smooth_factor: float = 1.0,
138
+ post_smooth_sigma_x: float = 5.0,
139
+ sigma_clip: float = 3.0,
140
+ maxiters: int = 3,
141
+ clip_mode: str = 'upper'
142
+ ) -> np.ndarray:
143
+ """Estimate scattered-light background via column-wise 1D smoothing splines.
144
+
145
+ For each column (or every 2nd column for speed on wide images), extract
146
+ unmasked (inter-order) pixels, fit a cubic smoothing spline, then
147
+ evaluate over the full column. Skipped columns are filled by linear
148
+ interpolation. A final light horizontal Gaussian smooth removes any
149
+ residual column-to-column discontinuity.
150
+
151
+ Parameters
152
+ ----------
153
+ image : 2D array — science or flat image.
154
+ order_mask : bool mask, True = inside an order (to be ignored).
155
+ spline_smooth_factor : Multiplier for the spline smoothing parameter *s*.
156
+ Computed as ``s = factor × N_bg × noise_variance``. factor=1.0
157
+ gives statistically optimal smoothing; values < 1 produce a tighter
158
+ fit that follows ripple peaks/troughs. Typical 0.3–2.0.
159
+ post_smooth_sigma_x : Gaussian σ (pixels) for a light horizontal
160
+ smoothing pass applied after assembling all columns.
161
+ Set to 0 to disable.
162
+ sigma_clip : iterative outlier rejection threshold (MAD-based).
163
+ maxiters : max sigma-clipping iterations.
164
+ clip_mode : 'upper' / 'both' / 'lower'.
165
+ """
166
+ from scipy.interpolate import splrep, splev
167
+ from scipy.ndimage import gaussian_filter
168
+
169
+ rows, cols = image.shape
170
+ background = np.full((rows, cols), np.nan, dtype=np.float64)
171
+ y_all = np.arange(rows, dtype=np.float64)
172
+
173
+ # Process every step-th column; interpolate the rest.
174
+ step = 2 if cols > 2000 else 1
175
+ fit_cols = list(range(0, cols, step))
176
+ if fit_cols[-1] != cols - 1:
177
+ fit_cols.append(cols - 1)
178
+
179
+ log_step = max(1, len(fit_cols) // 10)
180
+ for ci, ix in enumerate(fit_cols):
181
+ if ci % log_step == 0:
182
+ logger.info(f" column_spline: column {ix}/{cols}")
183
+ col_data = image[:, ix].astype(np.float64)
184
+ col_mask = order_mask[:, ix]
185
+ valid = ~col_mask
186
+ y_bg = y_all[valid]
187
+ f_bg = col_data[valid]
188
+
189
+ if len(y_bg) < 4:
190
+ background[:, ix] = np.nanmedian(f_bg) if len(f_bg) > 0 else 0.0
191
+ continue
192
+
193
+ # Estimate per-column noise for proper s scaling.
194
+ noise_est = float(np.median(np.abs(np.diff(f_bg)))) / 0.6745
195
+ var_est = max(noise_est ** 2, 1.0)
196
+
197
+ # Data range for clamping extrapolation artifacts
198
+ f_min = float(np.min(f_bg))
199
+ f_max = float(np.max(f_bg))
200
+ f_range = max(f_max - f_min, 1.0)
201
+ clamp_lo = f_min - 0.5 * f_range
202
+ clamp_hi = f_max + 0.5 * f_range
203
+
204
+ # Iterative sigma-clip on 1D residuals
205
+ current_valid = np.ones(len(y_bg), dtype=bool)
206
+ spl_tck = None
207
+ for _it in range(max(1, maxiters)):
208
+ yv = y_bg[current_valid]
209
+ fv = f_bg[current_valid]
210
+ if len(yv) < 4:
211
+ break
212
+ s_val = spline_smooth_factor * len(yv) * var_est
213
+ try:
214
+ spl_tck = splrep(yv, fv, s=s_val, k=3)
215
+ except Exception:
216
+ spl_tck = None
217
+ break
218
+ resid = f_bg - splev(y_bg, spl_tck)
219
+ med = float(np.median(resid[current_valid]))
220
+ mad = float(np.median(np.abs(resid[current_valid] - med)))
221
+ sig = 1.4826 * mad if mad > 0 else float(np.std(resid[current_valid]))
222
+ if sig <= 0 or sigma_clip <= 0:
223
+ break
224
+ if clip_mode == 'upper':
225
+ outlier = (resid - med) > sigma_clip * sig
226
+ elif clip_mode == 'lower':
227
+ outlier = (resid - med) < -sigma_clip * sig
228
+ else:
229
+ outlier = np.abs(resid - med) > sigma_clip * sig
230
+ new_valid = current_valid & ~outlier
231
+ if np.array_equal(new_valid, current_valid):
232
+ break
233
+ current_valid = new_valid
234
+
235
+ # Final spline with cleaned data
236
+ yv = y_bg[current_valid]
237
+ fv = f_bg[current_valid]
238
+ if len(yv) < 4:
239
+ background[:, ix] = np.nanmedian(col_data[valid])
240
+ continue
241
+ s_val = spline_smooth_factor * len(yv) * var_est
242
+ try:
243
+ spl_tck = splrep(yv, fv, s=s_val, k=3)
244
+ col_bg = splev(y_all, spl_tck)
245
+ np.clip(col_bg, clamp_lo, clamp_hi, out=col_bg)
246
+ background[:, ix] = col_bg
247
+ except Exception:
248
+ background[:, ix] = np.nanmedian(fv)
249
+
250
+ # Interpolate skipped columns linearly
251
+ if step > 1:
252
+ fit_arr = np.array(fit_cols, dtype=np.float64)
253
+ for iy in range(rows):
254
+ row_vals = background[iy, fit_cols]
255
+ good = np.isfinite(row_vals)
256
+ if np.sum(good) >= 2:
257
+ background[iy, :] = np.interp(
258
+ np.arange(cols), fit_arr[good], row_vals[good])
259
+ elif np.any(good):
260
+ background[iy, :] = row_vals[good][0]
261
+
262
+ # Light horizontal (dispersion-direction) smoothing to remove
263
+ # column-to-column discontinuities.
264
+ if post_smooth_sigma_x > 0:
265
+ background = gaussian_filter(background, sigma=[0, post_smooth_sigma_x])
266
+
267
+ return background
268
+
269
+ # 分区估算背景并组合模型
270
+ def _estimate_background_split_halves(self, image: np.ndarray, order_mask: np.ndarray,
271
+ order: int, method: str,
272
+ smooth_sigma: float, sigma_clip: float,
273
+ maxiters: int, bspline_smooth: float,
274
+ split_row: int = 0,
275
+ clip_mode: str = 'both',
276
+ kernel_sigma_x: float = 15.0,
277
+ kernel_sigma_y: float = 15.0,
278
+ spline_smooth_factor: float = 1.0,
279
+ spline_post_smooth_x: float = 5.0,
280
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
281
+ """Estimate background separately for upper/lower detector halves.
282
+
283
+ Args:
284
+ split_row: 1-based detector row dividing the two amplifier halves.
285
+ 0 (default) falls back to rows//2.
286
+ clip_mode: 'both' (symmetric), 'upper' (reject bright outliers only),
287
+ or 'lower' (reject faint outliers only).
288
+ kernel_sigma_x, kernel_sigma_y: Gaussian kernel σ for 'convolution' method.
289
+ spline_smooth_factor: Smoothing factor multiplier for 'column_spline' method.
290
+ spline_post_smooth_x: Horizontal Gaussian σ for post-smoothing in 'column_spline' method.
291
+ """
292
+ rows, cols = image.shape
293
+
294
+ # --- Convolution method: runs on the full image (no split) ---
295
+ if method == 'convolution':
296
+ logger.info(f"Background convolution: Gaussian kernel σ_x={kernel_sigma_x:.1f}, "
297
+ f"σ_y={kernel_sigma_y:.1f}")
298
+ background = self._estimate_background_convolution(
299
+ image, order_mask,
300
+ kernel_sigma_x=kernel_sigma_x, kernel_sigma_y=kernel_sigma_y,
301
+ sigma_clip=sigma_clip, maxiters=maxiters, clip_mode=clip_mode,
302
+ )
303
+ # Use the configured detector split row (1-based → 0-based index).
304
+ split = (split_row - 1) if 0 < split_row <= rows else rows // 2
305
+ split = max(1, min(split, rows - 1))
306
+ model_lower = background[:split, :]
307
+ model_upper = background[split:, :]
308
+ return background, model_lower, model_upper
309
+
310
+ # --- Column-wise 1D spline method: runs on the full image (no split) ---
311
+ if method == 'column_spline':
312
+ logger.info(f"Background column_spline: smooth_factor={spline_smooth_factor:.3f}, "
313
+ f"post_smooth_σ_x={spline_post_smooth_x:.1f}")
314
+ background = self._estimate_background_column_spline(
315
+ image, order_mask,
316
+ spline_smooth_factor=spline_smooth_factor,
317
+ post_smooth_sigma_x=spline_post_smooth_x,
318
+ sigma_clip=sigma_clip, maxiters=maxiters, clip_mode=clip_mode,
319
+ )
320
+ split = (split_row - 1) if 0 < split_row <= rows else rows // 2
321
+ split = max(1, min(split, rows - 1))
322
+ model_lower = background[:split, :]
323
+ model_upper = background[split:, :]
324
+ return background, model_lower, model_upper
325
+
326
+ # --- Polynomial / B-spline / smooth: per-half fitting ---
327
+ # Use the configured detector split row (1-based → 0-based index).
328
+ split = (split_row - 1) if 0 < split_row <= rows else rows // 2
329
+ split = max(1, min(split, rows - 1))
330
+ background = np.zeros_like(image, dtype=np.float64)
331
+ model_lower = np.zeros((split, cols), dtype=np.float64)
332
+ model_upper = np.zeros((rows - split, cols), dtype=np.float64)
333
+
334
+ halves = [
335
+ (0, split, "lower"),
336
+ (split, rows, "upper"),
337
+ ]
338
+ for y0, y1, label in halves:
339
+ sub = image[y0:y1, :].astype(np.float64)
340
+ sub_mask = order_mask[y0:y1, :]
341
+ valid = ~sub_mask
342
+
343
+ sampled = sub.copy()
344
+ sampled[sub_mask] = np.nan
345
+
346
+ if np.mean(valid) < 0.03:
347
+ # Fallback in very dense order region: smooth heavily on unmasked
348
+ # values while leaving masked zones as NaN constraints.
349
+ logger.warning(
350
+ f"Inter-order fraction too small in {label} half ({np.mean(valid):.4f}); "
351
+ "using robust median-smoothed fallback background."
352
+ )
353
+ fill = np.nanmedian(sampled)
354
+ if not np.isfinite(fill):
355
+ fill = float(np.nanmedian(sub))
356
+ tmp = np.where(np.isfinite(sampled), sampled, fill)
357
+ sub_bg = median_filter(tmp, size=31).astype(np.float64)
358
+ else:
359
+ sub_bg = estimate_background_region(
360
+ sampled,
361
+ order=order,
362
+ valid_mask=valid,
363
+ method=method,
364
+ smooth_sigma=smooth_sigma,
365
+ sigma_clip=sigma_clip,
366
+ maxiters=maxiters,
367
+ bspline_smooth=bspline_smooth,
368
+ clip_mode=clip_mode,
369
+ ).astype(np.float64)
370
+
371
+ background[y0:y1, :] = sub_bg
372
+ if label == "lower":
373
+ model_lower[:, :] = sub_bg
374
+ else:
375
+ model_upper[:, :] = sub_bg
376
+
377
+ return background, model_lower, model_upper
378
+
379
+ # 构建加宽且保留级次谷值的掩膜
380
+ def create_widened_mask(apertures: ApertureSet, flat_data: np.ndarray, margin: int,
381
+ n_mask_below: int = 0, n_mask_above: int = 0) -> Tuple[np.ndarray, np.ndarray, np.ndarray, list]:
382
+ """
383
+ Create a binary mask widened by margin, strictly preserving valleys between orders.
384
+ Extrapolates virtual orders along columns if n_mask_below/above > 0.
385
+ Returns: (binary_mask, lo_traces, hi_traces, order_ids)
386
+ """
387
+ from scipy.interpolate import UnivariateSpline
388
+
389
+ height, width = flat_data.shape
390
+ x_all = np.arange(width, dtype=float)
391
+
392
+ all_ap_ids = sorted(list(apertures.apertures.keys()))
393
+ n_ap = len(all_ap_ids)
394
+
395
+ # 1. Evaluate real orders
396
+ real_lo = np.zeros((n_ap, width))
397
+ real_hi = np.zeros((n_ap, width))
398
+ real_centers = np.zeros((n_ap, width))
399
+
400
+ for i, ap_id in enumerate(all_ap_ids):
401
+ ap = apertures.get_aperture(ap_id)
402
+ real_centers[i] = ap.get_position(x_all)
403
+ real_lo[i] = ap.get_lower(x_all) - margin
404
+ real_hi[i] = ap.get_upper(x_all) + margin
405
+
406
+ # 2. Extrapolate virtual orders along columns
407
+ # 沿级次方向外推二维边界数据
408
+ def extrapolate_2d(data_2d, n_extrap, direction='below'):
409
+ if n_ap < 2 or n_extrap <= 0:
410
+ return np.zeros((0, width))
411
+
412
+ res = np.zeros((n_extrap, width))
413
+
414
+ # 只使用靠近外推方向的一半级次数据,避免另一端的趋势带来远端牵制偏差
415
+ n_half = max(4, n_ap // 2) if n_ap >= 4 else n_ap
416
+ k_deg = min(3, n_half - 1)
417
+
418
+ if direction == 'below':
419
+ x_eval = np.arange(-n_extrap, 0, dtype=float)
420
+ fit_indices = np.arange(n_half, dtype=float)
421
+ data_subset = data_2d[:n_half, :]
422
+ else:
423
+ x_eval = np.arange(n_ap, n_ap + n_extrap, dtype=float)
424
+ fit_indices = np.arange(n_ap - n_half, n_ap, dtype=float)
425
+ data_subset = data_2d[-n_half:, :]
426
+
427
+ for xi in range(width):
428
+ col_data = data_subset[:, xi]
429
+ # 沿列方向利用半数探测级次进行 Spline 平滑
430
+ spl = UnivariateSpline(fit_indices, col_data, k=k_deg, s=n_half, ext=0)
431
+ res[:, xi] = spl(x_eval)
432
+
433
+ return res
434
+
435
+ lo_below = extrapolate_2d(real_lo, n_mask_below, 'below')
436
+ hi_below = extrapolate_2d(real_hi, n_mask_below, 'below')
437
+ c_below = extrapolate_2d(real_centers, n_mask_below, 'below')
438
+
439
+ lo_above = extrapolate_2d(real_lo, n_mask_above, 'above')
440
+ hi_above = extrapolate_2d(real_hi, n_mask_above, 'above')
441
+ c_above = extrapolate_2d(real_centers, n_mask_above, 'above')
442
+
443
+ # Combine real and virtual
444
+ full_lo = np.vstack((lo_below, real_lo, lo_above)) if n_mask_below or n_mask_above else real_lo
445
+ full_hi = np.vstack((hi_below, real_hi, hi_above)) if n_mask_below or n_mask_above else real_hi
446
+ full_centers = np.vstack((c_below, real_centers, c_above)) if n_mask_below or n_mask_above else real_centers
447
+
448
+ base_id_min, base_id_max = (min(all_ap_ids), max(all_ap_ids)) if all_ap_ids else (0, 0)
449
+ full_ids = [base_id_min - n_mask_below + i for i in range(n_mask_below)] + all_ap_ids + \
450
+ [base_id_max + 1 + i for i in range(n_mask_above)]
451
+ n_total = len(full_ids)
452
+
453
+ # 3. Enforce valley gap strictly
454
+ for i in range(n_total - 1):
455
+ for xi in range(width):
456
+ if full_hi[i, xi] >= full_lo[i+1, xi] - 1.0:
457
+ c1 = int(round(full_centers[i, xi]))
458
+ c2 = int(round(full_centers[i+1, xi]))
459
+ c1 = max(0, min(c1, height - 1))
460
+ c2 = max(0, min(c2, height - 1))
461
+ valley = c1 + np.argmin(flat_data[c1:c2, xi]) if c2 > c1 + 2 else int(round(0.5 * (c1 + c2)))
462
+ full_hi[i, xi] = valley - 0.5
463
+ full_lo[i+1, xi] = valley + 0.5
464
+
465
+ # 4. Draw Mask
466
+ step3_mask = np.zeros((height, width), dtype=np.uint8)
467
+ for i in range(n_total):
468
+ for xi in range(width):
469
+ if np.isfinite(full_lo[i, xi]) and np.isfinite(full_hi[i, xi]):
470
+ r0 = max(0, int(np.ceil(full_lo[i, xi])))
471
+ r1 = min(height, int(np.floor(full_hi[i, xi])) + 1)
472
+ if r1 > r0:
473
+ step3_mask[r0:r1, xi] = 1
474
+
475
+ return step3_mask, full_lo, full_hi, full_ids
476
+
477
+
478
+ # 执行散射光背景估算与扣除
479
+ def process_background_stage(science_image: np.ndarray,
480
+ output_dir_base: str,
481
+ output_subdir: str = 'step3_scatterlight',
482
+ output_tag: str = '',
483
+ apertures: Optional[ApertureSet] = None,
484
+ mask_margin_scale: float = 1.2,
485
+ sci_header=None,
486
+ order_mask: Optional[np.ndarray] = None,
487
+ poly_order: int = 3,
488
+ bg_method: str = 'convolution',
489
+ smooth_sigma: float = 20.0,
490
+ sigma_clip_val: float = 3.0,
491
+ maxiters: int = 4,
492
+ bspline_smooth: float = 1.0,
493
+ mask_margin_pixels: int = 1,
494
+ n_mask_below: int = 4,
495
+ n_mask_above: int = 4,
496
+ clip_mode: str = 'upper',
497
+ split_row: int = 2068,
498
+ kernel_sigma_x: float = 40.0,
499
+ kernel_sigma_y: float = 15.0,
500
+ spline_smooth_factor: float = 1.0,
501
+ spline_post_smooth_x: float = 5.0,
502
+ save_plots: bool = True,
503
+ fig_format: str = 'png') -> np.ndarray:
504
+ """
505
+ Execute scattered light subtraction stage (Step 3).
506
+
507
+ The order mask (MasterFlat_orders_mask) should be built once via
508
+ build_and_save_order_mask() and passed in via the ``order_mask`` parameter.
509
+ If omitted, the mask is built on-the-fly from ``apertures`` (legacy path,
510
+ discouraged because it re-saves the mask file for every image).
511
+
512
+ Returns:
513
+ Background model (2D array, same shape as science_image)
514
+ """
515
+ remover = BackgroundRemover()
516
+
517
+ if bg_method == '2d_poly':
518
+ bg_method = 'chebyshev'
519
+ elif bg_method == 'smooth':
520
+ bg_method = 'gaussian_smooth'
521
+
522
+ if order_mask is not None:
523
+ # Ensure order_mask is boolean (it may be loaded as int32 with order IDs)
524
+ if order_mask.dtype != bool:
525
+ order_mask = order_mask > 0
526
+ logger.info("Step 3: using pre-built widened order mask for scattered light fitting.")
527
+ else:
528
+ logger.warning("No order_mask provided in Step 3; fitting without order mask.")
529
+ order_mask = np.zeros_like(science_image, dtype=bool)
530
+
531
+ sampled_background = science_image.astype(np.float64).copy()
532
+ sampled_background[order_mask] = np.nan
533
+
534
+ background, model_lower, model_upper = remover._estimate_background_split_halves(
535
+ science_image, order_mask,
536
+ order=poly_order, method=bg_method,
537
+ smooth_sigma=smooth_sigma, sigma_clip=sigma_clip_val,
538
+ maxiters=maxiters, bspline_smooth=bspline_smooth,
539
+ split_row=split_row, clip_mode=clip_mode,
540
+ kernel_sigma_x=kernel_sigma_x, kernel_sigma_y=kernel_sigma_y,
541
+ spline_smooth_factor=spline_smooth_factor,
542
+ spline_post_smooth_x=spline_post_smooth_x,
543
+ )
544
+ remover.background_2d = background
545
+ logger.info(
546
+ f"Split-half scattered light estimated ({bg_method}): "
547
+ f"min={np.nanmin(background):.1f}, max={np.nanmax(background):.1f}, "
548
+ f"inter-order fraction={np.mean(~order_mask):.3f}"
549
+ )
550
+
551
+ # ------------------------------------------------------------------
552
+ # Save outputs
553
+ # ------------------------------------------------------------------
554
+ tag = output_tag or ''
555
+ out_dir = Path(output_dir_base) / output_subdir
556
+ out_dir.mkdir(parents=True, exist_ok=True)
557
+
558
+ # Derive the science-image stem from tag (tag = '{sci_name}_' by convention).
559
+ sci_stem = tag.rstrip('_') if tag else 'output'
560
+
561
+ # Compute residual now so all three arrays are ready for the combined plot.
562
+ residual = sampled_background.astype(np.float64) - background.astype(np.float64)
563
+ residual[order_mask] = np.nan
564
+
565
+ # Combined 3-panel diagnostic: bkg_orders_masked | bkg_model | bkg_residual
566
+ # All three panels share the same y-axis and a single shared colorbar whose
567
+ # scale is determined by the 1-99th percentile of the data/model arrays
568
+ # (panels 1 & 2), while the residual uses a symmetric scale centred on 0.
569
+ if save_plots:
570
+ data_arr = np.array(sampled_background, dtype=float)
571
+ model_arr = np.array(background, dtype=float)
572
+ resid_arr = np.array(residual, dtype=float)
573
+
574
+ # data_arr and resid_arr already have NaN in masked order regions.
575
+ # model_arr is intentionally NOT masked so the full background surface
576
+ # (including under orders) is visible, showing overall variation.
577
+
578
+ # Shared vmin/vmax for panels 1 & 2 (data and model on the same scale)
579
+ finite_dm = np.isfinite(data_arr) | np.isfinite(model_arr)
580
+ if np.any(finite_dm):
581
+ vmin_dm = float(np.nanpercentile(np.where(np.isfinite(data_arr), data_arr, np.nan), 1))
582
+ vmax_dm = float(np.nanpercentile(np.where(np.isfinite(data_arr), data_arr, np.nan), 99))
583
+ else:
584
+ vmin_dm, vmax_dm = 0.0, 1.0
585
+
586
+ # Symmetric scale for residual panel (centred on 0)
587
+ finite_r = np.isfinite(resid_arr)
588
+ if np.any(finite_r):
589
+ rlim = float(np.nanpercentile(np.abs(resid_arr[finite_r]), 99))
590
+ else:
591
+ rlim = 1.0
592
+ vmin_r, vmax_r = -rlim, rlim
593
+
594
+ rows, cols = data_arr.shape
595
+ # Width ratio: 3 image panels + 2 narrow colorbars (one shared, one residual)
596
+ fig, axes = plt.subplots(
597
+ 1, 3,
598
+ figsize=(18, 6),
599
+ sharey=True,
600
+ )
601
+ fig.subplots_adjust(wspace=0.04)
602
+
603
+ titles = [
604
+ 'Scattered Light Data',
605
+ 'Scattered Light Model',
606
+ 'Residual (data − model)',
607
+ ]
608
+ arrays = [data_arr, model_arr, resid_arr]
609
+ vmins = [vmin_dm, vmin_dm, vmin_r]
610
+ vmaxs = [vmax_dm, vmax_dm, vmax_r]
611
+ cmap_names = ['viridis', 'viridis', 'seismic']
612
+
613
+ # Build colormap copies: panels 1 & 3 have NaN (masked orders) shown as
614
+ # white (blank); panel 2 (model) has no NaN, keep default.
615
+ cmaps_used = []
616
+ for i, cn in enumerate(cmap_names):
617
+ cm = mcm.get_cmap(cn).copy()
618
+ if i != 1: # panels 0 (data) and 2 (residual)
619
+ cm.set_bad(color='white')
620
+ cmaps_used.append(cm)
621
+
622
+ ims = []
623
+ for ax, arr, title, vmin, vmax, cmap in zip(
624
+ axes, arrays, titles, vmins, vmaxs, cmaps_used):
625
+ im = ax.imshow(arr, origin='lower', aspect='auto',
626
+ cmap=cmap, vmin=vmin, vmax=vmax)
627
+ ax.set_title(title, fontsize=10)
628
+ ax.set_xlabel('Pixel (X)')
629
+ ims.append(im)
630
+
631
+ axes[0].set_ylabel('Pixel (Y)')
632
+ for ax in axes[1:]:
633
+ ax.tick_params(labelleft=False)
634
+
635
+ # Shared colorbar for panels 1 & 2 (attached to panel 2)
636
+ fig.colorbar(ims[1], ax=axes[1], fraction=0.035, pad=0.02,
637
+ label='Counts (shared scale)')
638
+ # Separate colorbar for residual panel
639
+ fig.colorbar(ims[2], ax=axes[2], fraction=0.035, pad=0.02,
640
+ label='Residual (counts)')
641
+
642
+ fig.suptitle('Background Diagnostics (orders masked)', fontsize=11)
643
+ plt.savefig(str(out_dir / f'{sci_stem}_bkg_diagnostic.{fig_format}'),
644
+ dpi=150, bbox_inches='tight')
645
+ plt.close(fig)
646
+ logger.info(f"Saved background diagnostic panel to "
647
+ f"{out_dir / f'{sci_stem}_bkg_diagnostic.{fig_format}'}")
648
+
649
+ # 4) Corrected image FITS only (no PNG — full science image, not a diagnostic)
650
+ corrected = (science_image.astype(np.float64) - background.astype(np.float64)).astype(np.float32)
651
+ corrected_fits = out_dir / f'{sci_stem}.fits'
652
+ write_fits_image(str(corrected_fits), corrected, header=sci_header, dtype='float32')
653
+
654
+ return background