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,1587 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Order tracing stage — Step 2 of the spectral reduction pipeline.
|
|
3
|
+
|
|
4
|
+
This module combines master flat frames, detects and traces echelle or grating
|
|
5
|
+
orders, defines their boundaries, and saves the results for subsequent
|
|
6
|
+
pipeline stages. The main entry point is `process_order_tracing_stage`.
|
|
7
|
+
|
|
8
|
+
The main entry point is process_order_tracing_stage(). FlatFieldProcessor is the
|
|
9
|
+
class that wraps all processing state.
|
|
10
|
+
"""
|
|
11
|
+
# 合并平场、追踪光谱级次并确定孔径边界。
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Tuple, Dict, Optional, Any
|
|
17
|
+
from scipy.signal import find_peaks
|
|
18
|
+
from scipy.ndimage import gaussian_filter1d
|
|
19
|
+
from scipy.interpolate import UnivariateSpline
|
|
20
|
+
from numpy.polynomial import chebyshev as cheb
|
|
21
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
22
|
+
import json
|
|
23
|
+
|
|
24
|
+
from specproc.utils.fits_io import read_fits_image, write_fits_image
|
|
25
|
+
from specproc.utils.image_processing import combine_images, find_bad_pixels, estimate_background_2d
|
|
26
|
+
from specproc.core.data_structures import ApertureSet, ApertureLocation, FlatField
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# 处理平场并追踪光谱级次
|
|
32
|
+
class FlatFieldProcessor:
|
|
33
|
+
"""Handles flat field processing and order tracing."""
|
|
34
|
+
|
|
35
|
+
# 初始化对象状态
|
|
36
|
+
def __init__(self):
|
|
37
|
+
"""
|
|
38
|
+
Initialize flat field processor.
|
|
39
|
+
"""
|
|
40
|
+
self.flat_data: Optional[np.ndarray] = None
|
|
41
|
+
self.flat_mask: Optional[np.ndarray] = None
|
|
42
|
+
self.flat_norm: Optional[np.ndarray] = None
|
|
43
|
+
self.scattered_light: Optional[np.ndarray] = None
|
|
44
|
+
self.order_diagnostics = {}
|
|
45
|
+
self.base_header = None
|
|
46
|
+
|
|
47
|
+
# 合并平场帧并生成主平场
|
|
48
|
+
def combine_flat_frames(self, flat_filenames: List[str],
|
|
49
|
+
combine_method: str = 'median',
|
|
50
|
+
combine_sigma: float = 3.0,
|
|
51
|
+
mosaic_maxcount: float = 65535) -> Tuple[np.ndarray, np.ndarray]:
|
|
52
|
+
"""
|
|
53
|
+
Combine multiple flat frames to create master flat.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
flat_filenames: List of flat FITS file paths
|
|
57
|
+
combine_method: Combination method
|
|
58
|
+
combine_sigma: Sigma for clipping
|
|
59
|
+
mosaic_maxcount: Cutoff for bad pixels
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Tuple of (combined_flat, flat_mask)
|
|
63
|
+
"""
|
|
64
|
+
logger.info(f"Combining {len(flat_filenames)} flat frames...")
|
|
65
|
+
|
|
66
|
+
if not flat_filenames:
|
|
67
|
+
raise ValueError("No flat frames provided")
|
|
68
|
+
|
|
69
|
+
# Read all flat images
|
|
70
|
+
flat_images = []
|
|
71
|
+
flat_exptimes = []
|
|
72
|
+
for filename in flat_filenames:
|
|
73
|
+
try:
|
|
74
|
+
img, header = read_fits_image(filename)
|
|
75
|
+
flat_images.append(img)
|
|
76
|
+
if self.base_header is None and header is not None:
|
|
77
|
+
self.base_header = header.copy()
|
|
78
|
+
|
|
79
|
+
exptime = np.nan
|
|
80
|
+
if header is not None:
|
|
81
|
+
exptime = header.get('EXPTIME', np.nan)
|
|
82
|
+
flat_exptimes.append(float(exptime) if exptime is not None else np.nan)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
logger.warning(f"Failed to read flat frame {filename}: {e}")
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
if not flat_images:
|
|
88
|
+
raise RuntimeError("Could not read any flat frames")
|
|
89
|
+
|
|
90
|
+
# Convert to array
|
|
91
|
+
images_array = np.array(flat_images, dtype=np.float32)
|
|
92
|
+
logger.info(f"Successfully read {len(images_array)} flat frames")
|
|
93
|
+
|
|
94
|
+
# If flat exposures differ a lot, scale to a common exposure level first.
|
|
95
|
+
# This avoids biasing the combined flat toward longest-exposure frames.
|
|
96
|
+
exptime_array = np.array(flat_exptimes[:len(images_array)], dtype=np.float64)
|
|
97
|
+
if len(exptime_array) == len(images_array):
|
|
98
|
+
valid = np.isfinite(exptime_array) & (exptime_array > 0)
|
|
99
|
+
if np.all(valid) and len(exptime_array) > 1:
|
|
100
|
+
exptime_ratio = np.max(exptime_array) / np.min(exptime_array)
|
|
101
|
+
if exptime_ratio > 1.1:
|
|
102
|
+
ref_exptime = float(np.median(exptime_array))
|
|
103
|
+
images_array = images_array / exptime_array[:, None, None] * ref_exptime
|
|
104
|
+
logger.info(
|
|
105
|
+
f"Flat EXPTIME range is large (min={np.min(exptime_array):.2f}, "
|
|
106
|
+
f"max={np.max(exptime_array):.2f}); scaled to reference {ref_exptime:.2f}s"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Combine (default workflow: median + sigma clipping for cosmic/bad pixels)
|
|
110
|
+
method = combine_method
|
|
111
|
+
if method == 'median':
|
|
112
|
+
from astropy.stats import sigma_clip
|
|
113
|
+
sigma = combine_sigma
|
|
114
|
+
clipped = sigma_clip(images_array, sigma=sigma, axis=0, masked=True)
|
|
115
|
+
combined = np.ma.median(clipped, axis=0).filled(np.nan)
|
|
116
|
+
# Fallback for any fully masked pixels
|
|
117
|
+
nanpix = ~np.isfinite(combined)
|
|
118
|
+
if np.any(nanpix):
|
|
119
|
+
combined[nanpix] = np.median(images_array, axis=0)[nanpix]
|
|
120
|
+
combined = combined.astype(np.float32)
|
|
121
|
+
else:
|
|
122
|
+
combined, _ = combine_images(images_array, method=method)
|
|
123
|
+
|
|
124
|
+
# Detect bad pixels
|
|
125
|
+
bad_mask = combined > mosaic_maxcount
|
|
126
|
+
|
|
127
|
+
self.flat_data = combined
|
|
128
|
+
self.flat_mask = bad_mask.astype(np.int16)
|
|
129
|
+
|
|
130
|
+
logger.info(f"Master flat created: shape {combined.shape}, "
|
|
131
|
+
f"saturated pixels: {np.sum(bad_mask)}")
|
|
132
|
+
|
|
133
|
+
return combined, self.flat_mask
|
|
134
|
+
|
|
135
|
+
# 检测并追踪平场中的级次
|
|
136
|
+
def detect_orders(self, snr_threshold: float = 5.0,
|
|
137
|
+
min_trace_coverage: float = 0.2,
|
|
138
|
+
trace_degree: int = 4,
|
|
139
|
+
output_dir_base: str = '') -> ApertureSet:
|
|
140
|
+
"""
|
|
141
|
+
Detect grating orders in flat field with simple tracing.
|
|
142
|
+
|
|
143
|
+
For grating spectrographs, orders run horizontally (left to right) across
|
|
144
|
+
the detector. The algorithm collapses the image along the dispersion direction
|
|
145
|
+
(X) to find initial order centroids along Y, then traces each order across
|
|
146
|
+
the image using polynomial fitting.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
snr_threshold: Detection threshold (multiples of sigma above baseline)
|
|
150
|
+
gap_fill_snr: SNR threshold for gap fill.
|
|
151
|
+
min_trace_coverage: Minimum fraction of detector width a traced order must cover.
|
|
152
|
+
trace_degree: Polynomial degree for fitting the trace.
|
|
153
|
+
output_dir_base: Output directory for diagnostics.
|
|
154
|
+
Returns:
|
|
155
|
+
ApertureSet with detected orders
|
|
156
|
+
"""
|
|
157
|
+
if self.flat_data is None:
|
|
158
|
+
raise RuntimeError("No flat data")
|
|
159
|
+
|
|
160
|
+
logger.info("Detecting grating orders (simple tracing)...")
|
|
161
|
+
|
|
162
|
+
tracer = GratingOrderTracer(
|
|
163
|
+
data=self.flat_data,
|
|
164
|
+
mask=self.flat_mask,
|
|
165
|
+
snr_threshold=snr_threshold,
|
|
166
|
+
min_trace_coverage=min_trace_coverage,
|
|
167
|
+
trace_degree=trace_degree,
|
|
168
|
+
)
|
|
169
|
+
apertures = tracer.trace_orders()
|
|
170
|
+
self.order_diagnostics = tracer.get_diagnostics()
|
|
171
|
+
|
|
172
|
+
return apertures
|
|
173
|
+
|
|
174
|
+
# 保存主平场与诊断图
|
|
175
|
+
def save_flat_field(self, output_path: str,
|
|
176
|
+
save_plots: bool = True,
|
|
177
|
+
fig_format: str = 'png'):
|
|
178
|
+
"""Save flat field data to FITS file.
|
|
179
|
+
|
|
180
|
+
MasterFlat.fits contains only the combined flat image (PRIMARY) and
|
|
181
|
+
the bad-pixel MASK extension so its size is comparable to a single
|
|
182
|
+
input flat frame.
|
|
183
|
+
"""
|
|
184
|
+
if self.flat_data is None:
|
|
185
|
+
raise RuntimeError("No flat data to save")
|
|
186
|
+
|
|
187
|
+
output_path = Path(output_path)
|
|
188
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
|
|
190
|
+
from astropy.io import fits
|
|
191
|
+
|
|
192
|
+
# ---- MasterFlat.fits: combined image + mask only ----
|
|
193
|
+
if self.base_header is not None:
|
|
194
|
+
if isinstance(self.base_header, fits.Header):
|
|
195
|
+
header = self.base_header.copy()
|
|
196
|
+
else:
|
|
197
|
+
header = fits.Header()
|
|
198
|
+
for k, v in self.base_header.items():
|
|
199
|
+
try:
|
|
200
|
+
header[k] = v
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
else:
|
|
204
|
+
header = fits.Header()
|
|
205
|
+
header['EXTNAME'] = 'MASTER FLAT'
|
|
206
|
+
primary_hdu = fits.PrimaryHDU(data=self.flat_data.astype(np.float32), header=header)
|
|
207
|
+
hdul = fits.HDUList([
|
|
208
|
+
primary_hdu,
|
|
209
|
+
fits.ImageHDU(data=self.flat_mask, name='MASK'),
|
|
210
|
+
])
|
|
211
|
+
hdul.writeto(str(output_path), overwrite=True)
|
|
212
|
+
logger.info(f"Saved master flat to {output_path}")
|
|
213
|
+
|
|
214
|
+
# Save diagnostic plots if enabled
|
|
215
|
+
if save_plots:
|
|
216
|
+
from specproc.plotting.spectra_plotter import plot_2d_image_to_file
|
|
217
|
+
out_dir = Path(output_path).parent
|
|
218
|
+
plot_2d_image_to_file(self.flat_data, str(out_dir / f'MasterFlat.{fig_format}'), "Master Flat Field")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# 检测并追踪光栅级次
|
|
222
|
+
class GratingOrderTracer:
|
|
223
|
+
"""
|
|
224
|
+
Detects and traces orders for grating spectrographs.
|
|
225
|
+
"""
|
|
226
|
+
# 初始化对象状态
|
|
227
|
+
def __init__(self, data: np.ndarray, mask: Optional[np.ndarray] = None, **kwargs):
|
|
228
|
+
self.data = np.asarray(data, dtype=np.float64)
|
|
229
|
+
self.mask = mask if mask is not None else np.zeros_like(data, dtype=bool)
|
|
230
|
+
self.h, self.w = self.data.shape
|
|
231
|
+
self.params = {
|
|
232
|
+
'snr_threshold': 5.0,
|
|
233
|
+
'min_trace_coverage': 0.20,
|
|
234
|
+
'trace_degree': 4,
|
|
235
|
+
}
|
|
236
|
+
self.params.update(kwargs)
|
|
237
|
+
self.diagnostics = {}
|
|
238
|
+
|
|
239
|
+
# 提取级次种子剖面并估算间距
|
|
240
|
+
def _get_seed_profile(self) -> Tuple[np.ndarray, dict]:
|
|
241
|
+
"""Extracts and analyzes a 1D cross-section to find order seeds."""
|
|
242
|
+
x_center = self.w // 2
|
|
243
|
+
half_band = max(5, self.w // 20)
|
|
244
|
+
x1, x2 = max(0, x_center - half_band), min(self.w, x_center + half_band + 1)
|
|
245
|
+
profile = np.median(self.data[:, x1:x2], axis=1)
|
|
246
|
+
profile = gaussian_filter1d(profile, sigma=1.5)
|
|
247
|
+
|
|
248
|
+
# Estimate dynamic order separation
|
|
249
|
+
rough_peaks, _ = find_peaks(gaussian_filter1d(profile, 2.0), distance=5, prominence=np.percentile(profile, 99) * 0.02)
|
|
250
|
+
if len(rough_peaks) >= 5:
|
|
251
|
+
diffs = np.diff(rough_peaks)
|
|
252
|
+
y_mids = 0.5 * (rough_peaks[:-1] + rough_peaks[1:])
|
|
253
|
+
med_diff = float(np.median(diffs))
|
|
254
|
+
# Filter out anomalously large diffs (e.g. missing orders) before fitting
|
|
255
|
+
valid = (diffs > 0.5 * med_diff) & (diffs < 1.5 * med_diff)
|
|
256
|
+
if np.sum(valid) >= 3:
|
|
257
|
+
deg = min(2, np.sum(valid) - 1)
|
|
258
|
+
coefs = np.polyfit(y_mids[valid], diffs[valid], deg)
|
|
259
|
+
med_sep = med_diff
|
|
260
|
+
local_sep_func = lambda y: float(np.clip(np.polyval(coefs, y), med_sep * 0.4, med_sep * 2.5))
|
|
261
|
+
else:
|
|
262
|
+
med_sep = med_diff
|
|
263
|
+
local_sep_func = lambda y: float(med_sep)
|
|
264
|
+
else:
|
|
265
|
+
med_sep = 30.0
|
|
266
|
+
local_sep_func = lambda y: float(med_sep)
|
|
267
|
+
|
|
268
|
+
# Background estimation
|
|
269
|
+
from scipy.ndimage import minimum_filter1d
|
|
270
|
+
bg_size = int(2.5 * med_sep)
|
|
271
|
+
envelope = gaussian_filter1d(minimum_filter1d(profile, size=bg_size), sigma=bg_size / 4.0)
|
|
272
|
+
seed_pure = profile - envelope
|
|
273
|
+
|
|
274
|
+
# Noise estimation
|
|
275
|
+
valid_prof = seed_pure[np.isfinite(seed_pure)]
|
|
276
|
+
s_med = np.median(np.sort(valid_prof)[:len(valid_prof)//4])
|
|
277
|
+
s_sig = np.median(np.abs(np.diff(valid_prof))) / 0.9539
|
|
278
|
+
noise, baseline = max(s_sig, 0.1), s_med
|
|
279
|
+
|
|
280
|
+
diags = {'profile': profile, 'envelope': envelope, 'noise': noise, 'baseline': baseline,
|
|
281
|
+
'local_sep_func': local_sep_func, 'med_sep': med_sep, 'x1': x1, 'x2': x2}
|
|
282
|
+
return seed_pure, diags
|
|
283
|
+
|
|
284
|
+
# 检测峰值并补入缺失种子
|
|
285
|
+
def _find_seeds(self, seed_profile: np.ndarray, diags: dict) -> np.ndarray:
|
|
286
|
+
"""Finds initial peak locations (seeds) in the 1D profile."""
|
|
287
|
+
det_threshold = diags['baseline'] + self.params['snr_threshold'] * diags['noise']
|
|
288
|
+
prominence = max(0.5 * diags['noise'], 0.1 * self.params['snr_threshold'] * diags['noise'])
|
|
289
|
+
|
|
290
|
+
# Initial peak finding with a small distance to not miss close orders
|
|
291
|
+
peaks, props = find_peaks(seed_profile, height=det_threshold, distance=3, prominence=prominence)
|
|
292
|
+
|
|
293
|
+
if len(peaks) < 5:
|
|
294
|
+
logger.info(f"Found {len(peaks)} seed peaks (SNR > {self.params['snr_threshold']:.1f})")
|
|
295
|
+
return np.asarray(sorted(peaks), dtype=int)
|
|
296
|
+
|
|
297
|
+
# --- Gap detection and seed insertion ---
|
|
298
|
+
sorted_peaks = np.asarray(sorted(peaks), dtype=int)
|
|
299
|
+
diffs = np.diff(sorted_peaks)
|
|
300
|
+
local_sep_func = diags.get('local_sep_func')
|
|
301
|
+
if not local_sep_func:
|
|
302
|
+
med_sep = diags.get('med_sep', np.median(diffs) if len(diffs) > 0 else 30.0)
|
|
303
|
+
local_sep_func = lambda y: float(med_sep)
|
|
304
|
+
|
|
305
|
+
new_seeds = list(sorted_peaks)
|
|
306
|
+
for i in range(len(diffs)):
|
|
307
|
+
gap = diffs[i]
|
|
308
|
+
mid_y = (sorted_peaks[i] + sorted_peaks[i+1]) / 2.0
|
|
309
|
+
expected_sep = local_sep_func(mid_y)
|
|
310
|
+
gap_thresh = expected_sep * 1.5 # A gap is > 1.5x the local expected separation
|
|
311
|
+
|
|
312
|
+
if gap > gap_thresh:
|
|
313
|
+
# Found a gap, try to insert seeds
|
|
314
|
+
n_missing = int(round(gap / expected_sep)) - 1
|
|
315
|
+
if n_missing > 0 and n_missing <= 5: # Limit insertions
|
|
316
|
+
logger.info(f"Gap detected between {sorted_peaks[i]} and {sorted_peaks[i+1]} (gap={gap:.1f}, expected_sep={expected_sep:.1f}). Trying to insert {n_missing} seed(s).")
|
|
317
|
+
for j in range(1, n_missing + 1):
|
|
318
|
+
# Predict seed position
|
|
319
|
+
predicted_y = int(round(sorted_peaks[i] + j * gap / (n_missing + 1)))
|
|
320
|
+
|
|
321
|
+
# Search for a peak near the predicted position, even if it's below the main SNR threshold
|
|
322
|
+
search_win = max(2, int(expected_sep * 0.2))
|
|
323
|
+
y1 = max(0, predicted_y - search_win)
|
|
324
|
+
y2 = min(len(seed_profile), predicted_y + search_win + 1)
|
|
325
|
+
|
|
326
|
+
if y2 > y1:
|
|
327
|
+
local_profile = seed_profile[y1:y2]
|
|
328
|
+
# Use a lower threshold for gap filling
|
|
329
|
+
gap_fill_thresh = diags['baseline'] + 1.0 * diags['noise'] # Lower SNR for gaps
|
|
330
|
+
local_peaks, _ = find_peaks(local_profile, height=gap_fill_thresh, distance=3)
|
|
331
|
+
if len(local_peaks) > 0:
|
|
332
|
+
best_local_peak_idx = np.argmax(local_profile[local_peaks])
|
|
333
|
+
inserted_y = y1 + local_peaks[best_local_peak_idx]
|
|
334
|
+
new_seeds.append(inserted_y)
|
|
335
|
+
logger.info(f" -> Inserted seed at y={inserted_y} (predicted at {predicted_y})")
|
|
336
|
+
|
|
337
|
+
final_seeds = np.asarray(sorted(list(set(new_seeds))), dtype=int)
|
|
338
|
+
logger.info(f"Found {len(peaks)} initial peaks, {len(final_seeds)} seeds after gap filling.")
|
|
339
|
+
return final_seeds
|
|
340
|
+
|
|
341
|
+
# 从种子沿列追踪单个级次
|
|
342
|
+
def _trace_one_order(self, seed_y: float, diags: dict) -> Optional[Tuple[np.ndarray, np.ndarray]]:
|
|
343
|
+
"""Traces a single order starting from a seed."""
|
|
344
|
+
xs, ys = [], []
|
|
345
|
+
|
|
346
|
+
# 在指定列精修级次峰位
|
|
347
|
+
def refine_peak(x, y_guess, search_half):
|
|
348
|
+
y0 = int(round(y_guess))
|
|
349
|
+
y1, y2 = max(0, y0 - search_half), min(self.h, y0 + search_half + 1)
|
|
350
|
+
if y2 - y1 < 3: return None
|
|
351
|
+
col = self.data[y1:y2, x]
|
|
352
|
+
if not np.any(np.isfinite(col)): return None
|
|
353
|
+
|
|
354
|
+
loc = int(np.nanargmax(col))
|
|
355
|
+
|
|
356
|
+
# A true peak must be a local maximum, not just the edge of the window
|
|
357
|
+
if loc == 0 or loc == len(col) - 1:
|
|
358
|
+
return None, 0.0
|
|
359
|
+
|
|
360
|
+
bg_local = np.nanmin(col)
|
|
361
|
+
flux_bg_sub = col[loc] - bg_local
|
|
362
|
+
# To prevent losing the track over absorption lines, we lower the hard cutoff
|
|
363
|
+
# to 3.0 sigma. If it's between 3.0 and snr_threshold, we will use it for
|
|
364
|
+
# steering but NOT for final trace fitting.
|
|
365
|
+
if flux_bg_sub < 3.0 * diags['noise']:
|
|
366
|
+
return None, 0.0
|
|
367
|
+
|
|
368
|
+
# Sub-pixel centroiding
|
|
369
|
+
hw = 2
|
|
370
|
+
c1, c2 = max(0, loc - hw), min(len(col), loc + hw + 1)
|
|
371
|
+
sub_col = col[c1:c2]
|
|
372
|
+
valid = np.isfinite(sub_col)
|
|
373
|
+
if np.sum(valid) >= 3:
|
|
374
|
+
yy = np.arange(c1, c2)[valid]
|
|
375
|
+
bg = np.min(sub_col[valid])
|
|
376
|
+
weights = np.maximum(0, sub_col[valid] - bg)
|
|
377
|
+
sum_w = np.sum(weights)
|
|
378
|
+
if sum_w > 0:
|
|
379
|
+
peak_y_subpix = float(y1 + np.sum(yy * weights) / sum_w)
|
|
380
|
+
peak_snr = flux_bg_sub / diags['noise']
|
|
381
|
+
return peak_y_subpix, peak_snr
|
|
382
|
+
peak_snr = flux_bg_sub / diags['noise']
|
|
383
|
+
return float(y1 + loc), peak_snr # Fallback to integer pixel if sub-pixel fails
|
|
384
|
+
|
|
385
|
+
# Start from the center, calculate dynamic search window
|
|
386
|
+
initial_sep = diags['local_sep_func'](seed_y)
|
|
387
|
+
initial_search_half = max(3, int(initial_sep * 0.25))
|
|
388
|
+
|
|
389
|
+
res = refine_peak(self.w // 2, seed_y, initial_search_half)
|
|
390
|
+
if res[0] is None: return None
|
|
391
|
+
start_y, start_snr = res
|
|
392
|
+
|
|
393
|
+
xs.append(float(self.w // 2))
|
|
394
|
+
ys.append(start_y)
|
|
395
|
+
|
|
396
|
+
# Trace right and left
|
|
397
|
+
for direction in [1, -1]:
|
|
398
|
+
hist_x = [float(self.w // 2)]
|
|
399
|
+
hist_y = [start_y]
|
|
400
|
+
miss_count = 0 # Counter for consecutive columns with signal below threshold or no peak
|
|
401
|
+
|
|
402
|
+
x_range = range(self.w // 2 + direction, self.w if direction == 1 else -1, direction)
|
|
403
|
+
for x in x_range:
|
|
404
|
+
# Extrapolate y_guess using a polynomial fit to the recent trace history
|
|
405
|
+
if len(hist_x) >= 15:
|
|
406
|
+
# Use quadratic fit for better curve handling
|
|
407
|
+
n_pts, deg = 15, 2
|
|
408
|
+
elif len(hist_x) >= 5:
|
|
409
|
+
# Use linear fit for stability
|
|
410
|
+
n_pts, deg = 5, 1
|
|
411
|
+
else:
|
|
412
|
+
n_pts, deg = 0, 0
|
|
413
|
+
|
|
414
|
+
if n_pts > 0:
|
|
415
|
+
fit_x = np.array(hist_x[-n_pts:])
|
|
416
|
+
fit_y = np.array(hist_y[-n_pts:])
|
|
417
|
+
# Shift x to be around 0 for better numerical stability
|
|
418
|
+
x0 = fit_x[-1]
|
|
419
|
+
try:
|
|
420
|
+
coeffs = np.polyfit(fit_x - x0, fit_y, deg)
|
|
421
|
+
y_guess = np.polyval(coeffs, x - x0)
|
|
422
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
423
|
+
y_guess = hist_y[-1] # Fallback
|
|
424
|
+
elif len(hist_x) >= 2:
|
|
425
|
+
slope = (hist_y[-1] - hist_y[-2]) / (hist_x[-1] - hist_x[-2])
|
|
426
|
+
y_guess = hist_y[-1] + slope * (x - hist_x[-1])
|
|
427
|
+
else:
|
|
428
|
+
y_guess = hist_y[-1]
|
|
429
|
+
|
|
430
|
+
# Dynamically adjust search window based on the smooth separation curve
|
|
431
|
+
local_sep = diags['local_sep_func'](y_guess)
|
|
432
|
+
search_half = max(2, int(local_sep * 0.25))
|
|
433
|
+
|
|
434
|
+
res = refine_peak(x, y_guess, search_half)
|
|
435
|
+
if res[0] is not None and abs(res[0] - y_guess) <= search_half:
|
|
436
|
+
y_new, peak_snr = res
|
|
437
|
+
# Update steering history even if SNR is low (e.g. inside absorption line)
|
|
438
|
+
# so we don't drift off the track.
|
|
439
|
+
hist_x.append(float(x))
|
|
440
|
+
hist_y.append(y_new)
|
|
441
|
+
|
|
442
|
+
if peak_snr >= self.params['snr_threshold']:
|
|
443
|
+
# Strong peak. Save to final trace points.
|
|
444
|
+
xs.append(float(x))
|
|
445
|
+
ys.append(y_new)
|
|
446
|
+
miss_count = 0
|
|
447
|
+
else:
|
|
448
|
+
# Weak peak (absorption line). Steer, but don't save to final trace.
|
|
449
|
+
miss_count += 1
|
|
450
|
+
else:
|
|
451
|
+
# Completely lost the peak (e.g., edge of detector)
|
|
452
|
+
miss_count += 1
|
|
453
|
+
|
|
454
|
+
# Allow a coasting distance of 50 pixels to jump over wide absorption lines.
|
|
455
|
+
max_miss = 50
|
|
456
|
+
if miss_count > max_miss:
|
|
457
|
+
break
|
|
458
|
+
|
|
459
|
+
if len(xs) < self.params['min_trace_coverage'] * self.w:
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
xs, ys = np.array(xs), np.array(ys)
|
|
463
|
+
order = np.argsort(xs)
|
|
464
|
+
return xs[order], ys[order]
|
|
465
|
+
|
|
466
|
+
# 检测并拟合全部级次轨迹
|
|
467
|
+
def trace_orders(self) -> ApertureSet:
|
|
468
|
+
"""Main method to perform order tracing."""
|
|
469
|
+
seed_profile, diags = self._get_seed_profile()
|
|
470
|
+
self.diagnostics.update(diags)
|
|
471
|
+
self.diagnostics['seed_profile'] = seed_profile
|
|
472
|
+
seeds = self._find_seeds(seed_profile, diags)
|
|
473
|
+
self.diagnostics['seeds'] = seeds
|
|
474
|
+
|
|
475
|
+
aperture_set = ApertureSet()
|
|
476
|
+
order_id_counter = 1
|
|
477
|
+
for seed in seeds:
|
|
478
|
+
trace_result = self._trace_one_order(float(seed), diags)
|
|
479
|
+
if trace_result is None:
|
|
480
|
+
continue
|
|
481
|
+
|
|
482
|
+
x_pts, y_pts = trace_result
|
|
483
|
+
if len(x_pts) < 10: continue
|
|
484
|
+
|
|
485
|
+
# Fit with Chebyshev polynomial
|
|
486
|
+
deg = min(self.params['trace_degree'], len(x_pts) - 2)
|
|
487
|
+
if deg < 1: continue
|
|
488
|
+
domain = (0.0, float(self.w - 1))
|
|
489
|
+
x_mapped = 2.0 * (x_pts - domain[0]) / (domain[1] - domain[0]) - 1.0
|
|
490
|
+
center_coef = cheb.chebfit(x_mapped, y_pts, deg)
|
|
491
|
+
|
|
492
|
+
# Estimate width
|
|
493
|
+
local_sep = diags['local_sep_func'](float(seed))
|
|
494
|
+
width = 0.4 * local_sep # Simplified width estimation
|
|
495
|
+
|
|
496
|
+
ap = ApertureLocation(
|
|
497
|
+
aperture=order_id_counter, order=order_id_counter,
|
|
498
|
+
center_coef=center_coef, width=width,
|
|
499
|
+
is_chebyshev=True, domain=domain
|
|
500
|
+
)
|
|
501
|
+
aperture_set.add_aperture(ap)
|
|
502
|
+
order_id_counter += 1
|
|
503
|
+
|
|
504
|
+
logger.info(f"Successfully traced {aperture_set.norders} orders.")
|
|
505
|
+
return aperture_set
|
|
506
|
+
|
|
507
|
+
# 返回级次追踪诊断数据
|
|
508
|
+
def get_diagnostics(self) -> dict:
|
|
509
|
+
return self.diagnostics
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# 根据级次间隙分配物理序号
|
|
513
|
+
def assign_order_indices(apertures: ApertureSet, gap_fill_factor: float = 1.35) -> dict:
|
|
514
|
+
"""
|
|
515
|
+
Assign integer order indices *m* to detected apertures, detecting gaps.
|
|
516
|
+
|
|
517
|
+
Walks ordered apertures from bottom (small Y) to top (large Y) and increments *m*
|
|
518
|
+
by 1 for each normal step. When the gap between two consecutive centres
|
|
519
|
+
exceeds *gap_fill_factor × local_expected_sep*, it skips the appropriate number
|
|
520
|
+
of *m* values, reserving slots for missing orders.
|
|
521
|
+
|
|
522
|
+
The expected separation is modelled as a smooth polynomial of sequential
|
|
523
|
+
index to adapt to monotonically changing spacing (e.g., in echelle spectra).
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
apertures: The set of detected apertures.
|
|
527
|
+
gap_fill_factor: Factor to identify a gap. A separation > factor * expected
|
|
528
|
+
is considered a gap.
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
A dictionary mapping original aperture_id to the new gap-aware index `m`.
|
|
532
|
+
"""
|
|
533
|
+
order_ids = apertures.get_orders()
|
|
534
|
+
if len(order_ids) < 2:
|
|
535
|
+
return {oid: i for i, oid in enumerate(order_ids)}
|
|
536
|
+
|
|
537
|
+
x_ref = apertures.get_aperture(order_ids[0]).domain[1] / 2.0
|
|
538
|
+
centres = {oid: float(apertures.get_aperture(oid).get_position(x_ref)) for oid in order_ids}
|
|
539
|
+
|
|
540
|
+
sorted_ids = sorted(order_ids, key=lambda oid: centres[oid])
|
|
541
|
+
diffs = np.array([centres[sorted_ids[i + 1]] - centres[sorted_ids[i]] for i in range(len(sorted_ids) - 1)])
|
|
542
|
+
med_sep = float(np.median(diffs)) if len(diffs) > 0 else 30.0
|
|
543
|
+
|
|
544
|
+
# Build a smooth, robust model for the expected local separation.
|
|
545
|
+
n_diffs = len(diffs)
|
|
546
|
+
if n_diffs >= 6:
|
|
547
|
+
idx_arr = np.arange(n_diffs, dtype=float)
|
|
548
|
+
fit_deg = min(3, n_diffs - 2)
|
|
549
|
+
good = np.ones(n_diffs, dtype=bool)
|
|
550
|
+
|
|
551
|
+
# Iteratively clip outliers to get a robust fit for separation trend
|
|
552
|
+
for _ in range(4):
|
|
553
|
+
if np.sum(good) < fit_deg + 2:
|
|
554
|
+
break
|
|
555
|
+
coef_tmp = np.polyfit(idx_arr[good], diffs[good], fit_deg)
|
|
556
|
+
model_tmp = np.polyval(coef_tmp, idx_arr)
|
|
557
|
+
resid = diffs - model_tmp
|
|
558
|
+
std_r = np.std(resid[good])
|
|
559
|
+
if std_r < 1e-6:
|
|
560
|
+
break
|
|
561
|
+
new_good = np.abs(resid) < 2.5 * std_r
|
|
562
|
+
if np.array_equal(new_good, good):
|
|
563
|
+
break
|
|
564
|
+
good = new_good
|
|
565
|
+
|
|
566
|
+
sep_coef = np.polyfit(idx_arr[good], diffs[good], fit_deg)
|
|
567
|
+
expected_seps = np.polyval(sep_coef, idx_arr)
|
|
568
|
+
inlier_min = np.min(diffs[good]) if np.any(good) else med_sep * 0.5
|
|
569
|
+
inlier_max = np.max(diffs[good]) if np.any(good) else med_sep * 2.0
|
|
570
|
+
expected_seps = np.clip(expected_seps, inlier_min * 0.5, inlier_max * 2.0)
|
|
571
|
+
else:
|
|
572
|
+
expected_seps = np.full(n_diffs, med_sep)
|
|
573
|
+
|
|
574
|
+
mapping = {}
|
|
575
|
+
m = 0
|
|
576
|
+
mapping[sorted_ids[0]] = m
|
|
577
|
+
for i in range(len(diffs)):
|
|
578
|
+
gap = diffs[i]
|
|
579
|
+
local_sep = float(expected_seps[i])
|
|
580
|
+
ratio = gap / max(local_sep, 1.0)
|
|
581
|
+
n_skip = int(round(ratio)) if ratio >= gap_fill_factor else 1
|
|
582
|
+
m += n_skip
|
|
583
|
+
mapping[sorted_ids[i + 1]] = m
|
|
584
|
+
|
|
585
|
+
logger.info(f"Order index assignment: {len(mapping)} apertures, m_max={m}, "
|
|
586
|
+
f"med_sep={med_sep:.1f}px, gap_factor={gap_fill_factor:.2f}")
|
|
587
|
+
return mapping
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# 按缺失级次重编号孔径
|
|
591
|
+
def renumber_apertures_with_gaps(apertures: ApertureSet, gap_fill_factor: float = 1.35) -> ApertureSet:
|
|
592
|
+
"""
|
|
593
|
+
Renumber aperture IDs to reflect physical order indices, including gaps.
|
|
594
|
+
|
|
595
|
+
After initial detection, apertures might have sequential IDs (1, 2, 3, ...).
|
|
596
|
+
This function re-assigns IDs based on the gap-aware index `m` from
|
|
597
|
+
`assign_order_indices`, so that missing orders create gaps in the IDs
|
|
598
|
+
(e.g., 1, 2, 4, 5 if order 3 is missing). The new ID is `m + 1`.
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
apertures: The original ApertureSet.
|
|
602
|
+
gap_fill_factor: Factor for gap detection, passed to `assign_order_indices`.
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
A new ApertureSet with renumbered apertures.
|
|
606
|
+
"""
|
|
607
|
+
if apertures.norders == 0:
|
|
608
|
+
return apertures
|
|
609
|
+
|
|
610
|
+
order_map = assign_order_indices(apertures, gap_fill_factor)
|
|
611
|
+
new_set = ApertureSet()
|
|
612
|
+
for old_id, m in order_map.items():
|
|
613
|
+
ap = apertures.get_aperture(old_id)
|
|
614
|
+
new_id = m + 1
|
|
615
|
+
# Create a new ApertureLocation with the updated ID
|
|
616
|
+
new_ap = ApertureLocation(
|
|
617
|
+
aperture=new_id,
|
|
618
|
+
order=new_id,
|
|
619
|
+
center_coef=ap.center_coef.copy() if ap.center_coef is not None else None,
|
|
620
|
+
lower_coef=ap.lower_coef.copy() if ap.lower_coef is not None else None,
|
|
621
|
+
upper_coef=ap.upper_coef.copy() if ap.upper_coef is not None else None,
|
|
622
|
+
width=ap.width,
|
|
623
|
+
is_chebyshev=ap.is_chebyshev,
|
|
624
|
+
domain=ap.domain,
|
|
625
|
+
is_interpolated=ap.is_interpolated,
|
|
626
|
+
)
|
|
627
|
+
# Preserve custom attributes if they exist
|
|
628
|
+
if getattr(ap, 'center_arr', None) is not None:
|
|
629
|
+
new_ap.center_arr = ap.center_arr.copy()
|
|
630
|
+
if getattr(ap, 'w_up_cheb_coef', None) is not None:
|
|
631
|
+
new_ap.w_up_cheb_coef = ap.w_up_cheb_coef.copy()
|
|
632
|
+
if getattr(ap, 'w_low_cheb_coef', None) is not None:
|
|
633
|
+
new_ap.w_low_cheb_coef = ap.w_low_cheb_coef.copy()
|
|
634
|
+
|
|
635
|
+
new_set.add_aperture(new_ap)
|
|
636
|
+
|
|
637
|
+
if set(apertures.get_orders()) != set(new_set.get_orders()):
|
|
638
|
+
logger.info(f"Renumbered apertures with gap-aware IDs: {apertures.norders} -> {new_set.norders} orders.")
|
|
639
|
+
return new_set
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# 根据平场峰值精修预测轨迹
|
|
643
|
+
def _refine_predicted_trace(
|
|
644
|
+
flat_data: np.ndarray,
|
|
645
|
+
pred_center: np.ndarray,
|
|
646
|
+
local_sep: float,
|
|
647
|
+
trace_degree: int = 3,
|
|
648
|
+
step: int = 20
|
|
649
|
+
) -> Optional[np.ndarray]:
|
|
650
|
+
"""
|
|
651
|
+
Refine a predicted trace by finding peaks in the actual flat image.
|
|
652
|
+
|
|
653
|
+
Args:
|
|
654
|
+
flat_data: The 2D flat field image.
|
|
655
|
+
pred_center: The predicted center trace array.
|
|
656
|
+
local_sep: The local order separation in pixels.
|
|
657
|
+
trace_degree: The polynomial degree for fitting the refined trace.
|
|
658
|
+
step: The column step for sampling peaks.
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
A refined center trace array, or None if refinement fails.
|
|
662
|
+
"""
|
|
663
|
+
h, w = flat_data.shape
|
|
664
|
+
search_half = max(6, int(0.4 * local_sep))
|
|
665
|
+
xs, ys = [], []
|
|
666
|
+
|
|
667
|
+
for x in range(0, w, step):
|
|
668
|
+
y_guess = pred_center[x]
|
|
669
|
+
y0 = int(round(y_guess))
|
|
670
|
+
y1 = max(0, y0 - search_half)
|
|
671
|
+
y2 = min(h, y0 + search_half + 1)
|
|
672
|
+
if y2 - y1 < 5:
|
|
673
|
+
continue
|
|
674
|
+
|
|
675
|
+
col = flat_data[y1:y2, x].astype(float)
|
|
676
|
+
if not np.any(np.isfinite(col)):
|
|
677
|
+
continue
|
|
678
|
+
|
|
679
|
+
loc = int(np.nanargmax(col))
|
|
680
|
+
peak_val = col[loc]
|
|
681
|
+
if not np.isfinite(peak_val) or peak_val <= 0:
|
|
682
|
+
continue
|
|
683
|
+
|
|
684
|
+
# Centroid refinement
|
|
685
|
+
hw = 3
|
|
686
|
+
g1, g2 = max(0, loc - hw), min(col.size, loc + hw + 1)
|
|
687
|
+
sub = col[g1:g2]
|
|
688
|
+
valid_sub = np.isfinite(sub)
|
|
689
|
+
if np.sum(valid_sub) < 3:
|
|
690
|
+
refined_y = float(y1 + loc)
|
|
691
|
+
else:
|
|
692
|
+
yy = np.arange(g1, g2)[valid_sub]
|
|
693
|
+
weights = sub[valid_sub]
|
|
694
|
+
bg = np.min(weights)
|
|
695
|
+
weights = np.maximum(0, weights - bg)
|
|
696
|
+
sum_w = np.sum(weights)
|
|
697
|
+
if sum_w > 0:
|
|
698
|
+
refined_y = float(y1 + np.sum(yy * weights) / sum_w)
|
|
699
|
+
else:
|
|
700
|
+
refined_y = float(y1 + loc)
|
|
701
|
+
|
|
702
|
+
if abs(refined_y - y_guess) <= search_half:
|
|
703
|
+
xs.append(float(x))
|
|
704
|
+
ys.append(refined_y)
|
|
705
|
+
|
|
706
|
+
if len(xs) < max(5, int(0.15 * (w / step))):
|
|
707
|
+
return None
|
|
708
|
+
|
|
709
|
+
xs_arr, ys_arr = np.asarray(xs), np.asarray(ys)
|
|
710
|
+
deg = min(trace_degree, max(1, len(xs_arr) - 2))
|
|
711
|
+
|
|
712
|
+
# Robust polynomial fit with sigma-clipping
|
|
713
|
+
for _ in range(3):
|
|
714
|
+
if len(xs_arr) < deg + 2:
|
|
715
|
+
return None
|
|
716
|
+
coef = np.polyfit(xs_arr, ys_arr, deg)
|
|
717
|
+
resid = ys_arr - np.polyval(coef, xs_arr)
|
|
718
|
+
sigma = max(0.5, float(np.std(resid)))
|
|
719
|
+
keep = np.abs(resid) < 2.5 * sigma
|
|
720
|
+
if np.all(keep):
|
|
721
|
+
break
|
|
722
|
+
xs_arr, ys_arr = xs_arr[keep], ys_arr[keep]
|
|
723
|
+
|
|
724
|
+
final_coef = np.polyfit(xs_arr, ys_arr, deg)
|
|
725
|
+
return np.polyval(final_coef, np.arange(w, dtype=float))
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
# 插值补齐并外推缺失级次
|
|
729
|
+
def fill_missing_orders_by_interpolation(
|
|
730
|
+
apertures: ApertureSet,
|
|
731
|
+
image_width: int,
|
|
732
|
+
image_height: int,
|
|
733
|
+
trace_degree: int = 3,
|
|
734
|
+
n_extend_below: int = 0,
|
|
735
|
+
n_extend_above: int = 0,
|
|
736
|
+
flat_data: Optional[np.ndarray] = None,
|
|
737
|
+
gap_fill_factor: float = 1.35
|
|
738
|
+
) -> ApertureSet:
|
|
739
|
+
"""
|
|
740
|
+
Fill interior gaps and extend edges of an ApertureSet.
|
|
741
|
+
|
|
742
|
+
Interior gaps are filled by interpolating traces between bounding real orders.
|
|
743
|
+
Edge orders are extrapolated based on the spacing of the nearest real orders.
|
|
744
|
+
If `flat_data` is provided, interpolated traces are refined against the image.
|
|
745
|
+
|
|
746
|
+
Args:
|
|
747
|
+
apertures: The ApertureSet, assumed to be renumbered with gaps.
|
|
748
|
+
image_width: The width of the detector image.
|
|
749
|
+
image_height: The height of the detector image.
|
|
750
|
+
trace_degree: Polynomial degree for fitting new traces.
|
|
751
|
+
n_extend_below: Number of orders to extrapolate below the first real order.
|
|
752
|
+
n_extend_above: Number of orders to extrapolate above the last real order.
|
|
753
|
+
flat_data: Optional 2D flat image for refining interpolated traces.
|
|
754
|
+
gap_fill_factor: Factor used for gap detection.
|
|
755
|
+
|
|
756
|
+
Returns:
|
|
757
|
+
A new ApertureSet containing original, interpolated, and extrapolated orders.
|
|
758
|
+
"""
|
|
759
|
+
if apertures.norders < 2:
|
|
760
|
+
return apertures.copy()
|
|
761
|
+
|
|
762
|
+
order_map = assign_order_indices(apertures, gap_fill_factor)
|
|
763
|
+
m_to_oid = {m: oid for oid, m in order_map.items()}
|
|
764
|
+
sorted_m = sorted(m_to_oid.keys())
|
|
765
|
+
|
|
766
|
+
filled_set = ApertureSet()
|
|
767
|
+
x_all = np.arange(image_width, dtype=float)
|
|
768
|
+
x_mapped = 2.0 * x_all / (image_width - 1.0) - 1.0
|
|
769
|
+
|
|
770
|
+
# --- 1. Fill interior gaps by interpolation ---
|
|
771
|
+
for i in range(len(sorted_m) - 1):
|
|
772
|
+
m_lo, m_hi = sorted_m[i], sorted_m[i + 1]
|
|
773
|
+
oid_lo, oid_hi = m_to_oid[m_lo], m_to_oid[m_hi]
|
|
774
|
+
|
|
775
|
+
# Add the lower-bound real order
|
|
776
|
+
filled_set.add_aperture(apertures.get_aperture(oid_lo))
|
|
777
|
+
|
|
778
|
+
n_gap = m_hi - m_lo - 1
|
|
779
|
+
if n_gap <= 0:
|
|
780
|
+
continue
|
|
781
|
+
|
|
782
|
+
ap_lo = apertures.get_aperture(oid_lo)
|
|
783
|
+
ap_hi = apertures.get_aperture(oid_hi)
|
|
784
|
+
traces = {
|
|
785
|
+
"cen": (ap_lo.get_position(x_all), ap_hi.get_position(x_all)),
|
|
786
|
+
"low": (ap_lo.get_lower(x_all), ap_hi.get_lower(x_all)),
|
|
787
|
+
"upp": (ap_lo.get_upper(x_all), ap_hi.get_upper(x_all)),
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
for k in range(1, n_gap + 1):
|
|
791
|
+
frac = k / (n_gap + 1.0)
|
|
792
|
+
pred_traces = {key: val[0] + frac * (val[1] - val[0]) for key, val in traces.items()}
|
|
793
|
+
|
|
794
|
+
yc_mid = pred_traces["cen"][image_width // 2]
|
|
795
|
+
if not (0 <= yc_mid < image_height):
|
|
796
|
+
continue
|
|
797
|
+
|
|
798
|
+
final_center = pred_traces["cen"]
|
|
799
|
+
if flat_data is not None:
|
|
800
|
+
local_sep = float(np.median(traces["cen"][1] - traces["cen"][0])) / (n_gap + 1)
|
|
801
|
+
refined = _refine_predicted_trace(flat_data, pred_traces["cen"], local_sep, trace_degree)
|
|
802
|
+
if refined is not None:
|
|
803
|
+
final_center = refined
|
|
804
|
+
logger.info(f" Refined interpolated order m={m_lo + k}")
|
|
805
|
+
|
|
806
|
+
# Reconstruct boundaries relative to the (potentially refined) center
|
|
807
|
+
half_width_low = pred_traces["cen"] - pred_traces["low"]
|
|
808
|
+
half_width_upp = pred_traces["upp"] - pred_traces["cen"]
|
|
809
|
+
final_lower = final_center - half_width_low
|
|
810
|
+
final_upper = final_center + half_width_upp
|
|
811
|
+
|
|
812
|
+
new_id = m_lo + k + 1
|
|
813
|
+
ap = ApertureLocation(
|
|
814
|
+
aperture=new_id, order=new_id,
|
|
815
|
+
center_coef=cheb.chebfit(x_mapped, final_center, trace_degree),
|
|
816
|
+
lower_coef=cheb.chebfit(x_mapped, final_lower, trace_degree),
|
|
817
|
+
upper_coef=cheb.chebfit(x_mapped, final_upper, trace_degree),
|
|
818
|
+
width=float(np.median(final_upper - final_lower)),
|
|
819
|
+
is_chebyshev=True,
|
|
820
|
+
domain=(0.0, float(image_width - 1.0)),
|
|
821
|
+
is_interpolated=True,
|
|
822
|
+
)
|
|
823
|
+
filled_set.add_aperture(ap)
|
|
824
|
+
logger.info(f" Filled gap order m={m_lo + k} (ID={new_id}) at y~{yc_mid:.1f}")
|
|
825
|
+
|
|
826
|
+
# Add the last real order
|
|
827
|
+
filled_set.add_aperture(apertures.get_aperture(m_to_oid[sorted_m[-1]]))
|
|
828
|
+
|
|
829
|
+
# --- 2. Extrapolate orders at the edges ---
|
|
830
|
+
if n_extend_below > 0:
|
|
831
|
+
_extrapolate_edge_orders(filled_set, 'below', n_extend_below, image_width, image_height, trace_degree)
|
|
832
|
+
if n_extend_above > 0:
|
|
833
|
+
_extrapolate_edge_orders(filled_set, 'above', n_extend_above, image_width, image_height, trace_degree)
|
|
834
|
+
|
|
835
|
+
logger.info(f"Interpolation/extrapolation complete: {apertures.norders} -> {filled_set.norders} orders.")
|
|
836
|
+
return filled_set
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
# 沿探测器边缘外推虚拟级次
|
|
840
|
+
def _extrapolate_edge_orders(
|
|
841
|
+
apertures: ApertureSet,
|
|
842
|
+
side: str,
|
|
843
|
+
n_orders: int,
|
|
844
|
+
image_width: int,
|
|
845
|
+
image_height: int,
|
|
846
|
+
trace_degree: int,
|
|
847
|
+
n_ref: int = 8
|
|
848
|
+
):
|
|
849
|
+
"""Helper to extrapolate and add virtual orders to an ApertureSet in-place."""
|
|
850
|
+
real_oids = sorted(apertures.get_orders())
|
|
851
|
+
if len(real_oids) < 2:
|
|
852
|
+
return
|
|
853
|
+
|
|
854
|
+
if side == 'below':
|
|
855
|
+
ref_oids = real_oids[:min(n_ref, len(real_oids))]
|
|
856
|
+
direction = -1
|
|
857
|
+
anchor_ap = apertures.get_aperture(ref_oids[0])
|
|
858
|
+
start_idx = -1
|
|
859
|
+
else: # 'above'
|
|
860
|
+
ref_oids = real_oids[max(0, len(real_oids) - n_ref):]
|
|
861
|
+
direction = 1
|
|
862
|
+
anchor_ap = apertures.get_aperture(ref_oids[-1])
|
|
863
|
+
start_idx = len(ref_oids) - 2
|
|
864
|
+
|
|
865
|
+
x_all = np.arange(image_width, dtype=float)
|
|
866
|
+
ref_centers = np.array([apertures.get_aperture(oid).get_position(x_all) for oid in ref_oids])
|
|
867
|
+
spacings = np.diff(ref_centers, axis=0)
|
|
868
|
+
|
|
869
|
+
# Model spacing trend with a simple linear fit
|
|
870
|
+
if spacings.shape[0] >= 2:
|
|
871
|
+
j_sp = np.arange(spacings.shape[0], dtype=float)
|
|
872
|
+
b_sp = np.polyfit(j_sp, spacings, 1)[0] # slope only
|
|
873
|
+
else:
|
|
874
|
+
b_sp = np.zeros(image_width)
|
|
875
|
+
|
|
876
|
+
last_center = anchor_ap.get_position(x_all)
|
|
877
|
+
last_lower = anchor_ap.get_lower(x_all)
|
|
878
|
+
last_upper = anchor_ap.get_upper(x_all)
|
|
879
|
+
last_sep = spacings[start_idx]
|
|
880
|
+
|
|
881
|
+
for k in range(1, n_orders + 1):
|
|
882
|
+
# Extrapolate spacing, center, and width
|
|
883
|
+
current_sep = last_sep + k * b_sp * direction
|
|
884
|
+
new_center = last_center + current_sep * direction
|
|
885
|
+
width = last_upper - last_lower
|
|
886
|
+
new_lower, new_upper = new_center - width / 2, new_center + width / 2
|
|
887
|
+
|
|
888
|
+
if (side == 'below' and np.max(new_upper) < 0) or \
|
|
889
|
+
(side == 'above' and np.min(new_lower) >= image_height):
|
|
890
|
+
break # Stop if order is completely off-detector
|
|
891
|
+
|
|
892
|
+
x_mapped = 2.0 * x_all / (image_width - 1.0) - 1.0
|
|
893
|
+
|
|
894
|
+
new_id = anchor_ap.aperture + k * direction
|
|
895
|
+
ap = ApertureLocation(
|
|
896
|
+
aperture=new_id, order=new_id,
|
|
897
|
+
center_coef=cheb.chebfit(x_mapped, new_center, trace_degree),
|
|
898
|
+
lower_coef=cheb.chebfit(x_mapped, new_lower, trace_degree),
|
|
899
|
+
upper_coef=cheb.chebfit(x_mapped, new_upper, trace_degree),
|
|
900
|
+
width=float(np.median(width)),
|
|
901
|
+
is_chebyshev=True,
|
|
902
|
+
domain=(0.0, float(image_width - 1.0)),
|
|
903
|
+
is_interpolated=True,
|
|
904
|
+
)
|
|
905
|
+
apertures.add_aperture(ap)
|
|
906
|
+
logger.info(f" Extrapolated virtual order (ID={new_id}) at y~{new_center[image_width//2]:.1f}")
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
# 由级次间谷值拟合背景包络
|
|
910
|
+
def _fit_background_envelope_1d(cross_section, order_centers_y, half_win=4):
|
|
911
|
+
"""Fit a smooth background baseline envelope using inter-order valley samples.
|
|
912
|
+
|
|
913
|
+
At each inter-order midpoint (and detector edges) takes the local minimum inside a
|
|
914
|
+
±half_win pixel window as a valley sample, then fits a smoothing cubic spline through
|
|
915
|
+
all samples to produce a continuous background baseline across the whole column.
|
|
916
|
+
|
|
917
|
+
Parameters
|
|
918
|
+
----------
|
|
919
|
+
cross_section : 1-D array – the CCD column cross-section
|
|
920
|
+
order_centers_y : list[float] – center pixel of each order (sorted ascending)
|
|
921
|
+
half_win : int – half-width of local-minimum window at each sample point
|
|
922
|
+
|
|
923
|
+
Returns
|
|
924
|
+
-------
|
|
925
|
+
envelope : np.ndarray, shape (n,) – background baseline at every pixel
|
|
926
|
+
"""
|
|
927
|
+
|
|
928
|
+
n = len(cross_section)
|
|
929
|
+
cs = cross_section.astype(float)
|
|
930
|
+
centers = sorted(float(c) for c in order_centers_y)
|
|
931
|
+
|
|
932
|
+
# 求指定位置窗口内的局部最小值
|
|
933
|
+
def _local_min(pos):
|
|
934
|
+
i0 = max(0, int(round(pos)) - half_win)
|
|
935
|
+
i1 = min(n, int(round(pos)) + half_win + 1)
|
|
936
|
+
seg = cs[i0:i1]
|
|
937
|
+
return float(np.nanmin(seg)) if seg.size > 0 else float(cs[int(np.clip(round(pos), 0, n-1))])
|
|
938
|
+
|
|
939
|
+
sample_y = []
|
|
940
|
+
sample_v = []
|
|
941
|
+
|
|
942
|
+
# Lower detector edge
|
|
943
|
+
gap0 = (centers[1] - centers[0]) if len(centers) > 1 else 20.0
|
|
944
|
+
edge_lo = max(0.0, centers[0] - gap0 * 0.6)
|
|
945
|
+
sample_y.append(edge_lo); sample_v.append(_local_min(edge_lo))
|
|
946
|
+
|
|
947
|
+
# Midpoints between consecutive orders
|
|
948
|
+
for k in range(len(centers) - 1):
|
|
949
|
+
mid = (centers[k] + centers[k + 1]) * 0.5
|
|
950
|
+
sample_y.append(mid); sample_v.append(_local_min(mid))
|
|
951
|
+
|
|
952
|
+
# Upper detector edge
|
|
953
|
+
gap1 = (centers[-1] - centers[-2]) if len(centers) > 1 else 20.0
|
|
954
|
+
edge_hi = min(float(n - 1), centers[-1] + gap1 * 0.6)
|
|
955
|
+
sample_y.append(edge_hi); sample_v.append(_local_min(edge_hi))
|
|
956
|
+
|
|
957
|
+
sy = np.array(sample_y, dtype=float)
|
|
958
|
+
sv = np.array(sample_v, dtype=float)
|
|
959
|
+
|
|
960
|
+
# Deduplicate
|
|
961
|
+
_, ui = np.unique(sy, return_index=True)
|
|
962
|
+
sy, sv = sy[ui], sv[ui]
|
|
963
|
+
|
|
964
|
+
x_all = np.arange(n, dtype=float)
|
|
965
|
+
if len(sy) >= 4:
|
|
966
|
+
spl = UnivariateSpline(sy, sv, k=3, s=0, ext=3)
|
|
967
|
+
envelope = spl(x_all)
|
|
968
|
+
elif len(sy) >= 2:
|
|
969
|
+
envelope = np.interp(x_all, sy, sv)
|
|
970
|
+
else:
|
|
971
|
+
envelope = np.full(n, sv[0] if sv.size > 0 else float(np.nanmedian(cs)))
|
|
972
|
+
|
|
973
|
+
# Clamp: envelope must not exceed the actual cross-section
|
|
974
|
+
return np.clip(envelope, np.nanmin(cs), np.nanmax(cs))
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
# 求级次轮廓与背景的交点
|
|
978
|
+
def _find_profile_roots_1d(cross_section, center_idx, search_lo, search_hi,
|
|
979
|
+
threshold_frac=0.05, bg_envelope=None,
|
|
980
|
+
noise_floor=None):
|
|
981
|
+
"""Find where an order profile's wings blend into the background envelope.
|
|
982
|
+
|
|
983
|
+
Walks outward from *center_idx*; stops when ``cs[j] - bg_envelope[j]`` drops to
|
|
984
|
+
``threshold_frac * (peak - bg_at_peak)``. Position is subpixel-interpolated.
|
|
985
|
+
|
|
986
|
+
Parameters
|
|
987
|
+
----------
|
|
988
|
+
cross_section : 1-D array
|
|
989
|
+
center_idx : int
|
|
990
|
+
search_lo : float – lower search limit (inter-order midpoint)
|
|
991
|
+
search_hi : float – upper search limit
|
|
992
|
+
threshold_frac : float – fraction of peak amplitude above envelope for the root
|
|
993
|
+
bg_envelope : 1-D array or None – smooth background baseline; if None a flat
|
|
994
|
+
local-percentile estimate is used (legacy behaviour)
|
|
995
|
+
noise_floor : float or None – minimum excess above envelope in counts
|
|
996
|
+
|
|
997
|
+
Returns
|
|
998
|
+
-------
|
|
999
|
+
root_lo, root_hi : float – subpixel crossing positions
|
|
1000
|
+
bg_at_peak : float – envelope value at the order center
|
|
1001
|
+
threshold : float – absolute intensity level of the crossing
|
|
1002
|
+
"""
|
|
1003
|
+
n = len(cross_section)
|
|
1004
|
+
cs = cross_section.astype(float)
|
|
1005
|
+
ci = int(np.clip(center_idx, 0, n - 1))
|
|
1006
|
+
slo = int(np.clip(float(search_lo), 0, n - 1))
|
|
1007
|
+
shi = int(np.clip(float(search_hi), 0, n - 1))
|
|
1008
|
+
|
|
1009
|
+
peak = cs[ci]
|
|
1010
|
+
|
|
1011
|
+
if bg_envelope is None:
|
|
1012
|
+
region = cs[slo:shi + 1]
|
|
1013
|
+
flat_bg = float(np.nanpercentile(region, 10)) if region.size > 3 else float(np.nanmin(region) if region.size else 0.0)
|
|
1014
|
+
bg_envelope = np.full(n, flat_bg)
|
|
1015
|
+
|
|
1016
|
+
bg_at_peak = float(bg_envelope[ci])
|
|
1017
|
+
amp_at_peak = max(float(peak) - bg_at_peak, 1.0)
|
|
1018
|
+
excess_thresh = threshold_frac * amp_at_peak
|
|
1019
|
+
|
|
1020
|
+
# Hybrid threshold: percentage of peak OR noise floor, whichever is larger.
|
|
1021
|
+
if noise_floor is not None and np.isfinite(noise_floor):
|
|
1022
|
+
excess_thresh = max(excess_thresh, float(noise_floor))
|
|
1023
|
+
|
|
1024
|
+
# Keep threshold physically meaningful for this order.
|
|
1025
|
+
excess_thresh = float(np.clip(excess_thresh, 0.0, 0.9 * amp_at_peak))
|
|
1026
|
+
|
|
1027
|
+
# 计算像素相对背景包络的超额信号
|
|
1028
|
+
def _excess(j):
|
|
1029
|
+
return cs[j] - float(bg_envelope[int(np.clip(j, 0, n - 1))])
|
|
1030
|
+
|
|
1031
|
+
# --- lower crossing: walk ci → slo ---
|
|
1032
|
+
root_lo = float(slo)
|
|
1033
|
+
for j in range(ci, slo - 1, -1):
|
|
1034
|
+
if _excess(j) <= excess_thresh:
|
|
1035
|
+
if j + 1 <= ci:
|
|
1036
|
+
e0, e1 = _excess(j), _excess(j + 1) # e0 ≤ thresh < e1
|
|
1037
|
+
denom = e1 - e0
|
|
1038
|
+
t = float(np.clip((excess_thresh - e0) / denom, 0.0, 1.0)) if denom > 0 else 0.0
|
|
1039
|
+
root_lo = float(j) + t
|
|
1040
|
+
else:
|
|
1041
|
+
root_lo = float(j)
|
|
1042
|
+
break
|
|
1043
|
+
|
|
1044
|
+
# --- upper crossing: walk ci → shi ---
|
|
1045
|
+
root_hi = float(shi)
|
|
1046
|
+
for j in range(ci, shi + 1):
|
|
1047
|
+
if _excess(j) <= excess_thresh:
|
|
1048
|
+
if j - 1 >= ci:
|
|
1049
|
+
e0, e1 = _excess(j - 1), _excess(j) # e0 > thresh ≥ e1
|
|
1050
|
+
denom = e0 - e1
|
|
1051
|
+
t = float(np.clip((e0 - excess_thresh) / denom, 0.0, 1.0)) if denom > 0 else 0.0
|
|
1052
|
+
root_hi = float(j - 1) + t
|
|
1053
|
+
else:
|
|
1054
|
+
root_hi = float(j)
|
|
1055
|
+
break
|
|
1056
|
+
|
|
1057
|
+
threshold = bg_at_peak + excess_thresh
|
|
1058
|
+
return root_lo, root_hi, bg_at_peak, threshold
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
# 执行平场合并与级次追踪流程
|
|
1062
|
+
def process_order_tracing_stage(flat_filenames: List[str],
|
|
1063
|
+
output_dir_base: str = './midpath',
|
|
1064
|
+
# Flat combination parameters
|
|
1065
|
+
combine_method: str = 'median',
|
|
1066
|
+
combine_sigma: float = 3.0,
|
|
1067
|
+
mosaic_maxcount: float = 65535,
|
|
1068
|
+
# Order detection parameters
|
|
1069
|
+
snr_threshold: float = 5.0,
|
|
1070
|
+
gap_fill_factor: float = 1.6,
|
|
1071
|
+
min_trace_coverage: float = 0.20,
|
|
1072
|
+
trace_degree: int = 4,
|
|
1073
|
+
# Profile boundary parameters
|
|
1074
|
+
width_cheb_degree: int = 3,
|
|
1075
|
+
aperture_boundary_snr: float = 3.0,
|
|
1076
|
+
# Gap fill / extend parameters
|
|
1077
|
+
n_extend_below: int = 0,
|
|
1078
|
+
n_extend_above: int = 0,
|
|
1079
|
+
gap_fill_factor_interp: float = 1.35,
|
|
1080
|
+
# Diagnostic parameters
|
|
1081
|
+
save_plots: bool = True,
|
|
1082
|
+
fig_format: str = 'png') -> Tuple[FlatField, ApertureSet]:
|
|
1083
|
+
"""_
|
|
1084
|
+
Execute flat fielding stage.
|
|
1085
|
+
|
|
1086
|
+
Args:
|
|
1087
|
+
flat_filenames: List of flat frame paths
|
|
1088
|
+
output_dir_base: Base output directory
|
|
1089
|
+
|
|
1090
|
+
Returns:
|
|
1091
|
+
Tuple of (FlatField, ApertureSet)
|
|
1092
|
+
"""
|
|
1093
|
+
processor = FlatFieldProcessor()
|
|
1094
|
+
|
|
1095
|
+
# Combine flat frames
|
|
1096
|
+
flat_data, flat_mask = processor.combine_flat_frames(
|
|
1097
|
+
flat_filenames,
|
|
1098
|
+
combine_method=combine_method,
|
|
1099
|
+
combine_sigma=combine_sigma,
|
|
1100
|
+
mosaic_maxcount=mosaic_maxcount)
|
|
1101
|
+
|
|
1102
|
+
# Save master flat field as FITS and PNG (combined image + mask only)
|
|
1103
|
+
logger.info("Saving master flat field...")
|
|
1104
|
+
base_output_path = output_dir_base
|
|
1105
|
+
flat_file = Path(base_output_path) / 'step2_trace' / 'MasterFlat.fits'
|
|
1106
|
+
flat_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1107
|
+
processor.save_flat_field(str(flat_file), save_plots=save_plots, fig_format=fig_format)
|
|
1108
|
+
logger.info(f"Saved master flat field to {flat_file}")
|
|
1109
|
+
|
|
1110
|
+
# Detect orders — uses raw combined flat, no normalization needed
|
|
1111
|
+
logger.info("Detecting grating orders...")
|
|
1112
|
+
apertures = processor.detect_orders( # Pass relevant params from config
|
|
1113
|
+
snr_threshold=snr_threshold,
|
|
1114
|
+
min_trace_coverage=min_trace_coverage,
|
|
1115
|
+
trace_degree=trace_degree,
|
|
1116
|
+
output_dir_base=output_dir_base)
|
|
1117
|
+
logger.info(f"Order detection completed: {apertures.norders} orders found")
|
|
1118
|
+
|
|
1119
|
+
# Renumber apertures with gap-aware physical order indices.
|
|
1120
|
+
# If spatial analysis detects missing orders, aperture IDs skip the
|
|
1121
|
+
# missing positions (e.g. 1,...,49,51,...,100) so that the downstream
|
|
1122
|
+
# 2D surface fitting uses correct order index m for every aperture.
|
|
1123
|
+
apertures = renumber_apertures_with_gaps(apertures, gap_fill_factor)
|
|
1124
|
+
|
|
1125
|
+
# Pre-compute profile-root-based boundaries for order mask + diagnostic plots.
|
|
1126
|
+
# _find_profile_roots_1d finds where the signal drops to aperture_boundary_snr × σ
|
|
1127
|
+
# above background, which is always strictly inside the inter-order midpoint. This
|
|
1128
|
+
# guarantees adjacent order masks never overlap even for densely packed echelle orders.
|
|
1129
|
+
logger.info("Pre-computing profile-root boundaries for order mask and diagnostics...")
|
|
1130
|
+
height, width = flat_data.shape
|
|
1131
|
+
x_coords_all = np.arange(width)
|
|
1132
|
+
center_col = width // 2
|
|
1133
|
+
|
|
1134
|
+
# 估算背景噪声阈值
|
|
1135
|
+
def _estimate_noise_floor(cross_section_1d, bg_envelope_1d, centers_y):
|
|
1136
|
+
"""Estimate background noise via iterative MAD sigma-clipping."""
|
|
1137
|
+
residual = cross_section_1d - bg_envelope_1d
|
|
1138
|
+
valid = np.isfinite(residual)
|
|
1139
|
+
res = residual[valid]
|
|
1140
|
+
|
|
1141
|
+
if res.size < 5:
|
|
1142
|
+
return float(np.nanstd(residual))
|
|
1143
|
+
|
|
1144
|
+
sorted_res = np.sort(res)
|
|
1145
|
+
bg_pixels = sorted_res[:max(5, len(sorted_res) // 4)]
|
|
1146
|
+
med = float(np.nanmedian(bg_pixels))
|
|
1147
|
+
mad = float(np.nanmedian(np.abs(bg_pixels - med)))
|
|
1148
|
+
sigma_est = max(1.4826 * mad, 1e-6)
|
|
1149
|
+
keep = res < (med + 3.0 * sigma_est)
|
|
1150
|
+
|
|
1151
|
+
for _ in range(5):
|
|
1152
|
+
if np.sum(keep) < 5: break
|
|
1153
|
+
sub = res[keep]
|
|
1154
|
+
med = float(np.nanmedian(sub))
|
|
1155
|
+
mad = float(np.nanmedian(np.abs(sub - med)))
|
|
1156
|
+
sigma_est = max(1.4826 * mad, 1e-6)
|
|
1157
|
+
new_keep = res < (med + 3.0 * sigma_est)
|
|
1158
|
+
if np.array_equal(new_keep, keep): break
|
|
1159
|
+
keep = new_keep
|
|
1160
|
+
|
|
1161
|
+
if np.sum(keep) >= 5:
|
|
1162
|
+
sub = res[keep]
|
|
1163
|
+
med = float(np.nanmedian(sub))
|
|
1164
|
+
mad = float(np.nanmedian(np.abs(sub - med)))
|
|
1165
|
+
sigma_bg = max(1.4826 * mad, 1e-6)
|
|
1166
|
+
else:
|
|
1167
|
+
sigma_bg = max(sigma_est, 1e-6)
|
|
1168
|
+
|
|
1169
|
+
return sigma_bg * aperture_boundary_snr
|
|
1170
|
+
|
|
1171
|
+
ordered_apertures = sorted(apertures.apertures.items(), key=lambda x: x[0])
|
|
1172
|
+
valid_orders = []
|
|
1173
|
+
for order_idx, (aperture_id, aperture) in enumerate(ordered_apertures, start=1):
|
|
1174
|
+
center_pos = aperture.get_position(x_coords_all)
|
|
1175
|
+
valid_center = np.isfinite(center_pos) & (center_pos >= 0) & (center_pos < height)
|
|
1176
|
+
if np.sum(valid_center) < 10:
|
|
1177
|
+
continue
|
|
1178
|
+
valid_orders.append({
|
|
1179
|
+
'order_idx': order_idx,
|
|
1180
|
+
'aperture_id': aperture_id,
|
|
1181
|
+
'aperture': aperture,
|
|
1182
|
+
'valid_center': valid_center,
|
|
1183
|
+
})
|
|
1184
|
+
|
|
1185
|
+
y_lo_all = np.full((len(valid_orders), width), np.nan, dtype=float)
|
|
1186
|
+
y_hi_all = np.full((len(valid_orders), width), np.nan, dtype=float)
|
|
1187
|
+
if len(valid_orders) >= 1:
|
|
1188
|
+
# 计算单列级次边界
|
|
1189
|
+
def process_column(xi):
|
|
1190
|
+
x = x_coords_all[xi]
|
|
1191
|
+
cross_section_col = flat_data[:, x].astype(float)
|
|
1192
|
+
centers = []
|
|
1193
|
+
active_rows = []
|
|
1194
|
+
for row_idx, item in enumerate(valid_orders):
|
|
1195
|
+
cy = float(item['aperture'].get_position(x))
|
|
1196
|
+
if not np.isfinite(cy) or cy < -5 or cy > height + 5:
|
|
1197
|
+
continue
|
|
1198
|
+
centers.append(float(np.clip(cy, 0, height - 1)))
|
|
1199
|
+
active_rows.append((row_idx, cy))
|
|
1200
|
+
if len(centers) == 0:
|
|
1201
|
+
return xi, None
|
|
1202
|
+
bg_env_col = _fit_background_envelope_1d(cross_section_col, centers)
|
|
1203
|
+
noise_floor_col = _estimate_noise_floor(cross_section_col, bg_env_col, centers)
|
|
1204
|
+
results = []
|
|
1205
|
+
for local_i, (row_idx, cy_raw) in enumerate(active_rows):
|
|
1206
|
+
cy_y = float(np.clip(cy_raw, 0, height - 1))
|
|
1207
|
+
cy_idx = int(round(cy_y))
|
|
1208
|
+
if local_i > 0:
|
|
1209
|
+
search_lo = 0.5 * (centers[local_i] + centers[local_i - 1])
|
|
1210
|
+
else:
|
|
1211
|
+
gap = (centers[1] - centers[0]) if len(centers) > 1 else 20.0
|
|
1212
|
+
search_lo = max(0.0, centers[local_i] - 0.5 * gap)
|
|
1213
|
+
if local_i < len(centers) - 1:
|
|
1214
|
+
search_hi = 0.5 * (centers[local_i] + centers[local_i + 1])
|
|
1215
|
+
else:
|
|
1216
|
+
gap = (centers[-1] - centers[-2]) if len(centers) > 1 else 20.0
|
|
1217
|
+
search_hi = min(float(height - 1), centers[local_i] + 0.5 * gap)
|
|
1218
|
+
root_lo, root_hi, _bg_at_peak, _threshold = _find_profile_roots_1d(
|
|
1219
|
+
cross_section_col, cy_idx, search_lo, search_hi,
|
|
1220
|
+
threshold_frac=0.0, bg_envelope=bg_env_col, noise_floor=noise_floor_col)
|
|
1221
|
+
results.append((row_idx, root_lo, root_hi))
|
|
1222
|
+
return xi, results
|
|
1223
|
+
|
|
1224
|
+
with ThreadPoolExecutor() as executor:
|
|
1225
|
+
for xi, results in executor.map(process_column, range(width)):
|
|
1226
|
+
if results is None:
|
|
1227
|
+
continue
|
|
1228
|
+
for row_idx, root_lo, root_hi in results:
|
|
1229
|
+
y_lo_all[row_idx, xi] = float(np.clip(root_lo, 0, height - 1))
|
|
1230
|
+
y_hi_all[row_idx, xi] = float(np.clip(root_hi, 0, height - 1))
|
|
1231
|
+
|
|
1232
|
+
logger.info("Filtering abnormal boundary widths via cross-order interpolation...")
|
|
1233
|
+
centers_all = np.full((len(valid_orders), width), np.nan)
|
|
1234
|
+
for row_idx, item in enumerate(valid_orders):
|
|
1235
|
+
centers_all[row_idx, :] = item['aperture'].get_position(x_coords_all)
|
|
1236
|
+
|
|
1237
|
+
w_up_all = y_hi_all - centers_all
|
|
1238
|
+
w_low_all = centers_all - y_lo_all
|
|
1239
|
+
|
|
1240
|
+
from scipy.ndimage import median_filter
|
|
1241
|
+
for xi in range(width):
|
|
1242
|
+
for w_arr in (w_up_all, w_low_all):
|
|
1243
|
+
col_w = w_arr[:, xi]
|
|
1244
|
+
valid = np.isfinite(col_w) & (col_w > 0)
|
|
1245
|
+
if np.sum(valid) >= 3:
|
|
1246
|
+
idx_valid = np.where(valid)[0]
|
|
1247
|
+
val_valid = col_w[valid]
|
|
1248
|
+
|
|
1249
|
+
if len(val_valid) >= 5:
|
|
1250
|
+
trend = median_filter(val_valid, size=5, mode='nearest')
|
|
1251
|
+
else:
|
|
1252
|
+
trend = np.full_like(val_valid, np.median(val_valid))
|
|
1253
|
+
|
|
1254
|
+
# 识别异常偏宽的边界:宽度 > 趋势宽度的 1.4 倍,或者比趋势宽出 4 个像素
|
|
1255
|
+
outlier_mask = (val_valid > 1.4 * trend) | (val_valid > trend + 4.0)
|
|
1256
|
+
|
|
1257
|
+
if np.any(outlier_mask):
|
|
1258
|
+
good_mask = ~outlier_mask
|
|
1259
|
+
if np.sum(good_mask) >= 2:
|
|
1260
|
+
val_valid[outlier_mask] = np.interp(idx_valid[outlier_mask], idx_valid[good_mask], val_valid[good_mask])
|
|
1261
|
+
elif np.sum(good_mask) == 1:
|
|
1262
|
+
val_valid[outlier_mask] = val_valid[good_mask][0]
|
|
1263
|
+
col_w[idx_valid] = val_valid
|
|
1264
|
+
|
|
1265
|
+
y_hi_all = centers_all + w_up_all
|
|
1266
|
+
y_lo_all = centers_all - w_low_all
|
|
1267
|
+
|
|
1268
|
+
# Resolve overlaps in raw boundaries before fitting to guide the polynomials
|
|
1269
|
+
for i in range(len(valid_orders) - 1):
|
|
1270
|
+
for xi in range(width):
|
|
1271
|
+
if y_hi_all[i, xi] >= y_lo_all[i+1, xi] - 1.0:
|
|
1272
|
+
c1 = int(round(centers_all[i, xi]))
|
|
1273
|
+
c2 = int(round(centers_all[i+1, xi]))
|
|
1274
|
+
c1 = max(0, min(c1, height - 1))
|
|
1275
|
+
c2 = max(0, min(c2, height - 1))
|
|
1276
|
+
if c2 > c1 + 2:
|
|
1277
|
+
valley = c1 + np.argmin(flat_data[c1:c2, xi])
|
|
1278
|
+
else:
|
|
1279
|
+
valley = int(round(0.5 * (c1 + c2)))
|
|
1280
|
+
y_hi_all[i, xi] = valley - 0.5
|
|
1281
|
+
y_lo_all[i+1, xi] = valley + 0.5
|
|
1282
|
+
|
|
1283
|
+
x_all_f = x_coords_all.astype(float)
|
|
1284
|
+
|
|
1285
|
+
logger.info(f"Hybrid smoothing: Chebyshev center (deg {trace_degree}) + Chebyshev width (deg {width_cheb_degree})")
|
|
1286
|
+
|
|
1287
|
+
_trace_coefs = {}
|
|
1288
|
+
|
|
1289
|
+
for row_idx, item in enumerate(valid_orders):
|
|
1290
|
+
aperture = item['aperture']
|
|
1291
|
+
valid_center = item['valid_center']
|
|
1292
|
+
center_raw = aperture.get_position(x_all_f)
|
|
1293
|
+
y_lo_raw = y_lo_all[row_idx, :]
|
|
1294
|
+
y_hi_raw = y_hi_all[row_idx, :]
|
|
1295
|
+
valid_bnd = valid_center & np.isfinite(y_lo_raw) & np.isfinite(y_hi_raw)
|
|
1296
|
+
n_valid = int(np.sum(valid_bnd))
|
|
1297
|
+
if n_valid < 10:
|
|
1298
|
+
continue
|
|
1299
|
+
|
|
1300
|
+
center_smooth = center_raw
|
|
1301
|
+
w_up_raw = y_hi_raw[valid_bnd] - center_smooth[valid_bnd]
|
|
1302
|
+
w_low_raw = center_smooth[valid_bnd] - y_lo_raw[valid_bnd]
|
|
1303
|
+
|
|
1304
|
+
x_bnd = x_all_f[valid_bnd]
|
|
1305
|
+
span = x_bnd[-1] - x_bnd[0] if len(x_bnd) > 0 else 0
|
|
1306
|
+
deg = min(width_cheb_degree, n_valid - 1) if span >= 0.6 * width else (min(1, width_cheb_degree, n_valid - 1) if span >= 0.3 * width else 0)
|
|
1307
|
+
|
|
1308
|
+
coef_w_up = cheb.chebfit(x_bnd, w_up_raw, deg)
|
|
1309
|
+
coef_w_low = cheb.chebfit(x_bnd, w_low_raw, deg)
|
|
1310
|
+
|
|
1311
|
+
w_up_smooth = np.maximum(cheb.chebval(x_all_f, coef_w_up), 0.0)
|
|
1312
|
+
w_low_smooth = np.maximum(cheb.chebval(x_all_f, coef_w_low), 0.0)
|
|
1313
|
+
|
|
1314
|
+
hi_final = np.clip(center_smooth + w_up_smooth, 0, height - 1)
|
|
1315
|
+
lo_final = np.clip(center_smooth - w_low_smooth, 0, height - 1)
|
|
1316
|
+
|
|
1317
|
+
y_lo_all[row_idx, :] = np.where(valid_center, lo_final, np.nan)
|
|
1318
|
+
y_hi_all[row_idx, :] = np.where(valid_center, hi_final, np.nan)
|
|
1319
|
+
|
|
1320
|
+
_trace_coefs[item['aperture_id']] = {
|
|
1321
|
+
'center_arr': center_smooth.tolist(),
|
|
1322
|
+
'w_up_cheb': coef_w_up.tolist(),
|
|
1323
|
+
'w_low_cheb': coef_w_low.tolist(),
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
logger.info("Hybrid smoothing complete (Chebyshev center + Chebyshev width → traces)")
|
|
1327
|
+
|
|
1328
|
+
for item in valid_orders:
|
|
1329
|
+
aid = item['aperture_id']
|
|
1330
|
+
if aid in _trace_coefs:
|
|
1331
|
+
bc = _trace_coefs[aid]
|
|
1332
|
+
ap = item['aperture']
|
|
1333
|
+
ap.center_arr = np.asarray(bc['center_arr'], dtype=float)
|
|
1334
|
+
ap.w_up_cheb_coef = np.asarray(bc['w_up_cheb'], dtype=float)
|
|
1335
|
+
ap.w_low_cheb_coef = np.asarray(bc['w_low_cheb'], dtype=float)
|
|
1336
|
+
|
|
1337
|
+
filled_apertures = fill_missing_orders_by_interpolation(
|
|
1338
|
+
apertures, width, height, trace_degree=trace_degree,
|
|
1339
|
+
n_extend_below=n_extend_below, n_extend_above=n_extend_above,
|
|
1340
|
+
flat_data=flat_data, gap_fill_factor=gap_fill_factor_interp)
|
|
1341
|
+
|
|
1342
|
+
if filled_apertures and filled_apertures.norders > apertures.norders:
|
|
1343
|
+
apertures = filled_apertures
|
|
1344
|
+
logger.info(f"Gap-fill added {filled_apertures.norders - apertures.norders} predicted order(s)")
|
|
1345
|
+
|
|
1346
|
+
coefs_path = Path(base_output_path) / 'step2_trace' / 'Orders_trace_coefs.json'
|
|
1347
|
+
with open(coefs_path, 'w') as f:
|
|
1348
|
+
json.dump({'orders': _trace_coefs}, f, indent=2)
|
|
1349
|
+
logger.info(f"Saved trace coefs ({len(_trace_coefs)} orders) to {coefs_path.name}")
|
|
1350
|
+
|
|
1351
|
+
all_ap_ids = sorted(list(apertures.apertures.keys()))
|
|
1352
|
+
n_ap = len(all_ap_ids)
|
|
1353
|
+
lo_traces = np.zeros((n_ap, width))
|
|
1354
|
+
hi_traces = np.zeros((n_ap, width))
|
|
1355
|
+
centers = np.zeros((n_ap, width))
|
|
1356
|
+
|
|
1357
|
+
for i, ap_id in enumerate(all_ap_ids):
|
|
1358
|
+
ap = apertures.get_aperture(ap_id)
|
|
1359
|
+
centers[i] = ap.get_position(x_all_f)
|
|
1360
|
+
lo_traces[i] = ap.get_lower(x_all_f)
|
|
1361
|
+
hi_traces[i] = ap.get_upper(x_all_f)
|
|
1362
|
+
|
|
1363
|
+
# Enforce valley gap for the final mask and plot
|
|
1364
|
+
for i in range(n_ap - 1):
|
|
1365
|
+
for xi in range(width):
|
|
1366
|
+
if hi_traces[i, xi] >= lo_traces[i+1, xi] - 1.0:
|
|
1367
|
+
c1 = int(round(centers[i, xi]))
|
|
1368
|
+
c2 = int(round(centers[i+1, xi]))
|
|
1369
|
+
c1 = max(0, min(c1, height - 1))
|
|
1370
|
+
c2 = max(0, min(c2, height - 1))
|
|
1371
|
+
if c2 > c1 + 2:
|
|
1372
|
+
valley = c1 + np.argmin(flat_data[c1:c2, xi])
|
|
1373
|
+
else:
|
|
1374
|
+
valley = int(round(0.5 * (c1 + c2)))
|
|
1375
|
+
hi_traces[i, xi] = valley - 0.5
|
|
1376
|
+
lo_traces[i+1, xi] = valley + 0.5
|
|
1377
|
+
|
|
1378
|
+
# Use uint8 for boolean mask (1 = order, 0 = background)
|
|
1379
|
+
order_mask = np.zeros((height, width), dtype=np.uint8)
|
|
1380
|
+
for i, ap_id in enumerate(all_ap_ids):
|
|
1381
|
+
for xi in range(width):
|
|
1382
|
+
if np.isfinite(lo_traces[i, xi]) and np.isfinite(hi_traces[i, xi]):
|
|
1383
|
+
r0 = max(0, int(np.ceil(lo_traces[i, xi])))
|
|
1384
|
+
r1 = min(height, int(np.floor(hi_traces[i, xi])) + 1)
|
|
1385
|
+
if r1 > r0:
|
|
1386
|
+
order_mask[r0:r1, xi] = 1
|
|
1387
|
+
|
|
1388
|
+
mask_path = Path(base_output_path) / 'step2_trace' / 'Orders_mask.fits'
|
|
1389
|
+
write_fits_image(str(mask_path), order_mask, dtype='uint8')
|
|
1390
|
+
logger.info(f"Saved order boolean mask to {mask_path}")
|
|
1391
|
+
|
|
1392
|
+
if save_plots and apertures.norders > 0:
|
|
1393
|
+
logger.info("Generating diagnostic plots...")
|
|
1394
|
+
out_dir = Path(base_output_path) / 'step2_trace'
|
|
1395
|
+
from specproc.plotting.spectra_plotter import plot_2d_image_to_file
|
|
1396
|
+
import matplotlib.pyplot as plt
|
|
1397
|
+
plt.figure(figsize=(12, 10))
|
|
1398
|
+
vmin, vmax = np.percentile(flat_data, [1, 99])
|
|
1399
|
+
plt.imshow(flat_data, aspect='auto', origin='lower', cmap='viridis', vmin=vmin, vmax=vmax)
|
|
1400
|
+
plt.colorbar(label='Counts')
|
|
1401
|
+
|
|
1402
|
+
for i, ap_id in enumerate(all_ap_ids):
|
|
1403
|
+
ap = apertures.get_aperture(ap_id)
|
|
1404
|
+
center_pos = centers[i]
|
|
1405
|
+
lower_pos = lo_traces[i]
|
|
1406
|
+
upper_pos = hi_traces[i]
|
|
1407
|
+
|
|
1408
|
+
is_filled = getattr(ap, 'is_interpolated', False)
|
|
1409
|
+
alpha_val = 0.7 if is_filled else 1.0
|
|
1410
|
+
|
|
1411
|
+
# 中心轨迹线用间隔大一点的虚线,上下边缘用点线
|
|
1412
|
+
# 颜色与背景光谱的 color map 对比明显
|
|
1413
|
+
plt.plot(x_coords_all, center_pos, color='red', linestyle='--', linewidth=0.5, alpha=alpha_val)
|
|
1414
|
+
plt.plot(x_coords_all, lower_pos, color='orange', linestyle=':', linewidth=0.4, alpha=alpha_val)
|
|
1415
|
+
plt.plot(x_coords_all, upper_pos, color='magenta', linestyle=':', linewidth=0.4, alpha=alpha_val)
|
|
1416
|
+
|
|
1417
|
+
if np.any(np.isfinite(center_pos)):
|
|
1418
|
+
x_anno = x_coords_all[np.isfinite(center_pos)][-1]
|
|
1419
|
+
y_anno = center_pos[np.isfinite(center_pos)][-1]
|
|
1420
|
+
plt.text(x_anno + 5, y_anno, str(ap_id), color='red', fontsize=5, ha='left', va='center', alpha=alpha_val, clip_on=False)
|
|
1421
|
+
|
|
1422
|
+
plt.xlim(0, width - 1)
|
|
1423
|
+
plt.ylim(bottom=0)
|
|
1424
|
+
plt.xlabel('Pixel (X)')
|
|
1425
|
+
plt.ylabel('Pixel (Y)')
|
|
1426
|
+
plt.title('Aperture Traces on Combined Master Flat')
|
|
1427
|
+
plt.grid(False)
|
|
1428
|
+
traces_plot_file = out_dir / f'order_traces.{fig_format}'
|
|
1429
|
+
plt.savefig(str(traces_plot_file), dpi=150, bbox_inches='tight')
|
|
1430
|
+
plt.close()
|
|
1431
|
+
logger.info(f"Saved order traces plot to {traces_plot_file}")
|
|
1432
|
+
|
|
1433
|
+
# Plot seed peaks diagnostic
|
|
1434
|
+
diags = processor.order_diagnostics
|
|
1435
|
+
if 'seed_profile' in diags and 'seeds' in diags:
|
|
1436
|
+
logger.info("Plotting seed peaks diagnostic...")
|
|
1437
|
+
seed_profile = diags['seed_profile']
|
|
1438
|
+
seeds = diags['seeds']
|
|
1439
|
+
envelope = diags.get('envelope')
|
|
1440
|
+
|
|
1441
|
+
fig, ax = plt.subplots(figsize=(12, 8))
|
|
1442
|
+
y_coords = np.arange(len(seed_profile))
|
|
1443
|
+
if envelope is not None and 'profile' in diags:
|
|
1444
|
+
ax.plot(y_coords, diags['profile'], 'k-', linewidth=0.7, alpha=1.0, label='Raw Profile')
|
|
1445
|
+
ax.plot(y_coords, envelope, 'g--', linewidth=0.7, label='Background Envelope')
|
|
1446
|
+
|
|
1447
|
+
det_threshold_flux = snr_threshold * diags.get('noise', 1.0)
|
|
1448
|
+
ax.plot(y_coords, envelope + det_threshold_flux, color='magenta', linestyle=':', linewidth=0.8, alpha=0.8, label=f'{snr_threshold} $\\sigma$ Detection Threshold')
|
|
1449
|
+
|
|
1450
|
+
# Add markings for each aperture
|
|
1451
|
+
max_prof_val = np.nanmax(diags['profile']) if 'profile' in diags else 1000
|
|
1452
|
+
x1_col = diags.get('x1', max(0, width // 2 - 5))
|
|
1453
|
+
x2_col = diags.get('x2', min(width, width // 2 + 6))
|
|
1454
|
+
x_cols_eval = np.arange(x1_col, x2_col, dtype=float)
|
|
1455
|
+
for ap_id, ap in apertures.apertures.items():
|
|
1456
|
+
center_y = np.nanmedian(ap.get_position(x_cols_eval))
|
|
1457
|
+
lower_y = np.nanmedian(ap.get_lower(x_cols_eval))
|
|
1458
|
+
upper_y = np.nanmedian(ap.get_upper(x_cols_eval))
|
|
1459
|
+
|
|
1460
|
+
if np.isfinite(center_y):
|
|
1461
|
+
if len(seeds) > 0:
|
|
1462
|
+
seed_idx = np.argmin(np.abs(seeds - center_y))
|
|
1463
|
+
if np.abs(seeds[seed_idx] - center_y) < diags.get('med_sep', 30) * 0.75:
|
|
1464
|
+
peak_y_coord = seeds[seed_idx]
|
|
1465
|
+
if 'profile' in diags and envelope is not None and 0 <= peak_y_coord < len(diags['profile']):
|
|
1466
|
+
peak_top = diags['profile'][peak_y_coord]
|
|
1467
|
+
|
|
1468
|
+
# 红色短线和级次序号标记在 peaks 顶部
|
|
1469
|
+
v_offset = 0.015 * max_prof_val
|
|
1470
|
+
ax.vlines(peak_y_coord, peak_top + v_offset, peak_top + v_offset + 0.025 * max_prof_val, color='red', linewidth=1.5, zorder=10)
|
|
1471
|
+
ax.text(peak_y_coord, peak_top + v_offset + 0.03 * max_prof_val, str(ap_id), color='red', ha='center', va='bottom', fontsize=7)
|
|
1472
|
+
|
|
1473
|
+
# 青色水平 error bar,两边的 cap 底部卡在实际的级次边缘流量对应的位置
|
|
1474
|
+
err_lower = center_y - lower_y
|
|
1475
|
+
err_upper = upper_y - center_y
|
|
1476
|
+
if err_lower > 0 and err_upper > 0:
|
|
1477
|
+
idx_lower = int(np.clip(round(lower_y), 0, len(diags['profile']) - 1))
|
|
1478
|
+
idx_upper = int(np.clip(round(upper_y), 0, len(diags['profile']) - 1))
|
|
1479
|
+
edge_flux = (diags['profile'][idx_lower] + diags['profile'][idx_upper]) / 2.0
|
|
1480
|
+
ax.errorbar(peak_y_coord, edge_flux, xerr=[[err_lower], [err_upper]], fmt='none', ecolor='cyan', capsize=2.0, elinewidth=0.6, capthick=0.4, zorder=10)
|
|
1481
|
+
|
|
1482
|
+
ax.set_xlabel('Pixel (Y)')
|
|
1483
|
+
ax.set_ylabel('Flux (Counts)')
|
|
1484
|
+
ax.set_title('Aperture Seeds Detection')
|
|
1485
|
+
ax.legend()
|
|
1486
|
+
ax.grid(True, alpha=0.3)
|
|
1487
|
+
seed_peaks_plot_file = out_dir / f'order_seed_peaks.{fig_format}'
|
|
1488
|
+
plt.savefig(str(seed_peaks_plot_file), dpi=150, bbox_inches='tight')
|
|
1489
|
+
plt.close(fig)
|
|
1490
|
+
logger.info(f"Saved seed peaks plot to {seed_peaks_plot_file}")
|
|
1491
|
+
|
|
1492
|
+
# Plot peak SNR diagnostic
|
|
1493
|
+
logger.info("Plotting peak SNR diagnostic...")
|
|
1494
|
+
noise = diags.get('noise', 1.0)
|
|
1495
|
+
local_sep_func = diags.get('local_sep_func')
|
|
1496
|
+
fig, ax1 = plt.subplots(figsize=(12, 8))
|
|
1497
|
+
|
|
1498
|
+
plot_data = []
|
|
1499
|
+
for ap_id, ap in apertures.apertures.items():
|
|
1500
|
+
center_y = np.nanmedian(ap.get_position(x_cols_eval))
|
|
1501
|
+
if np.isfinite(center_y) and len(seeds) > 0:
|
|
1502
|
+
seed_idx = np.argmin(np.abs(seeds - center_y))
|
|
1503
|
+
if np.abs(seeds[seed_idx] - center_y) < diags.get('med_sep', 30) * 0.75:
|
|
1504
|
+
s_y = seeds[seed_idx]
|
|
1505
|
+
if 0 <= s_y < len(seed_profile):
|
|
1506
|
+
snr_val = seed_profile[s_y] / noise
|
|
1507
|
+
plot_data.append((s_y, snr_val, ap_id))
|
|
1508
|
+
|
|
1509
|
+
plot_data.sort(key=lambda x: x[0])
|
|
1510
|
+
|
|
1511
|
+
if len(plot_data) > 0:
|
|
1512
|
+
plot_seeds, plot_snrs, plot_labels = zip(*plot_data)
|
|
1513
|
+
ax1.plot(plot_seeds, plot_snrs, 'b-o', linewidth=1.0, markersize=4, label='Peak SNR')
|
|
1514
|
+
|
|
1515
|
+
# 根据图像截面长度(完整空间方向跨度)稍微左右宽展几列
|
|
1516
|
+
x_margin = len(seed_profile) * 0.02
|
|
1517
|
+
ax1.set_xlim(-x_margin, len(seed_profile) - 1 + x_margin)
|
|
1518
|
+
|
|
1519
|
+
# 提高 Y 轴上限,为顶部居中的图例留出空间
|
|
1520
|
+
snr_max = max(plot_snrs)
|
|
1521
|
+
ax1.set_ylim(bottom=-snr_max * 0.05, top=max(snr_threshold * 1.5, snr_max * 1.20))
|
|
1522
|
+
|
|
1523
|
+
ax1.axhline(y=snr_threshold, color='magenta', linestyle=':', linewidth=1.2, alpha=0.8, label=f'{snr_threshold} $\\sigma$ Threshold')
|
|
1524
|
+
|
|
1525
|
+
ax1.set_xlabel('Pixel (Y)')
|
|
1526
|
+
ax1.set_ylabel('Signal-to-Noise Ratio (SNR)', color='b')
|
|
1527
|
+
ax1.tick_params(axis='y', labelcolor='b')
|
|
1528
|
+
ax1.set_title('Peak SNR and Inter-Aperture Separation')
|
|
1529
|
+
ax1.grid(True, alpha=0.3, axis='x')
|
|
1530
|
+
|
|
1531
|
+
if local_sep_func and len(plot_data) > 0:
|
|
1532
|
+
ax2 = ax1.twinx()
|
|
1533
|
+
separations = [local_sep_func(y) for y in plot_seeds]
|
|
1534
|
+
|
|
1535
|
+
# Inter-order Separation 只画点
|
|
1536
|
+
ax2.plot(plot_seeds, separations, 'rs', markersize=3, alpha=0.7, label='Inter-aperture Separation')
|
|
1537
|
+
|
|
1538
|
+
# 画出前面多项式拟合得到的光滑曲线
|
|
1539
|
+
x_min, x_max = ax1.get_xlim()
|
|
1540
|
+
y_eval = np.linspace(max(0, x_min), min(len(seed_profile)-1, x_max), 300)
|
|
1541
|
+
sep_eval = [local_sep_func(y) for y in y_eval]
|
|
1542
|
+
ax2.plot(y_eval, sep_eval, 'r-', linewidth=1.0, alpha=0.4, label='Smooth Fit Curve')
|
|
1543
|
+
|
|
1544
|
+
ax2.set_ylabel('Inter-aperture Separation (pixels)', color='r')
|
|
1545
|
+
ax2.tick_params(axis='y', labelcolor='r')
|
|
1546
|
+
|
|
1547
|
+
# 提高右侧 Y 轴上限,防止级次间距曲线撞到顶部图例
|
|
1548
|
+
sep_max = max(sep_eval) if len(sep_eval) > 0 else 10.0
|
|
1549
|
+
ax2.set_ylim(bottom=-sep_max * 0.05, top=sep_max * 1.20)
|
|
1550
|
+
|
|
1551
|
+
snr_range = max(plot_snrs) - min(plot_snrs) if len(plot_snrs) > 1 else 10.0
|
|
1552
|
+
y_range = max(sep_eval) - min(sep_eval) if len(sep_eval) > 1 else 10.0
|
|
1553
|
+
for y, sep, snr, label in zip(plot_seeds, separations, plot_snrs, plot_labels):
|
|
1554
|
+
ax1.text(y, snr + snr_range * 0.015, str(label), va='bottom', ha='center', color='blue', fontsize=7, rotation=90)
|
|
1555
|
+
ax2.text(y, sep + y_range * 0.015, str(label), va='bottom', ha='center', color='red', fontsize=7, rotation=90)
|
|
1556
|
+
|
|
1557
|
+
# 合并两个图的图例到一起
|
|
1558
|
+
lines1, labels1 = ax1.get_legend_handles_labels()
|
|
1559
|
+
lines2, labels2 = ax2.get_legend_handles_labels()
|
|
1560
|
+
ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper center', ncol=3)
|
|
1561
|
+
elif len(plot_data) > 0:
|
|
1562
|
+
snr_range = max(plot_snrs) - min(plot_snrs) if len(plot_snrs) > 1 else 10.0
|
|
1563
|
+
for y, snr, label in zip(plot_seeds, plot_snrs, plot_labels):
|
|
1564
|
+
ax1.text(y, snr + snr_range * 0.015, str(label), va='bottom', ha='center', color='blue', fontsize=7, rotation=90)
|
|
1565
|
+
ax1.legend(loc='upper center', ncol=2)
|
|
1566
|
+
|
|
1567
|
+
fig.tight_layout()
|
|
1568
|
+
snr_plot_file = out_dir / f'order_seed_snr.{fig_format}'
|
|
1569
|
+
plt.savefig(str(snr_plot_file), dpi=150, bbox_inches='tight')
|
|
1570
|
+
plt.close(fig)
|
|
1571
|
+
logger.info(f"Saved peak SNR plot to {snr_plot_file}")
|
|
1572
|
+
|
|
1573
|
+
logger.info("Creating FlatField object...")
|
|
1574
|
+
flat_field = FlatField(
|
|
1575
|
+
flat_data=processor.flat_data,
|
|
1576
|
+
flat_mask=processor.flat_mask,
|
|
1577
|
+
flat_norm=None,
|
|
1578
|
+
flat_sens=None,
|
|
1579
|
+
scattered_light=None,
|
|
1580
|
+
smoothed_model=None,
|
|
1581
|
+
pixel_flat=None,
|
|
1582
|
+
illumination_flat=None,
|
|
1583
|
+
aperture_set=apertures,
|
|
1584
|
+
)
|
|
1585
|
+
|
|
1586
|
+
logger.info("Step 2 order tracing completed successfully")
|
|
1587
|
+
return flat_field, apertures
|