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,1255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
2D flat-field correction stage — Step 4 of the spectral reduction pipeline.
|
|
3
|
+
|
|
4
|
+
Applies the pixel-to-pixel flat correction map (built in Step 2 / order_tracing)
|
|
5
|
+
to a science image, and persists all flat-model artefacts to the step4 output
|
|
6
|
+
directory.
|
|
7
|
+
|
|
8
|
+
Main entry point: process_flat_correction_stage()
|
|
9
|
+
"""
|
|
10
|
+
# 构建二维平场响应模型并校正科学图像。
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import pickle
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
from scipy import ndimage
|
|
22
|
+
from scipy.signal import savgol_filter
|
|
23
|
+
from scipy.interpolate import UnivariateSpline
|
|
24
|
+
from scipy.ndimage import gaussian_filter1d
|
|
25
|
+
from typing import Dict, Tuple, List
|
|
26
|
+
from specproc.core.data_structures import FlatField, ApertureSet
|
|
27
|
+
from specproc.utils.fits_io import write_fits_image
|
|
28
|
+
from specproc.plotting.spectra_plotter import plot_2d_image_to_file
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# 保存平场校正模型及诊断产物
|
|
34
|
+
def save_flat_correction_products(flat_field: FlatField,
|
|
35
|
+
apertures: Optional[ApertureSet],
|
|
36
|
+
out_dir: Path,
|
|
37
|
+
processor=None,
|
|
38
|
+
fig_format: str = 'png',
|
|
39
|
+
save_plots: bool = True) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Persist Step-4 flat-model artefacts (blaze profiles, sensitivity map,
|
|
42
|
+
pixel flat, 2D correction map, response map, diagnostic plots).
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
processor : FlatCorrectionModelBuilder, optional
|
|
47
|
+
If provided, re-use its already-computed response_map and diagnostics
|
|
48
|
+
instead of rebuilding from scratch (saves ~5 s).
|
|
49
|
+
"""
|
|
50
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
if flat_field.blaze_profiles:
|
|
53
|
+
with open(out_dir / 'blaze_profiles.pkl', 'wb') as f:
|
|
54
|
+
pickle.dump(flat_field.blaze_profiles, f)
|
|
55
|
+
|
|
56
|
+
if flat_field.cross_profiles:
|
|
57
|
+
with open(out_dir / 'cross_profiles.pkl', 'wb') as f:
|
|
58
|
+
pickle.dump(flat_field.cross_profiles, f)
|
|
59
|
+
|
|
60
|
+
# If processor is provided, save its diagnostics directly.
|
|
61
|
+
if processor is not None:
|
|
62
|
+
# Compute clean_flat for diagnostics
|
|
63
|
+
clean_flat = flat_field.flat_data
|
|
64
|
+
if flat_field.scattered_light is not None:
|
|
65
|
+
clean_flat = np.clip(
|
|
66
|
+
flat_field.flat_data.astype(np.float32) - flat_field.scattered_light.astype(np.float32),
|
|
67
|
+
1e-6,
|
|
68
|
+
None,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
clean_flat_corrected = None
|
|
72
|
+
flat_corr = flat_field.pixel_flat if flat_field.pixel_flat is not None else flat_field.flat_corr_2d
|
|
73
|
+
if flat_corr is not None:
|
|
74
|
+
safe_flat = flat_corr.astype(np.float32)
|
|
75
|
+
bad = (~np.isfinite(safe_flat)) | (safe_flat <= 0.05)
|
|
76
|
+
if np.any(bad):
|
|
77
|
+
safe_flat = safe_flat.copy()
|
|
78
|
+
safe_flat[bad] = 1.0
|
|
79
|
+
clean_flat_corrected = clean_flat.astype(np.float32) / safe_flat
|
|
80
|
+
|
|
81
|
+
processor.save_step4_diagnostics(
|
|
82
|
+
out_dir,
|
|
83
|
+
clean_flat=clean_flat.astype(np.float32),
|
|
84
|
+
clean_flat_corrected=clean_flat_corrected,
|
|
85
|
+
fig_format=fig_format,
|
|
86
|
+
save_plots=save_plots
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# 构建二维平场响应模型
|
|
92
|
+
class FlatCorrectionModelBuilder:
|
|
93
|
+
# 初始化对象状态
|
|
94
|
+
def __init__(self):
|
|
95
|
+
self.flat_data = None
|
|
96
|
+
self.flat_mask = None
|
|
97
|
+
self.response_map = None
|
|
98
|
+
self.smoothed_model = None
|
|
99
|
+
self.pixel_flat = None
|
|
100
|
+
self.illumination_flat = None
|
|
101
|
+
self.flat_corr_2d = None
|
|
102
|
+
self.flat_sens = None
|
|
103
|
+
self.order_diagnostics = {}
|
|
104
|
+
|
|
105
|
+
# 平滑单级次一维响应曲线
|
|
106
|
+
def _smooth_order_response(self, flux_1d: np.ndarray, method: str = 'savgol',
|
|
107
|
+
window: int = 21, bspline_smooth: float = 0.5) -> np.ndarray:
|
|
108
|
+
"""Smooth per-order 1D blaze extraction with selectable method."""
|
|
109
|
+
x = np.arange(flux_1d.size)
|
|
110
|
+
y = np.asarray(flux_1d, dtype=float)
|
|
111
|
+
|
|
112
|
+
valid = np.isfinite(y) & (y > 0)
|
|
113
|
+
if np.sum(valid) < max(20, int(0.2 * y.size)):
|
|
114
|
+
med = np.nanmedian(y[valid]) if np.any(valid) else 1.0
|
|
115
|
+
return np.full_like(y, med if np.isfinite(med) and med > 0 else 1.0)
|
|
116
|
+
|
|
117
|
+
y_interp = np.interp(x, x[valid], y[valid])
|
|
118
|
+
win = max(5, int(window))
|
|
119
|
+
win = min(win, y.size - 1 if y.size % 2 == 0 else y.size)
|
|
120
|
+
if win < 7:
|
|
121
|
+
win = 7
|
|
122
|
+
if win % 2 == 0:
|
|
123
|
+
win += 1
|
|
124
|
+
|
|
125
|
+
m = (method or 'savgol').strip().lower()
|
|
126
|
+
if m == 'median':
|
|
127
|
+
smooth = ndimage.median_filter(y_interp, size=win, mode='nearest')
|
|
128
|
+
smooth = savgol_filter(smooth, window_length=win, polyorder=min(3, win - 2), mode='interp')
|
|
129
|
+
elif m == 'bspline':
|
|
130
|
+
k = min(3, max(1, len(x) - 1))
|
|
131
|
+
s = max(0.0, float(bspline_smooth)) * len(x)
|
|
132
|
+
smooth = UnivariateSpline(x, y_interp, k=k, s=s)(x)
|
|
133
|
+
else:
|
|
134
|
+
smooth = savgol_filter(y_interp, window_length=win, polyorder=min(3, win - 2), mode='interp')
|
|
135
|
+
|
|
136
|
+
# Iterative clipping similar to apnormalize-style robust fitting.
|
|
137
|
+
fit = smooth.copy()
|
|
138
|
+
keep = np.ones_like(y_interp, dtype=bool)
|
|
139
|
+
for _ in range(3):
|
|
140
|
+
resid = y_interp - fit
|
|
141
|
+
sigma = np.std(resid[keep]) if np.any(keep) else 0.0
|
|
142
|
+
if sigma <= 0:
|
|
143
|
+
break
|
|
144
|
+
new_keep = np.abs(resid) < 3.0 * sigma
|
|
145
|
+
if np.array_equal(new_keep, keep):
|
|
146
|
+
break
|
|
147
|
+
keep = new_keep
|
|
148
|
+
if np.sum(keep) < 10:
|
|
149
|
+
break
|
|
150
|
+
fit = np.interp(x, x[keep], y_interp[keep])
|
|
151
|
+
fit = savgol_filter(fit, window_length=win, polyorder=3, mode='interp')
|
|
152
|
+
|
|
153
|
+
# Safety: no negative or zero values
|
|
154
|
+
fit = np.maximum(fit, 1e-6)
|
|
155
|
+
return fit
|
|
156
|
+
|
|
157
|
+
# 自动拟合并平滑级次光栅响应
|
|
158
|
+
def _smooth_blaze_auto_bspline(self, flux_1d: np.ndarray,
|
|
159
|
+
smooth_factor: float = 1.0,
|
|
160
|
+
prefilter_size: int = 15) -> np.ndarray:
|
|
161
|
+
"""Automated B-spline blaze extraction.
|
|
162
|
+
|
|
163
|
+
Uses SciPy's automated knot selection driven by a dynamic smoothing condition
|
|
164
|
+
derived from local noise variance. This eliminates the need for manual knot
|
|
165
|
+
spacing and perfectly tracks complex order shapes natively.
|
|
166
|
+
"""
|
|
167
|
+
from scipy.interpolate import splrep, splev
|
|
168
|
+
|
|
169
|
+
x = np.arange(flux_1d.size)
|
|
170
|
+
y = np.asarray(flux_1d, dtype=float)
|
|
171
|
+
|
|
172
|
+
valid = np.isfinite(y) & (y > 0)
|
|
173
|
+
n_valid = int(np.sum(valid))
|
|
174
|
+
if n_valid < max(20, int(0.2 * y.size)):
|
|
175
|
+
med = np.nanmedian(y[valid]) if np.any(valid) else 1.0
|
|
176
|
+
return np.full_like(y, med if np.isfinite(med) and med > 0 else 1.0)
|
|
177
|
+
|
|
178
|
+
actual_prefilter = min(prefilter_size, max(3, n_valid // 5))
|
|
179
|
+
if actual_prefilter % 2 == 0:
|
|
180
|
+
actual_prefilter += 1
|
|
181
|
+
|
|
182
|
+
# Pre-clean: median filter kills local outliers
|
|
183
|
+
y_clean = ndimage.median_filter(
|
|
184
|
+
np.interp(x, x[valid], y[valid]), size=actual_prefilter, mode='nearest')
|
|
185
|
+
|
|
186
|
+
# Fit region: > 1% of peak to allow knots to reach the very edges naturally
|
|
187
|
+
peak = np.max(y_clean)
|
|
188
|
+
sig_mask = y_clean > (peak * 0.01)
|
|
189
|
+
x_fit = x[sig_mask].astype(float)
|
|
190
|
+
y_fit = y_clean[sig_mask]
|
|
191
|
+
|
|
192
|
+
if len(x_fit) < 10:
|
|
193
|
+
return np.maximum(y_clean, 1.0)
|
|
194
|
+
|
|
195
|
+
# BUG FIX: Estimate noise from the RAW unsmoothed data!
|
|
196
|
+
# Using y_fit (which is median-filtered) yields a variance near 0,
|
|
197
|
+
# causing 's' to be tiny and forcing B-spline to overfit all wiggles.
|
|
198
|
+
y_raw = np.interp(x, x[valid], y[valid])
|
|
199
|
+
y_raw_fit = y_raw[sig_mask]
|
|
200
|
+
|
|
201
|
+
# Estimate true photon noise for 's' parameter (MAD of raw differences)
|
|
202
|
+
noise_est = np.nanmedian(np.abs(np.diff(y_raw_fit))) / 0.6745
|
|
203
|
+
var_est = max(noise_est ** 2, 1.0)
|
|
204
|
+
|
|
205
|
+
# s = N * variance is the statistical sweet spot.
|
|
206
|
+
s_val = smooth_factor * len(x_fit) * var_est
|
|
207
|
+
|
|
208
|
+
# Cubic B-spline fit with automatic knot selection!
|
|
209
|
+
tck = splrep(x_fit, y_fit, k=3, s=s_val)
|
|
210
|
+
|
|
211
|
+
# Natural extrapolation to the full array
|
|
212
|
+
blaze_smooth = splev(x, tck)
|
|
213
|
+
|
|
214
|
+
# --- NEW: Wing Deviation Check & Local Smoothing ---
|
|
215
|
+
# Check outer 10% of the array for wild extrapolations (up/down curls)
|
|
216
|
+
n_pts = len(x)
|
|
217
|
+
wing_len = int(0.1 * n_pts)
|
|
218
|
+
|
|
219
|
+
if wing_len > 10:
|
|
220
|
+
trans_len = max(1, int(0.05 * n_pts)) # Transition zone is the inner 5% of the wing
|
|
221
|
+
flat_len = wing_len - trans_len # Absolute local smoothing zone is the outer 5%
|
|
222
|
+
|
|
223
|
+
# Threshold: 10x noise or 5% of peak, whichever is larger
|
|
224
|
+
dev_threshold = max(10.0 * noise_est, 0.05 * peak)
|
|
225
|
+
|
|
226
|
+
macro_trend = None # Compute lazily to save time if not needed
|
|
227
|
+
|
|
228
|
+
# 1. Left wing check
|
|
229
|
+
left_wing_spline = blaze_smooth[:wing_len]
|
|
230
|
+
left_wing_raw = y_clean[:wing_len]
|
|
231
|
+
left_dev = np.max(np.abs(left_wing_spline - left_wing_raw))
|
|
232
|
+
|
|
233
|
+
# Trigger if deviation is too large OR if the spline dives below 1.0 (causing a flat cutoff)
|
|
234
|
+
if left_dev > dev_threshold or np.min(left_wing_spline) < 1.0:
|
|
235
|
+
if macro_trend is None:
|
|
236
|
+
macro_trend = ndimage.gaussian_filter1d(y_clean, sigma=max(20.0, wing_len / 4.0))
|
|
237
|
+
|
|
238
|
+
# Cosine blend: 1.0 at x=0, smoothly transitions to 0.0 at x=wing_len
|
|
239
|
+
w_l = np.ones(wing_len, dtype=float)
|
|
240
|
+
x_trans = np.arange(trans_len, dtype=float)
|
|
241
|
+
w_l[flat_len:] = 0.5 * (1.0 + np.cos(np.pi * x_trans / trans_len))
|
|
242
|
+
blaze_smooth[:wing_len] = w_l * macro_trend[:wing_len] + (1.0 - w_l) * left_wing_spline
|
|
243
|
+
|
|
244
|
+
# 2. Right wing check
|
|
245
|
+
right_wing_spline = blaze_smooth[-wing_len:]
|
|
246
|
+
right_wing_raw = y_clean[-wing_len:]
|
|
247
|
+
right_dev = np.max(np.abs(right_wing_spline - right_wing_raw))
|
|
248
|
+
|
|
249
|
+
if right_dev > dev_threshold or np.min(right_wing_spline) < 1.0:
|
|
250
|
+
if macro_trend is None:
|
|
251
|
+
macro_trend = ndimage.gaussian_filter1d(y_clean, sigma=max(20.0, wing_len / 4.0))
|
|
252
|
+
|
|
253
|
+
# Cosine blend: 0.0 at x=start_idx, smoothly transitions to 1.0 at the end
|
|
254
|
+
w_r = np.ones(wing_len, dtype=float)
|
|
255
|
+
x_trans = np.arange(trans_len, dtype=float)
|
|
256
|
+
w_r[:trans_len] = 0.5 * (1.0 - np.cos(np.pi * x_trans / trans_len))
|
|
257
|
+
|
|
258
|
+
start_idx = n_pts - wing_len
|
|
259
|
+
blaze_smooth[start_idx:] = w_r * macro_trend[start_idx:] + (1.0 - w_r) * right_wing_spline
|
|
260
|
+
|
|
261
|
+
# Safety: no negative or zero values
|
|
262
|
+
return blaze_smooth
|
|
263
|
+
|
|
264
|
+
# 用切比雪夫多项式平滑光栅响应
|
|
265
|
+
def _smooth_blaze_chebyshev(self, flux_1d: np.ndarray,
|
|
266
|
+
degree: int = 5,
|
|
267
|
+
prefilter_size: int = 51) -> np.ndarray:
|
|
268
|
+
"""Smooth blaze using a low-order Chebyshev polynomial.
|
|
269
|
+
|
|
270
|
+
This enforces a rigid global bow-shape curve, which is perfect for very bright
|
|
271
|
+
orders where B-splines might accidentally overfit high-frequency interference
|
|
272
|
+
fringes (ripples).
|
|
273
|
+
"""
|
|
274
|
+
from numpy.polynomial.chebyshev import chebfit, chebval
|
|
275
|
+
|
|
276
|
+
x = np.arange(flux_1d.size)
|
|
277
|
+
y = np.asarray(flux_1d, dtype=float)
|
|
278
|
+
|
|
279
|
+
valid = np.isfinite(y) & (y > 0)
|
|
280
|
+
n_valid = int(np.sum(valid))
|
|
281
|
+
if n_valid < max(20, int(0.2 * y.size)):
|
|
282
|
+
med = np.nanmedian(y[valid]) if np.any(valid) else 1.0
|
|
283
|
+
return np.full_like(y, med if np.isfinite(med) and med > 0 else 1.0)
|
|
284
|
+
|
|
285
|
+
actual_prefilter = min(prefilter_size, max(3, n_valid // 5))
|
|
286
|
+
if actual_prefilter % 2 == 0:
|
|
287
|
+
actual_prefilter += 1
|
|
288
|
+
|
|
289
|
+
# Pre-clean: median filter kills local outliers
|
|
290
|
+
y_clean = ndimage.median_filter(
|
|
291
|
+
np.interp(x, x[valid], y[valid]), size=actual_prefilter, mode='nearest')
|
|
292
|
+
|
|
293
|
+
# Determine the valid (signal) region: > 5% of peak
|
|
294
|
+
peak = np.max(y_clean)
|
|
295
|
+
sig_mask = y_clean > (peak * 0.05)
|
|
296
|
+
sig_idx = np.where(sig_mask)[0]
|
|
297
|
+
if sig_idx.size < degree + 2:
|
|
298
|
+
return y_clean.copy()
|
|
299
|
+
|
|
300
|
+
x_fit = x[sig_mask].astype(float)
|
|
301
|
+
y_fit = y_clean[sig_mask]
|
|
302
|
+
|
|
303
|
+
# Map x to canonical [-1, 1] domain for Chebyshev
|
|
304
|
+
x_min, x_max = x_fit[0], x_fit[-1]
|
|
305
|
+
span = max(x_max - x_min, 1e-6)
|
|
306
|
+
x_mapped = 2.0 * (x_fit - x_min) / span - 1.0
|
|
307
|
+
|
|
308
|
+
# Robust iterative fitting with sigma-clipping
|
|
309
|
+
keep = np.ones(len(x_fit), dtype=bool)
|
|
310
|
+
coef = None
|
|
311
|
+
for _ in range(5):
|
|
312
|
+
if np.sum(keep) < degree + 2:
|
|
313
|
+
break
|
|
314
|
+
coef = chebfit(x_mapped[keep], y_fit[keep], degree)
|
|
315
|
+
resid = y_fit - chebval(x_mapped, coef)
|
|
316
|
+
std = np.std(resid[keep])
|
|
317
|
+
if std < 1e-5:
|
|
318
|
+
break
|
|
319
|
+
# Symmetric clip is ideal for symmetric interference ripples
|
|
320
|
+
new_keep = np.abs(resid) < 2.5 * std
|
|
321
|
+
if np.array_equal(new_keep, keep):
|
|
322
|
+
break
|
|
323
|
+
keep = new_keep
|
|
324
|
+
|
|
325
|
+
if coef is None:
|
|
326
|
+
coef = chebfit(x_mapped, y_fit, degree)
|
|
327
|
+
|
|
328
|
+
# Evaluate on all pixels, but clamp domain to [-1, 1] to avoid wild polynomial extrapolation
|
|
329
|
+
x_all_mapped = 2.0 * (x.astype(float) - x_min) / span - 1.0
|
|
330
|
+
x_all_mapped_clamped = np.clip(x_all_mapped, -1.0, 1.0)
|
|
331
|
+
blaze_smooth = chebval(x_all_mapped_clamped, coef)
|
|
332
|
+
|
|
333
|
+
# Safety clamp: no negative or zero values
|
|
334
|
+
blaze_smooth = np.maximum(blaze_smooth, 1.0)
|
|
335
|
+
|
|
336
|
+
return blaze_smooth
|
|
337
|
+
|
|
338
|
+
# 用上包络样条提取光栅响应
|
|
339
|
+
def _smooth_blaze_upper_envelope_bspline(self, flux_1d: np.ndarray,
|
|
340
|
+
knot_spacing: int = 200,
|
|
341
|
+
edge_nknots: int = 6,
|
|
342
|
+
prefilter_size: int = 15) -> np.ndarray:
|
|
343
|
+
"""Smooth blaze using an Iterative Asymmetric Upper-Envelope B-spline.
|
|
344
|
+
|
|
345
|
+
This method forces the spline to rest on the 'peaks' of the interference fringes
|
|
346
|
+
(the upper envelope) by assigning high weights to points above the fit and extremely
|
|
347
|
+
low weights to points in the fringe valleys.
|
|
348
|
+
"""
|
|
349
|
+
from scipy.interpolate import splrep, splev
|
|
350
|
+
|
|
351
|
+
x = np.arange(flux_1d.size)
|
|
352
|
+
y = np.asarray(flux_1d, dtype=float)
|
|
353
|
+
|
|
354
|
+
valid = np.isfinite(y) & (y > 0)
|
|
355
|
+
n_valid = int(np.sum(valid))
|
|
356
|
+
if n_valid < max(20, int(0.2 * y.size)):
|
|
357
|
+
med = np.nanmedian(y[valid]) if np.any(valid) else 1.0
|
|
358
|
+
return np.full_like(y, med if np.isfinite(med) and med > 0 else 1.0)
|
|
359
|
+
|
|
360
|
+
actual_prefilter = min(prefilter_size, max(3, n_valid // 5))
|
|
361
|
+
if actual_prefilter % 2 == 0:
|
|
362
|
+
actual_prefilter += 1
|
|
363
|
+
|
|
364
|
+
# Pre-clean: median filter kills sharp shot-noise so peaks are reliable
|
|
365
|
+
y_clean = ndimage.median_filter(
|
|
366
|
+
np.interp(x, x[valid], y[valid]), size=actual_prefilter, mode='nearest')
|
|
367
|
+
|
|
368
|
+
# Determine the valid (signal) region: > 5% of peak
|
|
369
|
+
peak = np.max(y_clean)
|
|
370
|
+
sig_mask = y_clean > (peak * 0.05)
|
|
371
|
+
sig_idx = np.where(sig_mask)[0]
|
|
372
|
+
if sig_idx.size < 50:
|
|
373
|
+
return y_clean.copy()
|
|
374
|
+
|
|
375
|
+
x_fit = x[sig_mask].astype(float)
|
|
376
|
+
y_fit = y_clean[sig_mask]
|
|
377
|
+
|
|
378
|
+
# --- Build knot vector ---
|
|
379
|
+
start, end = float(x_fit[0]), float(x_fit[-1])
|
|
380
|
+
span = end - start
|
|
381
|
+
edge_zone = max(50.0, span * 0.10)
|
|
382
|
+
|
|
383
|
+
mid_knots = np.arange(start + edge_zone, end - edge_zone, knot_spacing, dtype=float)
|
|
384
|
+
left_offsets = np.geomspace(10, edge_zone, edge_nknots)
|
|
385
|
+
right_offsets = np.geomspace(10, edge_zone, edge_nknots)
|
|
386
|
+
|
|
387
|
+
all_knots = np.sort(np.unique(np.concatenate([start + left_offsets, mid_knots, end - right_offsets])))
|
|
388
|
+
all_knots = all_knots[(all_knots > x_fit[0]) & (all_knots < x_fit[-1])]
|
|
389
|
+
|
|
390
|
+
# --- Iterative Asymmetric Fitting (Upper Envelope) ---
|
|
391
|
+
weights = np.ones(len(x_fit), dtype=float)
|
|
392
|
+
tck = None
|
|
393
|
+
|
|
394
|
+
for iteration in range(5):
|
|
395
|
+
try:
|
|
396
|
+
if all_knots.size >= 2:
|
|
397
|
+
tck = splrep(x_fit, y_fit, w=weights, t=all_knots, k=3, task=-1)
|
|
398
|
+
else:
|
|
399
|
+
tck = splrep(x_fit, y_fit, w=weights, k=3, s=len(x_fit))
|
|
400
|
+
except Exception:
|
|
401
|
+
tck = splrep(x_fit, y_fit, w=weights, k=3, s=len(x_fit)*2.0)
|
|
402
|
+
|
|
403
|
+
current_fit = splev(x_fit, tck)
|
|
404
|
+
resid = y_fit - current_fit
|
|
405
|
+
|
|
406
|
+
# Asymmetric weighting: Points ABOVE the fit (peaks) get weight 1.0.
|
|
407
|
+
# Points BELOW the fit (fringe valleys) get weight 0.01 to ignore them.
|
|
408
|
+
weights = np.where(resid > 0, 1.0, 0.01)
|
|
409
|
+
|
|
410
|
+
# Force high weights at the very edges to anchor the spline from flying away
|
|
411
|
+
weights[:20] = 1.0
|
|
412
|
+
weights[-20:] = 1.0
|
|
413
|
+
|
|
414
|
+
blaze_smooth = splev(x, tck)
|
|
415
|
+
|
|
416
|
+
return np.maximum(blaze_smooth, 1.0)
|
|
417
|
+
|
|
418
|
+
# 用切比雪夫上包络提取响应
|
|
419
|
+
def _smooth_blaze_upper_envelope_chebyshev(self, flux_1d: np.ndarray,
|
|
420
|
+
degree: int = 4,
|
|
421
|
+
prefilter_size: int = 21) -> np.ndarray:
|
|
422
|
+
"""Smooth blaze using an Iterative Asymmetric Upper-Envelope Chebyshev polynomial.
|
|
423
|
+
|
|
424
|
+
Combines the extreme rigidity of a low-order Chebyshev polynomial (to bridge over
|
|
425
|
+
deep local pits and fringes) with the asymmetric weighting of an upper-envelope
|
|
426
|
+
fit (to perfectly rest on the wave peaks without being dragged down by the valleys).
|
|
427
|
+
"""
|
|
428
|
+
from numpy.polynomial.chebyshev import chebfit, chebval
|
|
429
|
+
|
|
430
|
+
x = np.arange(flux_1d.size)
|
|
431
|
+
y = np.asarray(flux_1d, dtype=float)
|
|
432
|
+
|
|
433
|
+
valid = np.isfinite(y) & (y > 0)
|
|
434
|
+
n_valid = int(np.sum(valid))
|
|
435
|
+
if n_valid < max(20, int(0.2 * y.size)):
|
|
436
|
+
med = np.nanmedian(y[valid]) if np.any(valid) else 1.0
|
|
437
|
+
return np.full_like(y, med if np.isfinite(med) and med > 0 else 1.0)
|
|
438
|
+
|
|
439
|
+
actual_prefilter = min(prefilter_size, max(3, n_valid // 5))
|
|
440
|
+
if actual_prefilter % 2 == 0:
|
|
441
|
+
actual_prefilter += 1
|
|
442
|
+
|
|
443
|
+
# Pre-clean: median filter kills sharp shot-noise so peaks are reliable
|
|
444
|
+
y_clean = ndimage.median_filter(
|
|
445
|
+
np.interp(x, x[valid], y[valid]), size=actual_prefilter, mode='nearest')
|
|
446
|
+
|
|
447
|
+
# Determine the valid (signal) region: > 5% of peak
|
|
448
|
+
peak = np.max(y_clean)
|
|
449
|
+
sig_mask = y_clean > (peak * 0.05)
|
|
450
|
+
sig_idx = np.where(sig_mask)[0]
|
|
451
|
+
if sig_idx.size < degree + 2:
|
|
452
|
+
return y_clean.copy()
|
|
453
|
+
|
|
454
|
+
x_fit = x[sig_mask].astype(float)
|
|
455
|
+
y_fit = y_clean[sig_mask]
|
|
456
|
+
|
|
457
|
+
x_min, x_max = x_fit[0], x_fit[-1]
|
|
458
|
+
span = max(x_max - x_min, 1e-6)
|
|
459
|
+
x_mapped = 2.0 * (x_fit - x_min) / span - 1.0
|
|
460
|
+
|
|
461
|
+
# --- Iterative Asymmetric Fitting (Upper Envelope) ---
|
|
462
|
+
weights = np.ones(len(x_fit), dtype=float)
|
|
463
|
+
coef = None
|
|
464
|
+
|
|
465
|
+
edge_len = min(20, len(x_fit) // 10)
|
|
466
|
+
|
|
467
|
+
for iteration in range(5):
|
|
468
|
+
coef = chebfit(x_mapped, y_fit, degree, w=weights)
|
|
469
|
+
current_fit = chebval(x_mapped, coef)
|
|
470
|
+
resid = y_fit - current_fit
|
|
471
|
+
|
|
472
|
+
# Asymmetric weighting: Points ABOVE the fit (peaks) get weight 1.0.
|
|
473
|
+
# Points BELOW the fit (fringe valleys) get weight 0.01.
|
|
474
|
+
weights = np.where(resid > 0, 1.0, 0.01)
|
|
475
|
+
|
|
476
|
+
# Force high weights at the very edges to anchor the polynomial
|
|
477
|
+
if edge_len > 0:
|
|
478
|
+
weights[:edge_len] = 1.0
|
|
479
|
+
weights[-edge_len:] = 1.0
|
|
480
|
+
|
|
481
|
+
# Evaluate on all pixels, clamping the domain to [-1, 1] to avoid wild extrapolation
|
|
482
|
+
x_all_mapped = 2.0 * (x.astype(float) - x_min) / span - 1.0
|
|
483
|
+
x_all_mapped_clamped = np.clip(x_all_mapped, -1.0, 1.0)
|
|
484
|
+
blaze_smooth = chebval(x_all_mapped_clamped, coef)
|
|
485
|
+
|
|
486
|
+
return np.maximum(blaze_smooth, 1.0)
|
|
487
|
+
|
|
488
|
+
# 构建级次响应图与像素平场图
|
|
489
|
+
def build_order_response_map(self, apertures: ApertureSet,
|
|
490
|
+
source_image: Optional[np.ndarray] = None,
|
|
491
|
+
blaze_smooth_factor: float = 1.0,
|
|
492
|
+
width_smooth_window: int = 41,
|
|
493
|
+
profile_bin_step: float = 0.01,
|
|
494
|
+
n_profile_segments: int = 100,
|
|
495
|
+
profile_smooth_sigma: float = 6.0,
|
|
496
|
+
pixel_flat_min: float = 0.5,
|
|
497
|
+
pixel_flat_max: float = 1.5,
|
|
498
|
+
fringe_orders: int = 20
|
|
499
|
+
) -> Tuple[np.ndarray, Dict[int, np.ndarray], Dict[int, np.ndarray], np.ndarray, np.ndarray, Optional[np.ndarray]]:
|
|
500
|
+
"""
|
|
501
|
+
Build per-order response model and convert it to a 2D sensitivity map.
|
|
502
|
+
|
|
503
|
+
This follows the echelle flat-field philosophy used in IRAF/PypeIt/REDUCE:
|
|
504
|
+
estimate order-by-order blaze/response first, then construct a 2D correction map.
|
|
505
|
+
|
|
506
|
+
Improvements over a single global profile:
|
|
507
|
+
1. **Segmented cross-dispersion profiles**: The profile is built in
|
|
508
|
+
``n_profile_segments`` bins along X, then linearly interpolated at
|
|
509
|
+
each column, so the model tracks PSF shape changes (aberrations,
|
|
510
|
+
defocus, vignetting).
|
|
511
|
+
2. **Sparse-knots B-spline blaze fitting**: Interior knots are placed
|
|
512
|
+
at wide spacing (``blaze_knot_spacing``) to crush fringe ripples,
|
|
513
|
+
while dense knots near edges track the steep blaze roll-off
|
|
514
|
+
— so the 2D model is fringe-free and the resulting pixel_flat
|
|
515
|
+
absorbs fringe structure for correction.
|
|
516
|
+
"""
|
|
517
|
+
if self.flat_data is None and source_image is None:
|
|
518
|
+
raise RuntimeError("No flat data")
|
|
519
|
+
|
|
520
|
+
image = source_image if source_image is not None else self.flat_data
|
|
521
|
+
image = np.asarray(image, dtype=np.float64)
|
|
522
|
+
height, width = image.shape
|
|
523
|
+
x_coords = np.arange(width)
|
|
524
|
+
|
|
525
|
+
# Clamp segments to a sane range
|
|
526
|
+
n_profile_segments = max(1, min(n_profile_segments, 200))
|
|
527
|
+
|
|
528
|
+
response_map = np.zeros_like(image, dtype=np.float32)
|
|
529
|
+
coverage = np.zeros_like(image, dtype=bool)
|
|
530
|
+
|
|
531
|
+
blaze_profiles: Dict[int, np.ndarray] = {}
|
|
532
|
+
cross_profiles: Dict[int, np.ndarray] = {}
|
|
533
|
+
order_diag: Dict[int, Dict[str, np.ndarray]] = {}
|
|
534
|
+
|
|
535
|
+
# --- First Pass: Extract raw blaze for all orders and compute intensity percentiles ---
|
|
536
|
+
extracted_raw_blaze = {}
|
|
537
|
+
extracted_width_raw = {}
|
|
538
|
+
extracted_bounds = {}
|
|
539
|
+
|
|
540
|
+
for aperture_id, aperture in apertures.apertures.items():
|
|
541
|
+
lower = aperture.get_lower(x_coords)
|
|
542
|
+
upper = aperture.get_upper(x_coords)
|
|
543
|
+
|
|
544
|
+
# 1D aperture sum extraction inside traced boundaries.
|
|
545
|
+
raw_blaze = np.full(width, np.nan, dtype=float)
|
|
546
|
+
width_raw = np.full(width, np.nan, dtype=float)
|
|
547
|
+
per_col_bounds = []
|
|
548
|
+
|
|
549
|
+
for x in x_coords:
|
|
550
|
+
y_lo = min(lower[x], upper[x])
|
|
551
|
+
y_hi = max(lower[x], upper[x])
|
|
552
|
+
y1 = max(0, int(np.floor(y_lo)))
|
|
553
|
+
y2 = min(height, int(np.ceil(y_hi + 1)))
|
|
554
|
+
if y2 - y1 < 3:
|
|
555
|
+
per_col_bounds.append(None)
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
stripe = image[y1:y2, x]
|
|
559
|
+
yy = np.arange(y1, y2)
|
|
560
|
+
if self.flat_mask is not None:
|
|
561
|
+
local_bad = self.flat_mask[y1:y2, x] > 0
|
|
562
|
+
if np.all(local_bad):
|
|
563
|
+
per_col_bounds.append(None)
|
|
564
|
+
continue
|
|
565
|
+
stripe = stripe[~local_bad]
|
|
566
|
+
yy = yy[~local_bad]
|
|
567
|
+
|
|
568
|
+
if stripe.size < 3:
|
|
569
|
+
per_col_bounds.append(None)
|
|
570
|
+
continue
|
|
571
|
+
|
|
572
|
+
raw_blaze[x] = np.sum(stripe)
|
|
573
|
+
width_raw[x] = max(1.0, y_hi - y_lo)
|
|
574
|
+
per_col_bounds.append((yy, stripe))
|
|
575
|
+
|
|
576
|
+
extracted_raw_blaze[aperture_id] = raw_blaze
|
|
577
|
+
extracted_width_raw[aperture_id] = width_raw
|
|
578
|
+
extracted_bounds[aperture_id] = per_col_bounds
|
|
579
|
+
|
|
580
|
+
# Determine cutoff for fringe correction based on user parameter
|
|
581
|
+
oids = sorted(extracted_raw_blaze.keys())
|
|
582
|
+
if oids and fringe_orders > 0:
|
|
583
|
+
n_fringed = min(len(oids), fringe_orders)
|
|
584
|
+
fringed_oids = set(oids[:n_fringed])
|
|
585
|
+
is_fringed = {oid: (oid in fringed_oids) for oid in oids}
|
|
586
|
+
logger.info(f"Applying Upper Envelope Chebyshev Fit to the first {n_fringed} red-end orders.")
|
|
587
|
+
else:
|
|
588
|
+
is_fringed = {}
|
|
589
|
+
n_fringed = 0
|
|
590
|
+
|
|
591
|
+
# --- Second Pass: Smooth blaze and reconstruct 2D model with dynamic parameters ---
|
|
592
|
+
for aperture_id, aperture in apertures.apertures.items():
|
|
593
|
+
center = aperture.get_position(x_coords)
|
|
594
|
+
lower = aperture.get_lower(x_coords)
|
|
595
|
+
upper = aperture.get_upper(x_coords)
|
|
596
|
+
|
|
597
|
+
raw_blaze = extracted_raw_blaze[aperture_id]
|
|
598
|
+
width_raw = extracted_width_raw[aperture_id]
|
|
599
|
+
per_col_bounds = extracted_bounds[aperture_id]
|
|
600
|
+
|
|
601
|
+
# Dynamic rule based on auto-detected fringes
|
|
602
|
+
if is_fringed.get(aperture_id, False):
|
|
603
|
+
# Use Iterative Asymmetric Upper-Envelope Chebyshev to track peaks with rigid bow shape.
|
|
604
|
+
blaze_1d = self._smooth_blaze_upper_envelope_chebyshev(
|
|
605
|
+
raw_blaze,
|
|
606
|
+
degree=6,
|
|
607
|
+
prefilter_size=21
|
|
608
|
+
)
|
|
609
|
+
fit_method_label = 'Upper Env Chebyshev'
|
|
610
|
+
else:
|
|
611
|
+
# Medium & Dark blue orders: use automated B-spline.
|
|
612
|
+
# Create a two-step "transition zone" just past the fringe zone:
|
|
613
|
+
# +1 to +5 orders: multiply smooth factor by 50.0
|
|
614
|
+
# +6 to +15 orders: multiply smooth factor by 15.0
|
|
615
|
+
# This irons out small-scale wiggles gradually before reverting to normal flexibility.
|
|
616
|
+
current_factor = blaze_smooth_factor
|
|
617
|
+
order_idx = oids.index(aperture_id) if aperture_id in oids else -1
|
|
618
|
+
|
|
619
|
+
if n_fringed <= order_idx < n_fringed + 5:
|
|
620
|
+
current_factor *= 50.0
|
|
621
|
+
fit_method_label = 'Rigid Auto B-spline'
|
|
622
|
+
elif n_fringed + 5 <= order_idx < n_fringed + 15:
|
|
623
|
+
current_factor *= 15.0
|
|
624
|
+
fit_method_label = 'Rigid Auto B-spline'
|
|
625
|
+
else:
|
|
626
|
+
fit_method_label = 'Auto B-spline'
|
|
627
|
+
|
|
628
|
+
blaze_1d = self._smooth_blaze_auto_bspline(
|
|
629
|
+
raw_blaze,
|
|
630
|
+
smooth_factor=current_factor,
|
|
631
|
+
prefilter_size=21
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
width_smooth = self._smooth_order_response(
|
|
635
|
+
width_raw,
|
|
636
|
+
method='savgol',
|
|
637
|
+
window=width_smooth_window,
|
|
638
|
+
bspline_smooth=0.0,
|
|
639
|
+
)
|
|
640
|
+
width_smooth = np.clip(width_smooth, 1.0, None)
|
|
641
|
+
|
|
642
|
+
norm = np.nanmedian(blaze_1d[np.isfinite(blaze_1d) & (blaze_1d > 0)])
|
|
643
|
+
if not np.isfinite(norm) or norm <= 0:
|
|
644
|
+
norm = 1.0
|
|
645
|
+
blaze = blaze_1d / norm
|
|
646
|
+
blaze = np.where(np.isfinite(blaze) & (blaze > 1e-6), blaze, 1.0)
|
|
647
|
+
blaze_profiles[aperture_id] = blaze.astype(np.float32)
|
|
648
|
+
|
|
649
|
+
valid_cols = np.where(np.isfinite(raw_blaze))[0]
|
|
650
|
+
if valid_cols.size == 0:
|
|
651
|
+
logger.warning(f"Order {aperture_id}: no valid columns — skipped")
|
|
652
|
+
continue
|
|
653
|
+
|
|
654
|
+
sample_cols = set()
|
|
655
|
+
if valid_cols.size > 0:
|
|
656
|
+
sample_cols = set(np.unique(np.linspace(valid_cols.min(), valid_cols.max(), num=min(n_profile_segments, valid_cols.size), dtype=int)))
|
|
657
|
+
|
|
658
|
+
# ==============================================================
|
|
659
|
+
# Build segmented cross-dispersion profiles along X
|
|
660
|
+
# ==============================================================
|
|
661
|
+
step = max(0.002, float(profile_bin_step))
|
|
662
|
+
n_seg = n_profile_segments
|
|
663
|
+
|
|
664
|
+
# Determine column ranges for each segment
|
|
665
|
+
x_lo_seg = valid_cols.min()
|
|
666
|
+
x_hi_seg = valid_cols.max()
|
|
667
|
+
seg_edges = np.linspace(x_lo_seg, x_hi_seg + 1, n_seg + 1).astype(int)
|
|
668
|
+
seg_centers = 0.5 * (seg_edges[:-1] + seg_edges[1:]) # X center of each segment
|
|
669
|
+
|
|
670
|
+
# Build one profile per segment
|
|
671
|
+
seg_profiles_x = [] # list of (prof_x_i,) arrays — normalized y coordinates
|
|
672
|
+
seg_profiles_y = [] # list of (prof_y_i,) arrays — profile values
|
|
673
|
+
common_yn_grid = None # will be unified after first pass
|
|
674
|
+
|
|
675
|
+
for si in range(n_seg):
|
|
676
|
+
xs0, xs1 = int(seg_edges[si]), int(seg_edges[si + 1])
|
|
677
|
+
yn_seg = []
|
|
678
|
+
fn_seg = []
|
|
679
|
+
for x in range(xs0, min(xs1, width)):
|
|
680
|
+
if per_col_bounds[x] is None:
|
|
681
|
+
continue
|
|
682
|
+
yy, stripe = per_col_bounds[x]
|
|
683
|
+
bval = blaze_1d[x]
|
|
684
|
+
wval = width_smooth[x]
|
|
685
|
+
if (not np.isfinite(bval)) or bval <= 0 or (not np.isfinite(wval)) or wval <= 0:
|
|
686
|
+
continue
|
|
687
|
+
yn = (yy.astype(float) - center[x]) / wval
|
|
688
|
+
fn = stripe / bval
|
|
689
|
+
good = np.isfinite(yn) & np.isfinite(fn)
|
|
690
|
+
if np.any(good):
|
|
691
|
+
yn_seg.append(yn[good])
|
|
692
|
+
fn_seg.append(fn[good])
|
|
693
|
+
|
|
694
|
+
if not yn_seg:
|
|
695
|
+
seg_profiles_x.append(None)
|
|
696
|
+
seg_profiles_y.append(None)
|
|
697
|
+
continue
|
|
698
|
+
|
|
699
|
+
yn_cat = np.concatenate(yn_seg)
|
|
700
|
+
fn_cat = np.concatenate(fn_seg)
|
|
701
|
+
|
|
702
|
+
yn_min = max(-1.2, float(np.nanpercentile(yn_cat, 1)))
|
|
703
|
+
yn_max_v = min(1.2, float(np.nanpercentile(yn_cat, 99)))
|
|
704
|
+
if yn_max_v - yn_min < 0.2:
|
|
705
|
+
yn_min, yn_max_v = -0.8, 0.8
|
|
706
|
+
bins = np.arange(yn_min, yn_max_v + step, step)
|
|
707
|
+
if bins.size < 8:
|
|
708
|
+
bins = np.linspace(-0.8, 0.8, 81)
|
|
709
|
+
|
|
710
|
+
idx = np.digitize(yn_cat, bins) - 1
|
|
711
|
+
px, py = [], []
|
|
712
|
+
for i in range(len(bins) - 1):
|
|
713
|
+
pick = idx == i
|
|
714
|
+
if np.sum(pick) < 6:
|
|
715
|
+
continue
|
|
716
|
+
px.append(0.5 * (bins[i] + bins[i + 1]))
|
|
717
|
+
py.append(np.nanmedian(fn_cat[pick]))
|
|
718
|
+
|
|
719
|
+
if len(px) < 8:
|
|
720
|
+
seg_profiles_x.append(None)
|
|
721
|
+
seg_profiles_y.append(None)
|
|
722
|
+
continue
|
|
723
|
+
|
|
724
|
+
px = np.asarray(px, dtype=float)
|
|
725
|
+
py = np.asarray(py, dtype=float)
|
|
726
|
+
py = np.where(np.isfinite(py), py, 1.0)
|
|
727
|
+
py = np.clip(py, 1e-6, None)
|
|
728
|
+
|
|
729
|
+
# Smooth the binned profile
|
|
730
|
+
py = self._smooth_order_response(
|
|
731
|
+
py,
|
|
732
|
+
method='savgol',
|
|
733
|
+
window=max(11, int(11 * max(1.0, step / 0.01))),
|
|
734
|
+
bspline_smooth=0.0,
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
seg_profiles_x.append(px)
|
|
738
|
+
seg_profiles_y.append(py)
|
|
739
|
+
|
|
740
|
+
# Count valid segments
|
|
741
|
+
valid_seg_idx = [i for i in range(n_seg) if seg_profiles_x[i] is not None]
|
|
742
|
+
|
|
743
|
+
if not valid_seg_idx:
|
|
744
|
+
logger.warning(f"Order {aperture_id}: no valid profile segments — skipped")
|
|
745
|
+
continue
|
|
746
|
+
|
|
747
|
+
# Build a common y_norm grid that covers all segments
|
|
748
|
+
all_yn_min = min(seg_profiles_x[i].min() for i in valid_seg_idx)
|
|
749
|
+
all_yn_max = max(seg_profiles_x[i].max() for i in valid_seg_idx)
|
|
750
|
+
common_yn = np.arange(all_yn_min, all_yn_max + step, step)
|
|
751
|
+
|
|
752
|
+
# Resample each segment profile onto the common grid
|
|
753
|
+
seg_on_grid = np.full((n_seg, common_yn.size), np.nan, dtype=float)
|
|
754
|
+
for i in valid_seg_idx:
|
|
755
|
+
seg_on_grid[i] = np.interp(common_yn, seg_profiles_x[i], seg_profiles_y[i],
|
|
756
|
+
left=1e-6, right=1e-6)
|
|
757
|
+
|
|
758
|
+
# Fill missing segments by nearest-neighbour copy
|
|
759
|
+
for i in range(n_seg):
|
|
760
|
+
if i in valid_seg_idx:
|
|
761
|
+
continue
|
|
762
|
+
# Find nearest valid segment
|
|
763
|
+
dists = [abs(i - vi) for vi in valid_seg_idx]
|
|
764
|
+
nearest = valid_seg_idx[int(np.argmin(dists))]
|
|
765
|
+
seg_on_grid[i] = seg_on_grid[nearest]
|
|
766
|
+
|
|
767
|
+
# ----------------------------------------------------------
|
|
768
|
+
# Smooth profiles along dispersion direction (axis 0 = segment index)
|
|
769
|
+
# so that CSP varies continuously from segment to segment.
|
|
770
|
+
# ----------------------------------------------------------
|
|
771
|
+
if n_seg >= 3 and profile_smooth_sigma > 0:
|
|
772
|
+
for j in range(seg_on_grid.shape[1]):
|
|
773
|
+
col = seg_on_grid[:, j]
|
|
774
|
+
ok = np.isfinite(col)
|
|
775
|
+
if np.sum(ok) < 3:
|
|
776
|
+
continue
|
|
777
|
+
# Interpolate NaN gaps before smoothing
|
|
778
|
+
idx_ok = np.where(ok)[0]
|
|
779
|
+
col_filled = np.interp(np.arange(n_seg), idx_ok, col[idx_ok])
|
|
780
|
+
seg_on_grid[:, j] = gaussian_filter1d(
|
|
781
|
+
col_filled, sigma=profile_smooth_sigma, mode='nearest')
|
|
782
|
+
|
|
783
|
+
# Store the composite profile for diagnostics (average of all segments)
|
|
784
|
+
with np.errstate(all='ignore'):
|
|
785
|
+
mean_prof_y = np.nanmean(seg_on_grid, axis=0)
|
|
786
|
+
mean_prof_y = np.where(np.isfinite(mean_prof_y), mean_prof_y, 1e-6)
|
|
787
|
+
csum = np.nansum(mean_prof_y)
|
|
788
|
+
if np.isfinite(csum) and csum > 0:
|
|
789
|
+
cross_profiles[aperture_id] = (mean_prof_y / csum).astype(np.float32)
|
|
790
|
+
|
|
791
|
+
sampled_realspace = []
|
|
792
|
+
|
|
793
|
+
# ==============================================================
|
|
794
|
+
# Reconstruct 2D model: Model(X,Y) = P(y_norm; X) × Blaze(X)
|
|
795
|
+
# with P interpolated between segment profiles
|
|
796
|
+
# ==============================================================
|
|
797
|
+
for x in x_coords:
|
|
798
|
+
y_lo = min(lower[x], upper[x])
|
|
799
|
+
y_hi = max(lower[x], upper[x])
|
|
800
|
+
y1 = max(0, int(np.floor(y_lo)))
|
|
801
|
+
y2 = min(height, int(np.ceil(y_hi + 1)))
|
|
802
|
+
if y2 - y1 < 3:
|
|
803
|
+
continue
|
|
804
|
+
|
|
805
|
+
bval = blaze_1d[x]
|
|
806
|
+
wval = width_smooth[x]
|
|
807
|
+
if (not np.isfinite(bval)) or bval <= 0 or (not np.isfinite(wval)) or wval <= 0:
|
|
808
|
+
continue
|
|
809
|
+
|
|
810
|
+
yy = np.arange(y1, y2, dtype=float)
|
|
811
|
+
yreq = (yy - center[x]) / wval
|
|
812
|
+
|
|
813
|
+
# Interpolate profile between the two nearest segments
|
|
814
|
+
if n_seg == 1:
|
|
815
|
+
pval = np.interp(yreq, common_yn, seg_on_grid[0], left=1e-6, right=1e-6)
|
|
816
|
+
else:
|
|
817
|
+
# Find the fractional segment index for this column
|
|
818
|
+
frac = np.clip((float(x) - seg_centers[0]) / max(1.0, seg_centers[-1] - seg_centers[0]),
|
|
819
|
+
0.0, 1.0)
|
|
820
|
+
seg_f = frac * (n_seg - 1)
|
|
821
|
+
si_lo = max(0, min(int(np.floor(seg_f)), n_seg - 2))
|
|
822
|
+
si_hi = si_lo + 1
|
|
823
|
+
w_hi = seg_f - si_lo
|
|
824
|
+
w_lo = 1.0 - w_hi
|
|
825
|
+
|
|
826
|
+
p_lo = np.interp(yreq, common_yn, seg_on_grid[si_lo], left=1e-6, right=1e-6)
|
|
827
|
+
p_hi = np.interp(yreq, common_yn, seg_on_grid[si_hi], left=1e-6, right=1e-6)
|
|
828
|
+
pval = w_lo * p_lo + w_hi * p_hi
|
|
829
|
+
|
|
830
|
+
model_col = pval * bval
|
|
831
|
+
|
|
832
|
+
if x in sample_cols:
|
|
833
|
+
sampled_realspace.append({
|
|
834
|
+
'x': int(x),
|
|
835
|
+
'y': yy.astype(np.float32),
|
|
836
|
+
'ynorm': yreq.astype(np.float32),
|
|
837
|
+
'profile_val': pval.astype(np.float32),
|
|
838
|
+
'model_flux': model_col.astype(np.float32),
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
response_map[y1:y2, x] = model_col.astype(np.float32)
|
|
842
|
+
coverage[y1:y2, x] = True
|
|
843
|
+
|
|
844
|
+
order_diag[aperture_id] = {
|
|
845
|
+
'raw_blaze': raw_blaze.astype(np.float32),
|
|
846
|
+
'blaze_fit': blaze_1d.astype(np.float32),
|
|
847
|
+
'width_smooth': width_smooth.astype(np.float32),
|
|
848
|
+
'profile_x': common_yn.astype(np.float32),
|
|
849
|
+
'profile_y': mean_prof_y.astype(np.float32),
|
|
850
|
+
'seg_on_grid': seg_on_grid.astype(np.float32),
|
|
851
|
+
'seg_centers': seg_centers.astype(np.float32),
|
|
852
|
+
'sampled_realspace': sampled_realspace,
|
|
853
|
+
'n_profile_segments': n_seg,
|
|
854
|
+
'n_valid_segments': len(valid_seg_idx),
|
|
855
|
+
'blaze_method': fit_method_label,
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
# Smoothed model keeps blaze + cross-order envelope, without pixel noise.
|
|
859
|
+
smoothed_model = response_map.astype(np.float32)
|
|
860
|
+
|
|
861
|
+
# Pixel-to-pixel flat: high-frequency component around unity.
|
|
862
|
+
|
|
863
|
+
pixel_flat = np.ones_like(image, dtype=np.float32)
|
|
864
|
+
pixel_flat[coverage] = image[coverage] / (smoothed_model[coverage] + 1e-10)
|
|
865
|
+
bad_cov = coverage & (~np.isfinite(pixel_flat) | (pixel_flat < pixel_flat_min) | (pixel_flat > pixel_flat_max))
|
|
866
|
+
if np.any(bad_cov):
|
|
867
|
+
pixel_flat[bad_cov] = 1.0
|
|
868
|
+
if np.any(coverage):
|
|
869
|
+
pf_med = np.nanmedian(pixel_flat[coverage][np.isfinite(pixel_flat[coverage])])
|
|
870
|
+
if np.isfinite(pf_med) and pf_med > 0:
|
|
871
|
+
pixel_flat[coverage] = pixel_flat[coverage] / pf_med
|
|
872
|
+
pixel_flat = np.where(np.isfinite(pixel_flat), pixel_flat, 1.0)
|
|
873
|
+
pixel_flat[~coverage] = 1.0
|
|
874
|
+
|
|
875
|
+
# 将级次上下边界 1 pixel 的区域强制设为 1.0,防止边缘极低信噪比带来的除法突变
|
|
876
|
+
from scipy.ndimage import binary_erosion
|
|
877
|
+
eroded_coverage = binary_erosion(coverage, structure=np.ones((3, 1), dtype=bool))
|
|
878
|
+
pixel_flat[coverage & (~eroded_coverage)] = 1.0
|
|
879
|
+
|
|
880
|
+
# 进一步消除级次内部的异常突变:将信噪比低不可靠的极端像元限制得更严格些
|
|
881
|
+
edge_mask = coverage & ((pixel_flat < 0.8) | (pixel_flat > 1.2))
|
|
882
|
+
if np.any(edge_mask):
|
|
883
|
+
pixel_flat[edge_mask] = 1.0
|
|
884
|
+
pixel_flat = np.clip(pixel_flat, 0.1, 10.0).astype(np.float32)
|
|
885
|
+
|
|
886
|
+
# Illumination flat is intentionally not used/output in pixel-to-pixel-only workflow.
|
|
887
|
+
illumination_flat = None
|
|
888
|
+
|
|
889
|
+
# Final 2D correction map equals pixel flat in this workflow.
|
|
890
|
+
flat_corr_2d = np.clip(pixel_flat, 0.1, 10.0).astype(np.float32)
|
|
891
|
+
|
|
892
|
+
self.response_map = response_map
|
|
893
|
+
self.smoothed_model = smoothed_model
|
|
894
|
+
self.pixel_flat = pixel_flat
|
|
895
|
+
self.illumination_flat = illumination_flat
|
|
896
|
+
self.flat_corr_2d = flat_corr_2d
|
|
897
|
+
self.flat_sens = None
|
|
898
|
+
self.order_diagnostics = order_diag
|
|
899
|
+
logger.info("Built Step4 flat model via 1D blaze + normalized-profile reconstruction")
|
|
900
|
+
return None, blaze_profiles, cross_profiles, smoothed_model, pixel_flat, illumination_flat
|
|
901
|
+
|
|
902
|
+
# 保存二维平场模型与诊断图
|
|
903
|
+
def save_step4_diagnostics(self, output_dir: Path,
|
|
904
|
+
clean_flat: Optional[np.ndarray] = None,
|
|
905
|
+
clean_flat_corrected: Optional[np.ndarray] = None,
|
|
906
|
+
fig_format: str = 'png',
|
|
907
|
+
save_plots: bool = True):
|
|
908
|
+
"""Save step4 diagnostic artifacts: blaze/profile/inverse-map/model/pixel-flat and corrected flat."""
|
|
909
|
+
out_dir = Path(output_dir)
|
|
910
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
911
|
+
|
|
912
|
+
# fig_format and save_plots are now passed via method parameters
|
|
913
|
+
# (kept as instance defaults for backward compat with save_step4_diagnostics signature)
|
|
914
|
+
|
|
915
|
+
# ---- FITS outputs ----
|
|
916
|
+
if self.response_map is not None:
|
|
917
|
+
write_fits_image(str(out_dir / 'flat_smoothed_model.fits'), self.response_map.astype(np.float32), dtype='float32')
|
|
918
|
+
if self.pixel_flat is not None:
|
|
919
|
+
write_fits_image(str(out_dir / 'flat_pixel_2d.fits'), self.pixel_flat.astype(np.float32), dtype='float32')
|
|
920
|
+
if clean_flat_corrected is not None:
|
|
921
|
+
write_fits_image(str(out_dir / 'MasterFlat.fits'), clean_flat_corrected.astype(np.float32), dtype='float32')
|
|
922
|
+
|
|
923
|
+
# ---- 2D overview PNG plots ----
|
|
924
|
+
if save_plots:
|
|
925
|
+
if self.response_map is not None:
|
|
926
|
+
plot_2d_image_to_file(self.response_map, str(out_dir / f'flat_smoothed_model.{fig_format}'), 'Reconstructed 2D Flat Model')
|
|
927
|
+
if self.pixel_flat is not None:
|
|
928
|
+
plot_2d_image_to_file(self.pixel_flat, str(out_dir / f'flat_pixel_2d.{fig_format}'),
|
|
929
|
+
'Pixel-to-Pixel Flat', vmin=0.85, vmax=1.15)
|
|
930
|
+
if clean_flat_corrected is not None:
|
|
931
|
+
plot_2d_image_to_file(clean_flat_corrected, str(out_dir / f'MasterFlat.{fig_format}'), 'Master Flat After 2D Flat Correction')
|
|
932
|
+
|
|
933
|
+
if not self.order_diagnostics:
|
|
934
|
+
return
|
|
935
|
+
|
|
936
|
+
import matplotlib.pyplot as plt
|
|
937
|
+
from matplotlib.cm import ScalarMappable
|
|
938
|
+
from matplotlib.colors import Normalize
|
|
939
|
+
|
|
940
|
+
blaze_dir = out_dir / 'blaze_per_aperture'
|
|
941
|
+
profile_dir = out_dir / 'profile_per_aperture'
|
|
942
|
+
realspace_dir = out_dir / 'realspace_profile_per_aperture'
|
|
943
|
+
blaze_dir.mkdir(parents=True, exist_ok=True)
|
|
944
|
+
profile_dir.mkdir(parents=True, exist_ok=True)
|
|
945
|
+
realspace_dir.mkdir(parents=True, exist_ok=True)
|
|
946
|
+
|
|
947
|
+
# Collect data for combined overview plots.
|
|
948
|
+
all_order_ids = sorted(self.order_diagnostics.keys())
|
|
949
|
+
|
|
950
|
+
# ---- Per-order diagnostic loop ----
|
|
951
|
+
for aperture_id, diag in self.order_diagnostics.items():
|
|
952
|
+
raw_blaze = diag.get('raw_blaze')
|
|
953
|
+
blaze_fit = diag.get('blaze_fit')
|
|
954
|
+
blaze_method = diag.get('blaze_method', 'B-spline Fit')
|
|
955
|
+
profile_x = diag.get('profile_x')
|
|
956
|
+
profile_y = diag.get('profile_y')
|
|
957
|
+
seg_on_grid = diag.get('seg_on_grid')
|
|
958
|
+
seg_centers = diag.get('seg_centers')
|
|
959
|
+
sampled_realspace = diag.get('sampled_realspace', [])
|
|
960
|
+
n_seg = diag.get('n_profile_segments', 1)
|
|
961
|
+
n_valid_seg = diag.get('n_valid_segments', 0)
|
|
962
|
+
|
|
963
|
+
# Save numerical data
|
|
964
|
+
np.savez(
|
|
965
|
+
blaze_dir / f'aperture_{aperture_id:03d}_blaze.npz',
|
|
966
|
+
raw_blaze=raw_blaze,
|
|
967
|
+
blaze_fit=blaze_fit,
|
|
968
|
+
width_smooth=diag.get('width_smooth'),
|
|
969
|
+
)
|
|
970
|
+
np.savez(
|
|
971
|
+
profile_dir / f'aperture_{aperture_id:03d}_profile.npz',
|
|
972
|
+
y_norm=profile_x,
|
|
973
|
+
profile_mean=profile_y,
|
|
974
|
+
seg_on_grid=seg_on_grid,
|
|
975
|
+
seg_centers=seg_centers,
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
if not save_plots:
|
|
979
|
+
continue
|
|
980
|
+
|
|
981
|
+
# --- Per-order blaze plot ---
|
|
982
|
+
if raw_blaze is not None and blaze_fit is not None:
|
|
983
|
+
plt.figure(figsize=(10, 4))
|
|
984
|
+
x = np.arange(raw_blaze.size)
|
|
985
|
+
plt.plot(x, raw_blaze, color='0.6', linewidth=0.8, label='Raw Blaze 1D')
|
|
986
|
+
plt.plot(x, blaze_fit, color='tab:red', linewidth=1.4, label=blaze_method)
|
|
987
|
+
plt.xlabel('X (column)')
|
|
988
|
+
plt.ylabel('Flux')
|
|
989
|
+
plt.title(f'Aperture {aperture_id} — Blaze (segments={n_seg}, valid={n_valid_seg})')
|
|
990
|
+
plt.legend()
|
|
991
|
+
plt.grid(alpha=0.2)
|
|
992
|
+
plt.tight_layout()
|
|
993
|
+
plt.savefig(blaze_dir / f'aperture_{aperture_id:03d}_blaze.{fig_format}', dpi=150)
|
|
994
|
+
plt.close()
|
|
995
|
+
|
|
996
|
+
# --- Per-order profile plot (2D heatmap + mean) ---
|
|
997
|
+
if profile_x is not None and profile_y is not None:
|
|
998
|
+
fig = plt.figure(figsize=(10, 4.5))
|
|
999
|
+
|
|
1000
|
+
if seg_on_grid is not None and seg_centers is not None and n_seg > 1:
|
|
1001
|
+
ax1 = plt.subplot(121)
|
|
1002
|
+
# seg_on_grid is shape (n_seg, n_y). We transpose to (n_y, n_seg)
|
|
1003
|
+
img_data = seg_on_grid.T
|
|
1004
|
+
# Origin='lower' so y goes up.
|
|
1005
|
+
extent = [seg_centers[0], seg_centers[-1], profile_x[0], profile_x[-1]]
|
|
1006
|
+
im = ax1.imshow(img_data, aspect='auto', origin='lower', extent=extent, cmap='viridis')
|
|
1007
|
+
ax1.set_xlabel('Dispersion X (pixel)')
|
|
1008
|
+
ax1.set_ylabel('Spatial y_norm')
|
|
1009
|
+
ax1.set_title('Profile Variation along Dispersion')
|
|
1010
|
+
fig.colorbar(im, ax=ax1, label='Normalized Flux')
|
|
1011
|
+
|
|
1012
|
+
ax2 = plt.subplot(122)
|
|
1013
|
+
|
|
1014
|
+
# Draw segment profiles as colored lines
|
|
1015
|
+
cmap = plt.cm.plasma
|
|
1016
|
+
norm = Normalize(vmin=seg_centers[0], vmax=seg_centers[-1])
|
|
1017
|
+
for i in range(n_seg):
|
|
1018
|
+
color = cmap(norm(seg_centers[i]))
|
|
1019
|
+
ax2.plot(profile_x, seg_on_grid[i], color=color, linewidth=0.8, alpha=0.6)
|
|
1020
|
+
|
|
1021
|
+
# Add colorbar for lines
|
|
1022
|
+
sm = ScalarMappable(cmap=cmap, norm=norm)
|
|
1023
|
+
sm.set_array([])
|
|
1024
|
+
fig.colorbar(sm, ax=ax2, label='Dispersion X (pixel)')
|
|
1025
|
+
else:
|
|
1026
|
+
ax2 = plt.subplot(111)
|
|
1027
|
+
|
|
1028
|
+
ax2.plot(profile_x, profile_y, color='black', linewidth=1.8, label='Mean profile', zorder=10)
|
|
1029
|
+
ax2.set_xlabel('Spatial y_norm')
|
|
1030
|
+
ax2.set_ylabel('Normalized Flux')
|
|
1031
|
+
ax2.set_title(f'Aperture {aperture_id} — Cross-Dispersion Profile')
|
|
1032
|
+
ax2.grid(alpha=0.2)
|
|
1033
|
+
ax2.legend()
|
|
1034
|
+
|
|
1035
|
+
plt.suptitle(f'Aperture {aperture_id} — Cross-Dispersion Profile ({n_seg} segments)')
|
|
1036
|
+
plt.tight_layout()
|
|
1037
|
+
plt.savefig(profile_dir / f'aperture_{aperture_id:03d}_profile.{fig_format}', dpi=150)
|
|
1038
|
+
plt.close()
|
|
1039
|
+
|
|
1040
|
+
# --- Per-order real-space profile plot ---
|
|
1041
|
+
if sampled_realspace:
|
|
1042
|
+
fig = plt.figure(figsize=(10, 4.5))
|
|
1043
|
+
sampled_realspace.sort(key=lambda d: d['x'])
|
|
1044
|
+
x_vals = [d['x'] for d in sampled_realspace]
|
|
1045
|
+
|
|
1046
|
+
cmap = plt.cm.plasma
|
|
1047
|
+
norm = Normalize(vmin=min(x_vals), vmax=max(x_vals))
|
|
1048
|
+
|
|
1049
|
+
if len(sampled_realspace) > 1:
|
|
1050
|
+
ax1 = plt.subplot(121)
|
|
1051
|
+
xs_arr, ys_arr, fs_arr = [], [], []
|
|
1052
|
+
for prof in sampled_realspace:
|
|
1053
|
+
xs_arr.extend([prof['x']] * len(prof['y']))
|
|
1054
|
+
ys_arr.extend(prof['y'])
|
|
1055
|
+
fs_arr.extend(prof['model_flux'])
|
|
1056
|
+
|
|
1057
|
+
sc = ax1.scatter(xs_arr, ys_arr, c=fs_arr, cmap='viridis', s=5, marker='s', edgecolors='none')
|
|
1058
|
+
ax1.set_xlabel('Dispersion X (pixel)')
|
|
1059
|
+
ax1.set_ylabel('Physical Y (pixel)')
|
|
1060
|
+
ax1.set_title('Real-Space Model Heatmap')
|
|
1061
|
+
fig.colorbar(sc, ax=ax1, label='Model Flux')
|
|
1062
|
+
|
|
1063
|
+
ax2 = plt.subplot(122)
|
|
1064
|
+
else:
|
|
1065
|
+
ax2 = plt.subplot(111)
|
|
1066
|
+
|
|
1067
|
+
for prof in sampled_realspace:
|
|
1068
|
+
color = cmap(norm(prof['x']))
|
|
1069
|
+
ax2.plot(prof['y'], prof['model_flux'], color=color, linewidth=0.8, alpha=0.6)
|
|
1070
|
+
|
|
1071
|
+
if len(sampled_realspace) > 1:
|
|
1072
|
+
sm = ScalarMappable(cmap=cmap, norm=norm)
|
|
1073
|
+
sm.set_array([])
|
|
1074
|
+
fig.colorbar(sm, ax=ax2, label='Dispersion X (pixel)')
|
|
1075
|
+
|
|
1076
|
+
ax2.set_xlabel('Physical Y (pixel)')
|
|
1077
|
+
ax2.set_ylabel('Model Flux')
|
|
1078
|
+
ax2.set_title(f'Aperture {aperture_id} — Real-Space Profiles')
|
|
1079
|
+
ax2.grid(alpha=0.2)
|
|
1080
|
+
|
|
1081
|
+
plt.suptitle(f'Aperture {aperture_id} — Real-Space Profiles')
|
|
1082
|
+
plt.tight_layout()
|
|
1083
|
+
plt.savefig(realspace_dir / f'aperture_{aperture_id:03d}_realspace_profiles.{fig_format}', dpi=150)
|
|
1084
|
+
plt.close()
|
|
1085
|
+
|
|
1086
|
+
# ================================================================
|
|
1087
|
+
# Combined overview: all orders' blaze in one figure
|
|
1088
|
+
# ================================================================
|
|
1089
|
+
if save_plots and all_order_ids:
|
|
1090
|
+
n_orders = len(all_order_ids)
|
|
1091
|
+
cmap = plt.cm.turbo
|
|
1092
|
+
# Reverse: low order IDs (bottom, long wavelength) → red,
|
|
1093
|
+
# high order IDs (top, short wavelength) → blue.
|
|
1094
|
+
norm_c = Normalize(vmin=0, vmax=max(1, n_orders - 1))
|
|
1095
|
+
|
|
1096
|
+
# 按级次索引返回绘图颜色
|
|
1097
|
+
def _order_color(i):
|
|
1098
|
+
return cmap(norm_c(n_orders - 1 - i))
|
|
1099
|
+
|
|
1100
|
+
# --- All-orders blaze overview ---
|
|
1101
|
+
fig, ax = plt.subplots(figsize=(14, 6), constrained_layout=True)
|
|
1102
|
+
for i, oid in enumerate(all_order_ids):
|
|
1103
|
+
diag = self.order_diagnostics[oid]
|
|
1104
|
+
bfit = diag.get('blaze_fit')
|
|
1105
|
+
if bfit is None:
|
|
1106
|
+
continue
|
|
1107
|
+
x = np.arange(bfit.size)
|
|
1108
|
+
clr = _order_color(i)
|
|
1109
|
+
ax.plot(x, bfit, color=clr, linewidth=0.6, alpha=0.8)
|
|
1110
|
+
ax.set_xlabel('X (column)')
|
|
1111
|
+
ax.set_ylabel('Heavy-Smooth Blaze')
|
|
1112
|
+
ax.set_title(f'All Apertures Blaze Overview ({n_orders} apertures)')
|
|
1113
|
+
ax.grid(alpha=0.15)
|
|
1114
|
+
cmap_r = plt.cm.turbo_r
|
|
1115
|
+
sm = ScalarMappable(cmap=cmap_r, norm=norm_c)
|
|
1116
|
+
sm.set_array([])
|
|
1117
|
+
cbar = fig.colorbar(sm, ax=ax, shrink=0.8, pad=0.02)
|
|
1118
|
+
order_ids_arr = np.array(all_order_ids)
|
|
1119
|
+
cbar.set_ticks(np.linspace(0, n_orders - 1, min(10, n_orders)))
|
|
1120
|
+
cbar.set_ticklabels([str(order_ids_arr[int(t)]) for t in np.linspace(0, n_orders - 1, min(10, n_orders))])
|
|
1121
|
+
cbar.set_label('Aperture ID')
|
|
1122
|
+
fig.savefig(out_dir / f'all_apertures_blaze_overview.{fig_format}', dpi=150)
|
|
1123
|
+
plt.close(fig)
|
|
1124
|
+
|
|
1125
|
+
# --- All-orders profile overview ---
|
|
1126
|
+
fig, ax = plt.subplots(figsize=(10, 7))
|
|
1127
|
+
for i, oid in enumerate(all_order_ids):
|
|
1128
|
+
diag = self.order_diagnostics[oid]
|
|
1129
|
+
px = diag.get('profile_x')
|
|
1130
|
+
py = diag.get('profile_y')
|
|
1131
|
+
if px is None or py is None:
|
|
1132
|
+
continue
|
|
1133
|
+
clr = _order_color(i)
|
|
1134
|
+
ax.plot(px, py, color=clr, linewidth=0.7, alpha=0.8)
|
|
1135
|
+
ax.set_xlabel('y_norm')
|
|
1136
|
+
ax.set_ylabel('Normalized Flux')
|
|
1137
|
+
ax.set_title(f'All Apertures Cross-Dispersion Profile Overview ({n_orders} apertures)')
|
|
1138
|
+
ax.grid(alpha=0.15)
|
|
1139
|
+
sm = ScalarMappable(cmap=cmap_r, norm=norm_c)
|
|
1140
|
+
sm.set_array([])
|
|
1141
|
+
cbar = fig.colorbar(sm, ax=ax, shrink=0.7, pad=0.02)
|
|
1142
|
+
cbar.set_ticks(np.linspace(0, n_orders - 1, min(10, n_orders)))
|
|
1143
|
+
cbar.set_ticklabels([str(order_ids_arr[int(t)]) for t in np.linspace(0, n_orders - 1, min(10, n_orders))])
|
|
1144
|
+
cbar.set_label('Aperture ID')
|
|
1145
|
+
fig.tight_layout()
|
|
1146
|
+
fig.savefig(out_dir / f'all_apertures_profile_overview.{fig_format}', dpi=150)
|
|
1147
|
+
plt.close(fig)
|
|
1148
|
+
|
|
1149
|
+
logger.info(f"Step 4 diagnostics saved to {out_dir} "
|
|
1150
|
+
f"({n_orders} apertures, format={fig_format})")
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
# 构建平场模型并校正科学图像
|
|
1155
|
+
def process_flat_correction_stage(science_image: np.ndarray,
|
|
1156
|
+
flat_field: FlatField,
|
|
1157
|
+
output_dir_base: str,
|
|
1158
|
+
apertures: Optional[ApertureSet] = None,
|
|
1159
|
+
science_name: str = 'science',
|
|
1160
|
+
# Blaze / profile parameters for on-demand model build
|
|
1161
|
+
blaze_smooth_factor: float = 1.0,
|
|
1162
|
+
width_smooth_window: int = 41,
|
|
1163
|
+
profile_bin_step: float = 0.01,
|
|
1164
|
+
n_profile_segments: int = 100,
|
|
1165
|
+
profile_smooth_sigma: float = 6.0,
|
|
1166
|
+
pixel_flat_min: float = 0.5,
|
|
1167
|
+
pixel_flat_max: float = 1.5,
|
|
1168
|
+
fringe_orders: int = 20,
|
|
1169
|
+
save_plots: bool = True,
|
|
1170
|
+
fig_format: str = 'png') -> np.ndarray:
|
|
1171
|
+
"""
|
|
1172
|
+
Step 4: apply the 2D pixel-flat correction map to a science image.
|
|
1173
|
+
"""
|
|
1174
|
+
|
|
1175
|
+
processor = None
|
|
1176
|
+
if apertures is None:
|
|
1177
|
+
raise RuntimeError("Apertures are required to build the flat model, but none were provided.")
|
|
1178
|
+
logger.info(" Building 2D flat model (blaze × profile → pixel flat)...")
|
|
1179
|
+
processor = FlatCorrectionModelBuilder()
|
|
1180
|
+
processor.flat_data = flat_field.flat_data
|
|
1181
|
+
processor.flat_mask = flat_field.flat_mask
|
|
1182
|
+
|
|
1183
|
+
clean_flat = flat_field.flat_data
|
|
1184
|
+
if flat_field.scattered_light is not None:
|
|
1185
|
+
clean_flat = np.clip(
|
|
1186
|
+
flat_field.flat_data.astype(np.float32) - flat_field.scattered_light.astype(np.float32),
|
|
1187
|
+
1e-6,
|
|
1188
|
+
None,
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
flat_sens, blaze_profiles, cross_profiles, smoothed_model, pixel_flat, illum = (
|
|
1192
|
+
processor.build_order_response_map(
|
|
1193
|
+
apertures, source_image=clean_flat,
|
|
1194
|
+
blaze_smooth_factor=blaze_smooth_factor,
|
|
1195
|
+
width_smooth_window=width_smooth_window,
|
|
1196
|
+
profile_bin_step=profile_bin_step,
|
|
1197
|
+
n_profile_segments=n_profile_segments,
|
|
1198
|
+
profile_smooth_sigma=profile_smooth_sigma,
|
|
1199
|
+
pixel_flat_min=pixel_flat_min,
|
|
1200
|
+
pixel_flat_max=pixel_flat_max,
|
|
1201
|
+
fringe_orders=fringe_orders)
|
|
1202
|
+
)
|
|
1203
|
+
flat_field.flat_sens = flat_sens
|
|
1204
|
+
flat_field.blaze_profiles = blaze_profiles
|
|
1205
|
+
flat_field.cross_profiles = cross_profiles
|
|
1206
|
+
flat_field.smoothed_model = smoothed_model
|
|
1207
|
+
flat_field.pixel_flat = pixel_flat
|
|
1208
|
+
flat_field.illumination_flat = illum
|
|
1209
|
+
|
|
1210
|
+
flat_corr = flat_field.pixel_flat
|
|
1211
|
+
if flat_corr is None:
|
|
1212
|
+
flat_corr = flat_field.flat_corr_2d
|
|
1213
|
+
if flat_corr is None:
|
|
1214
|
+
raise RuntimeError("No flat correction map available. Run Step 2 first.")
|
|
1215
|
+
|
|
1216
|
+
if flat_corr.shape != science_image.shape:
|
|
1217
|
+
raise RuntimeError(
|
|
1218
|
+
f"Science image shape {science_image.shape} does not match "
|
|
1219
|
+
f"flat correction shape {flat_corr.shape}"
|
|
1220
|
+
)
|
|
1221
|
+
|
|
1222
|
+
safe_flat = flat_corr.astype(np.float32)
|
|
1223
|
+
bad = (~np.isfinite(safe_flat)) | (safe_flat <= 0.05)
|
|
1224
|
+
if np.any(bad):
|
|
1225
|
+
safe_flat = safe_flat.copy()
|
|
1226
|
+
safe_flat[bad] = 1.0
|
|
1227
|
+
|
|
1228
|
+
corrected = science_image.astype(np.float32) / safe_flat
|
|
1229
|
+
|
|
1230
|
+
out_dir = Path(output_dir_base) / 'step4_flat_corrected'
|
|
1231
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1232
|
+
|
|
1233
|
+
# When building the model, save all flat-model artefacts.
|
|
1234
|
+
# The processor object is only available during the model build step.
|
|
1235
|
+
save_flat_correction_products(flat_field, apertures, out_dir, processor=processor,
|
|
1236
|
+
fig_format=fig_format, save_plots=save_plots)
|
|
1237
|
+
|
|
1238
|
+
# The corrected master flat is already saved as part of the diagnostics.
|
|
1239
|
+
# Only save other science/calib images here to avoid redundancy.
|
|
1240
|
+
if science_name != "MasterFlat":
|
|
1241
|
+
write_fits_image(
|
|
1242
|
+
str(out_dir / f'{science_name}.fits'),
|
|
1243
|
+
corrected,
|
|
1244
|
+
dtype='float32',
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
if save_plots:
|
|
1248
|
+
plot_2d_image_to_file(
|
|
1249
|
+
corrected,
|
|
1250
|
+
str(out_dir / f'{science_name}_flat2d_corrected.{fig_format}'),
|
|
1251
|
+
f'Image After 2D Flat Correction: {science_name}',
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
logger.info(f"✓ 2D flat correction complete: {science_name}")
|
|
1255
|
+
return corrected
|