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.
- specproc/__init__.py +9 -0
- specproc/calib_data/linelists/FeAr.dat +375 -0
- specproc/calib_data/linelists/README +79 -0
- specproc/calib_data/linelists/airvac.cl +61 -0
- specproc/calib_data/linelists/argon.dat +161 -0
- specproc/calib_data/linelists/ctiohear.dat +106 -0
- specproc/calib_data/linelists/ctiohenear.dat +146 -0
- specproc/calib_data/linelists/cuar.dat +459 -0
- specproc/calib_data/linelists/henear.dat +93 -0
- specproc/calib_data/linelists/henearhres.dat +300 -0
- specproc/calib_data/linelists/idhenear.dat +127 -0
- specproc/calib_data/linelists/krypton.dat +109 -0
- specproc/calib_data/linelists/lowskylines.dat +577 -0
- specproc/calib_data/linelists/ohlines.dat +268 -0
- specproc/calib_data/linelists/skylines.dat +1385 -0
- specproc/calib_data/linelists/thar-noao.dat +4169 -0
- specproc/calib_data/linelists/thar.dat +3057 -0
- specproc/calib_data/linelists/thorium.dat +3057 -0
- specproc/calib_data/linelists/vacidhenear.dat +127 -0
- specproc/calib_data/linelists/vacthar.dat +3057 -0
- specproc/calib_data/linelists/vacthorium.dat +3057 -0
- specproc/calib_data/linelists/xenon.dat +89 -0
- specproc/calib_data/telescopes/lijiang24hrs/lijiang24_hrs_thar_lines.csv +550 -0
- 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
- specproc/calib_data/telescopes/xinglong216bfosc//346/212/245/345/221/212/345/233/276.docx +0 -0
- 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
- specproc/calib_data/telescopes/xinglong216hrs/wlcalib_20211123011_A.png +0 -0
- specproc/calib_data/telescopes/xinglong216hrs/xinglong216_thar_lines.csv +517 -0
- specproc/config/__init__.py +1 -0
- specproc/config/config_manager.py +431 -0
- specproc/core/__init__.py +1 -0
- specproc/core/basic_reduction.py +615 -0
- specproc/core/data_structures.py +525 -0
- specproc/core/de_blazing.py +210 -0
- specproc/core/extraction.py +375 -0
- specproc/core/flat_correction.py +1255 -0
- specproc/core/line_identification.py +302 -0
- specproc/core/order_stitching.py +187 -0
- specproc/core/order_tracing.py +1587 -0
- specproc/core/processing_pipeline.py +1183 -0
- specproc/core/scattered_light.py +654 -0
- specproc/core/slit_pipeline.py +762 -0
- specproc/core/wave_calibration.py +961 -0
- specproc/gui/__init__.py +1 -0
- specproc/gui/line_identifier.py +731 -0
- specproc/gui/main_window.py +2315 -0
- specproc/gui/settings_dialog.py +946 -0
- specproc/main.py +388 -0
- specproc/plotting/__init__.py +1 -0
- specproc/plotting/spectra_plotter.py +483 -0
- specproc/utils/__init__.py +1 -0
- specproc/utils/airvac.py +123 -0
- specproc/utils/fits_io.py +269 -0
- specproc/utils/image_processing.py +426 -0
- specproc/utils/overscan.py +600 -0
- specproc-1.0.0.dist-info/METADATA +668 -0
- specproc-1.0.0.dist-info/RECORD +61 -0
- specproc-1.0.0.dist-info/WHEEL +5 -0
- specproc-1.0.0.dist-info/entry_points.txt +5 -0
- specproc-1.0.0.dist-info/licenses/LICENSE +674 -0
- 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
|