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,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
De-blazing correction stage.
|
|
3
|
+
|
|
4
|
+
Corrects for the blaze function of the spectrograph grating
|
|
5
|
+
to flatten the spectral response.
|
|
6
|
+
"""
|
|
7
|
+
# 根据平场闪耀轮廓校正提取光谱响应。
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
from specproc.core.data_structures import SpectraSet, FlatField
|
|
14
|
+
from specproc.plotting.spectra_plotter import plot_spectrum_to_file, plot_spectra_to_pdf
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# 用平场闪耀轮廓校正光谱。
|
|
20
|
+
def apply_de_blazing(spectra_set: SpectraSet, flat_field: FlatField) -> SpectraSet:
|
|
21
|
+
"""
|
|
22
|
+
Apply de-blazing correction to extracted spectra.
|
|
23
|
+
|
|
24
|
+
Divides each spectrum by its corresponding blaze profile
|
|
25
|
+
to remove wavelength-dependent efficiency variations.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
spectra_set: Extracted spectra to correct
|
|
29
|
+
flat_field: Flat field data containing blaze profiles
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
De-blazed spectra set
|
|
33
|
+
"""
|
|
34
|
+
if flat_field.blaze_profiles is None or len(flat_field.blaze_profiles) == 0:
|
|
35
|
+
logger.warning("No blaze profiles available, skipping de-blazing")
|
|
36
|
+
return spectra_set
|
|
37
|
+
|
|
38
|
+
corrected_spectra = SpectraSet()
|
|
39
|
+
|
|
40
|
+
for spectrum in spectra_set.spectra.values():
|
|
41
|
+
aperture_id = spectrum.aperture
|
|
42
|
+
|
|
43
|
+
if aperture_id in flat_field.blaze_profiles:
|
|
44
|
+
blaze_profile = flat_field.blaze_profiles[aperture_id]
|
|
45
|
+
|
|
46
|
+
# Ensure blaze profile matches spectrum length
|
|
47
|
+
if len(blaze_profile) == len(spectrum.flux):
|
|
48
|
+
blaze_peak = np.nanmax(blaze_profile)
|
|
49
|
+
if not np.isfinite(blaze_peak) or blaze_peak <= 0:
|
|
50
|
+
blaze_peak = 1.0
|
|
51
|
+
|
|
52
|
+
blaze_threshold = 0.02 * blaze_peak
|
|
53
|
+
|
|
54
|
+
# --- FIX: Contiguous valid region ---
|
|
55
|
+
# Only keep the contiguous region around the peak that is above the threshold.
|
|
56
|
+
# This strictly ignores any artificial "up-swings" or polynomial ringing at the extreme edges.
|
|
57
|
+
bad_blaze_mask = np.ones_like(blaze_profile, dtype=bool)
|
|
58
|
+
peak_idx = int(np.nanargmax(blaze_profile))
|
|
59
|
+
|
|
60
|
+
# Walk left from the peak
|
|
61
|
+
left_bound = peak_idx
|
|
62
|
+
while left_bound > 0 and blaze_profile[left_bound - 1] >= blaze_threshold:
|
|
63
|
+
left_bound -= 1
|
|
64
|
+
|
|
65
|
+
# Walk right from the peak
|
|
66
|
+
right_bound = peak_idx
|
|
67
|
+
while right_bound < len(blaze_profile) - 1 and blaze_profile[right_bound + 1] >= blaze_threshold:
|
|
68
|
+
right_bound += 1
|
|
69
|
+
|
|
70
|
+
# The contiguous region from left_bound to right_bound is good.
|
|
71
|
+
bad_blaze_mask[left_bound:right_bound + 1] = False
|
|
72
|
+
|
|
73
|
+
# Avoid division by zero or very small numbers by setting them to NaN
|
|
74
|
+
blaze_for_division = np.where(bad_blaze_mask, np.nan, blaze_profile)
|
|
75
|
+
|
|
76
|
+
# Apply de-blazing, ignoring division warnings for NaNs
|
|
77
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
78
|
+
corrected_flux = spectrum.flux / blaze_for_division
|
|
79
|
+
corrected_error = spectrum.error / blaze_for_division if spectrum.error is not None else None
|
|
80
|
+
|
|
81
|
+
# In regions of low blaze, the result is unreliable. Set flux and error to NaN.
|
|
82
|
+
corrected_flux[bad_blaze_mask] = np.nan
|
|
83
|
+
if corrected_error is not None:
|
|
84
|
+
corrected_error[bad_blaze_mask] = np.nan
|
|
85
|
+
|
|
86
|
+
# Clean up any other non-finite values that might have resulted from the division
|
|
87
|
+
corrected_flux[~np.isfinite(corrected_flux)] = np.nan
|
|
88
|
+
if corrected_error is not None:
|
|
89
|
+
corrected_error[~np.isfinite(corrected_error)] = np.nan
|
|
90
|
+
|
|
91
|
+
# Create corrected spectrum
|
|
92
|
+
corrected_spectrum = spectrum.copy()
|
|
93
|
+
corrected_spectrum.flux = corrected_flux
|
|
94
|
+
corrected_spectrum.error = corrected_error
|
|
95
|
+
corrected_spectra.add_spectrum(corrected_spectrum)
|
|
96
|
+
else:
|
|
97
|
+
logger.warning(f"Blaze profile length mismatch for aperture {aperture_id}, skipping")
|
|
98
|
+
corrected_spectra.add_spectrum(spectrum)
|
|
99
|
+
else:
|
|
100
|
+
logger.warning(f"No blaze profile for aperture {aperture_id}, skipping de-blazing")
|
|
101
|
+
corrected_spectra.add_spectrum(spectrum)
|
|
102
|
+
|
|
103
|
+
logger.info(f"✓ De-blazing complete: {len(corrected_spectra.spectra)} spectra processed")
|
|
104
|
+
return corrected_spectra
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# 将去闪耀光谱保存为FITS表格。
|
|
108
|
+
def save_deblazed_spectra(output_path: str, spectra_set: SpectraSet,
|
|
109
|
+
content_desc: str = 'De-blazed calibrated spectra',
|
|
110
|
+
log_desc: str = 'de-blazed'):
|
|
111
|
+
"""Save de-blazed spectra to FITS file."""
|
|
112
|
+
if spectra_set is None:
|
|
113
|
+
raise RuntimeError("No de-blazed spectra to save")
|
|
114
|
+
|
|
115
|
+
from astropy.io import fits
|
|
116
|
+
from pathlib import Path
|
|
117
|
+
|
|
118
|
+
output_path = Path(output_path)
|
|
119
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
|
|
121
|
+
# Create structured array for table
|
|
122
|
+
norders = spectra_set.norders
|
|
123
|
+
max_pixels = max([s.npixel() for s in spectra_set.spectra.values()])
|
|
124
|
+
|
|
125
|
+
# Create binary table
|
|
126
|
+
cols = []
|
|
127
|
+
for aperture_id in spectra_set.get_orders():
|
|
128
|
+
spec = spectra_set.get_spectrum(aperture_id)
|
|
129
|
+
|
|
130
|
+
# Ensure arrays are floating-point so NaN padding is always valid.
|
|
131
|
+
wl_arr = np.asarray(spec.wavelength, dtype=np.float64)
|
|
132
|
+
fl_arr = np.asarray(spec.flux, dtype=np.float64)
|
|
133
|
+
|
|
134
|
+
# Pad arrays to same length
|
|
135
|
+
wl = np.pad(wl_arr, (0, max_pixels - len(wl_arr)), constant_values=np.nan)
|
|
136
|
+
fl = np.pad(fl_arr, (0, max_pixels - len(fl_arr)), constant_values=np.nan)
|
|
137
|
+
|
|
138
|
+
cols.append(fits.Column(name=f'WAV_{aperture_id:02d}', format='D', array=wl))
|
|
139
|
+
cols.append(fits.Column(name=f'FLUX_{aperture_id:02d}', format='D', array=fl))
|
|
140
|
+
|
|
141
|
+
if spec.error is not None:
|
|
142
|
+
err_arr = np.asarray(spec.error, dtype=np.float64)
|
|
143
|
+
err = np.pad(err_arr, (0, max_pixels - len(err_arr)), constant_values=np.nan)
|
|
144
|
+
cols.append(fits.Column(name=f'ERR_{aperture_id:02d}', format='D', array=err))
|
|
145
|
+
|
|
146
|
+
coldefs = fits.ColDefs(cols)
|
|
147
|
+
table_hdu = fits.BinTableHDU.from_columns(coldefs)
|
|
148
|
+
|
|
149
|
+
# Create header
|
|
150
|
+
header = fits.Header()
|
|
151
|
+
header['NORDERS'] = norders
|
|
152
|
+
header['NPX'] = max_pixels
|
|
153
|
+
header['CONTENTS'] = content_desc
|
|
154
|
+
header['COMMENT'] = 'Wavelength in Angstroms, Flux in ADU'
|
|
155
|
+
|
|
156
|
+
primary_hdu = fits.PrimaryHDU(header=header)
|
|
157
|
+
|
|
158
|
+
hdul = fits.HDUList([primary_hdu, table_hdu])
|
|
159
|
+
hdul.writeto(str(output_path), overwrite=True)
|
|
160
|
+
|
|
161
|
+
logger.info(f"Saved {norders} {log_desc} spectra to {output_path}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# 执行去闪耀校正并保存结果。
|
|
165
|
+
def process_de_blazing_stage(spectra_set: SpectraSet,
|
|
166
|
+
output_dir_base: str,
|
|
167
|
+
flat_field: Optional[FlatField] = None,
|
|
168
|
+
save_output: bool = True,
|
|
169
|
+
save_deblaze: bool = True,
|
|
170
|
+
output_filename: str = 'deblazed_spectra.fits',
|
|
171
|
+
plot_prefix: str = 'deblazed',
|
|
172
|
+
save_plots: bool = True,
|
|
173
|
+
fig_format: str = 'png') -> SpectraSet:
|
|
174
|
+
"""
|
|
175
|
+
Execute de-blazing correction stage.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
spectra_set: Extracted spectra
|
|
179
|
+
output_dir_base: Base output directory
|
|
180
|
+
flat_field: Flat field data with blaze profiles
|
|
181
|
+
save_output: Whether to save de-blazed spectra to file
|
|
182
|
+
save_deblaze: Whether to save de-blazed intermediate products
|
|
183
|
+
output_filename: Filename for the saved FITS file
|
|
184
|
+
plot_prefix: Prefix for saved plot files
|
|
185
|
+
save_plots: Whether to save diagnostic plots
|
|
186
|
+
fig_format: Format for diagnostic plots
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
De-blazed spectra set
|
|
190
|
+
"""
|
|
191
|
+
if flat_field is None:
|
|
192
|
+
logger.warning("No flat field provided, skipping de-blazing")
|
|
193
|
+
return spectra_set
|
|
194
|
+
|
|
195
|
+
deblazed_spectra = apply_de_blazing(spectra_set, flat_field)
|
|
196
|
+
|
|
197
|
+
# Save de-blazed spectra if enabled
|
|
198
|
+
if save_output and save_deblaze:
|
|
199
|
+
deblazed_file = Path(output_dir_base) / 'step6_deblazing' / output_filename
|
|
200
|
+
deblazed_file.parent.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
save_deblazed_spectra(str(deblazed_file), deblazed_spectra)
|
|
202
|
+
|
|
203
|
+
# Save diagnostic plots if enabled
|
|
204
|
+
if save_plots:
|
|
205
|
+
out_dir = deblazed_file.parent
|
|
206
|
+
pdf_name = f"{plot_prefix}.pdf"
|
|
207
|
+
pdf_path = out_dir / pdf_name
|
|
208
|
+
plot_spectra_to_pdf(deblazed_spectra, str(pdf_path), title_prefix="De-blazed Spectrum (pixels)", xlabel="Pixel")
|
|
209
|
+
|
|
210
|
+
return deblazed_spectra
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Spectrum extraction stage of spectral reduction pipeline.
|
|
3
|
+
|
|
4
|
+
Handles 1D spectrum extraction using sum or optimal (Gaussian) methods.
|
|
5
|
+
"""
|
|
6
|
+
# 从二维图像中提取求和或最优一维光谱。
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Tuple, Optional
|
|
12
|
+
from scipy.optimize import curve_fit
|
|
13
|
+
from specproc.core.data_structures import Spectrum, SpectraSet, ApertureSet, WaveCalib, FlatField
|
|
14
|
+
from specproc.plotting.spectra_plotter import plot_spectrum_to_file, plot_spectra_to_pdf
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# 负责从二维图像提取一维光谱。
|
|
20
|
+
class SpectrumExtractor:
|
|
21
|
+
"""Handles 1D spectrum extraction from 2D images."""
|
|
22
|
+
|
|
23
|
+
# 设置最优提取阈值与默认方法。
|
|
24
|
+
def __init__(self, optimal_sigma: float = 3.0, method: str = 'optimal'):
|
|
25
|
+
"""
|
|
26
|
+
Initialize spectrum extractor.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
optimal_sigma: Gaussian sigma for optimal extraction profile.
|
|
30
|
+
method: Default extraction method ('optimal' or 'sum').
|
|
31
|
+
"""
|
|
32
|
+
self.optimal_sigma = optimal_sigma
|
|
33
|
+
self.method = method
|
|
34
|
+
self.extracted_spectra = None
|
|
35
|
+
|
|
36
|
+
# 对孔径内像素求和提取光谱。
|
|
37
|
+
def sum_extraction(self, image: np.ndarray, aperture_positions: np.ndarray,
|
|
38
|
+
lower_bounds: np.ndarray = None, upper_bounds: np.ndarray = None) -> np.ndarray:
|
|
39
|
+
"""
|
|
40
|
+
Extract spectrum using simple aperture sum.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
image: 2D image array
|
|
44
|
+
aperture_positions: Aperture center positions (per column)
|
|
45
|
+
lower_bounds: Lower boundary positions (per column, absolute y)
|
|
46
|
+
upper_bounds: Upper boundary positions (per column, absolute y)
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
1D spectrum array
|
|
50
|
+
"""
|
|
51
|
+
logger.debug("Performing sum extraction...")
|
|
52
|
+
|
|
53
|
+
rows, cols = image.shape
|
|
54
|
+
|
|
55
|
+
if len(aperture_positions) != cols:
|
|
56
|
+
raise ValueError("Aperture positions length must match image width")
|
|
57
|
+
|
|
58
|
+
spectrum = np.zeros(cols)
|
|
59
|
+
|
|
60
|
+
for x in range(cols):
|
|
61
|
+
center = aperture_positions[x]
|
|
62
|
+
if lower_bounds is not None and upper_bounds is not None:
|
|
63
|
+
y_min = max(0, int(np.floor(lower_bounds[x])))
|
|
64
|
+
y_max = min(rows, int(np.ceil(upper_bounds[x])) + 1)
|
|
65
|
+
else:
|
|
66
|
+
y_min = max(0, int(center - 5))
|
|
67
|
+
y_max = min(rows, int(center + 5) + 1)
|
|
68
|
+
|
|
69
|
+
if y_max > y_min:
|
|
70
|
+
spectrum[x] = np.sum(image[y_min:y_max, x])
|
|
71
|
+
else:
|
|
72
|
+
spectrum[x] = 0.0
|
|
73
|
+
|
|
74
|
+
logger.debug(f"Sum extraction complete: {len(spectrum)} pixels")
|
|
75
|
+
|
|
76
|
+
return spectrum
|
|
77
|
+
|
|
78
|
+
# 按空间光强剖面最优提取光谱。
|
|
79
|
+
def optimal_extraction(self, image: np.ndarray, aperture_positions: np.ndarray,
|
|
80
|
+
spatial_profile_2d: Optional[np.ndarray] = None,
|
|
81
|
+
lower_bounds: np.ndarray = None,
|
|
82
|
+
upper_bounds: np.ndarray = None) -> Tuple[np.ndarray, np.ndarray]:
|
|
83
|
+
"""
|
|
84
|
+
Extract spectrum using optimal extraction.
|
|
85
|
+
|
|
86
|
+
Uses a profile-weighted approach. If a flat-field-derived profile is
|
|
87
|
+
provided, use it; otherwise fallback to Gaussian profile.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
image: 2D image array
|
|
91
|
+
aperture_positions: Aperture center positions (per column)
|
|
92
|
+
profile: Optional spatial profile shape (relative weights)
|
|
93
|
+
lower_bounds: Lower boundary positions (per column, absolute y)
|
|
94
|
+
upper_bounds: Upper boundary positions (per column, absolute y)
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Tuple of (spectrum, error)
|
|
98
|
+
"""
|
|
99
|
+
logger.debug("Performing optimal extraction...")
|
|
100
|
+
|
|
101
|
+
rows, cols = image.shape
|
|
102
|
+
|
|
103
|
+
if len(aperture_positions) != cols:
|
|
104
|
+
raise ValueError("Aperture positions length must match image width")
|
|
105
|
+
|
|
106
|
+
spectrum = np.zeros(cols)
|
|
107
|
+
error = np.zeros(cols)
|
|
108
|
+
|
|
109
|
+
for x in range(cols):
|
|
110
|
+
center = aperture_positions[x]
|
|
111
|
+
if lower_bounds is not None and upper_bounds is not None:
|
|
112
|
+
y_min = max(0, int(np.floor(lower_bounds[x])))
|
|
113
|
+
y_max = min(rows, int(np.ceil(upper_bounds[x])) + 1)
|
|
114
|
+
else:
|
|
115
|
+
y_min = max(0, int(center - 5))
|
|
116
|
+
y_max = min(rows, int(center + 5) + 1)
|
|
117
|
+
|
|
118
|
+
if y_max <= y_min:
|
|
119
|
+
spectrum[x] = 0.0
|
|
120
|
+
error[x] = 0.0
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
sub_image = image[y_min:y_max, x]
|
|
124
|
+
local_y = np.arange(y_min, y_max) - center
|
|
125
|
+
|
|
126
|
+
weights = None
|
|
127
|
+
if spatial_profile_2d is not None:
|
|
128
|
+
p_col = spatial_profile_2d[y_min:y_max, x].astype(np.float64)
|
|
129
|
+
sum_p = np.sum(p_col)
|
|
130
|
+
if sum_p > 0:
|
|
131
|
+
weights = p_col / sum_p
|
|
132
|
+
|
|
133
|
+
if weights is None:
|
|
134
|
+
sigma = self.optimal_sigma
|
|
135
|
+
weights = np.exp(-0.5 * (local_y / sigma) ** 2)
|
|
136
|
+
weights /= np.sum(weights) if np.sum(weights) > 0 else 1.0
|
|
137
|
+
|
|
138
|
+
weighted = sub_image * weights
|
|
139
|
+
spectrum[x] = np.sum(weighted)
|
|
140
|
+
error[x] = np.sqrt(np.sum((sub_image * weights) ** 2))
|
|
141
|
+
|
|
142
|
+
logger.debug(f"Optimal extraction complete: {len(spectrum)} pixels")
|
|
143
|
+
|
|
144
|
+
return spectrum, error
|
|
145
|
+
|
|
146
|
+
# 对全部孔径执行光谱提取。
|
|
147
|
+
def extract_aperture_set(self, image: np.ndarray, apertures: ApertureSet,
|
|
148
|
+
wavelength_calib: Optional[WaveCalib] = None,
|
|
149
|
+
flat_field: Optional[FlatField] = None,
|
|
150
|
+
method_override: Optional[str] = None) -> SpectraSet:
|
|
151
|
+
"""
|
|
152
|
+
Extract all apertures from 2D image.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
image: 2D science image
|
|
156
|
+
apertures: ApertureSet with aperture definitions
|
|
157
|
+
wavelength_calib: Optional wavelength calibration
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
SpectraSet with extracted spectra
|
|
161
|
+
"""
|
|
162
|
+
logger.info(f"Extracting {apertures.norders} apertures...")
|
|
163
|
+
|
|
164
|
+
spectra_set = SpectraSet()
|
|
165
|
+
|
|
166
|
+
method = method_override if method_override is not None else self.method
|
|
167
|
+
|
|
168
|
+
_, cols = image.shape
|
|
169
|
+
|
|
170
|
+
for aperture_id in apertures.get_orders():
|
|
171
|
+
aperture = apertures.get_aperture(aperture_id)
|
|
172
|
+
if aperture is None:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
x_pix = np.arange(cols)
|
|
176
|
+
center_pos = aperture.get_position(x_pix)
|
|
177
|
+
lower_bounds = aperture.get_lower(x_pix)
|
|
178
|
+
upper_bounds = aperture.get_upper(x_pix)
|
|
179
|
+
|
|
180
|
+
spatial_profile_2d = None
|
|
181
|
+
if flat_field is not None:
|
|
182
|
+
if flat_field.smoothed_model is not None:
|
|
183
|
+
spatial_profile_2d = flat_field.smoothed_model
|
|
184
|
+
|
|
185
|
+
if method == 'optimal':
|
|
186
|
+
flux, error = self.optimal_extraction(image, center_pos,
|
|
187
|
+
spatial_profile_2d=spatial_profile_2d,
|
|
188
|
+
lower_bounds=lower_bounds,
|
|
189
|
+
upper_bounds=upper_bounds)
|
|
190
|
+
else:
|
|
191
|
+
flux = self.sum_extraction(image, center_pos,
|
|
192
|
+
lower_bounds=lower_bounds,
|
|
193
|
+
upper_bounds=upper_bounds)
|
|
194
|
+
error = np.sqrt(np.abs(flux))
|
|
195
|
+
|
|
196
|
+
# Apply wavelength calibration if available
|
|
197
|
+
if wavelength_calib is not None:
|
|
198
|
+
wavelength = wavelength_calib.apply_to_pixel(x_pix,
|
|
199
|
+
aperture_id * np.ones_like(x_pix))
|
|
200
|
+
else:
|
|
201
|
+
wavelength = x_pix
|
|
202
|
+
|
|
203
|
+
# Create Spectrum object
|
|
204
|
+
spec = Spectrum(
|
|
205
|
+
aperture=aperture_id,
|
|
206
|
+
order=aperture.order,
|
|
207
|
+
wavelength=wavelength,
|
|
208
|
+
flux=flux,
|
|
209
|
+
error=error,
|
|
210
|
+
background=None,
|
|
211
|
+
mask=None
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
spectra_set.add_spectrum(spec)
|
|
215
|
+
|
|
216
|
+
self.extracted_spectra = spectra_set
|
|
217
|
+
logger.info(f"Extraction complete: {spectra_set.norders} spectra")
|
|
218
|
+
|
|
219
|
+
return spectra_set
|
|
220
|
+
|
|
221
|
+
# 将提取的光谱集合写入FITS。
|
|
222
|
+
def save_spectra(self, output_path: str, spectra_set: SpectraSet):
|
|
223
|
+
"""Save extracted spectra to FITS file."""
|
|
224
|
+
if spectra_set is None:
|
|
225
|
+
raise RuntimeError("No spectra to save")
|
|
226
|
+
|
|
227
|
+
from astropy.io import fits
|
|
228
|
+
from pathlib import Path
|
|
229
|
+
|
|
230
|
+
output_path = Path(output_path)
|
|
231
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
|
|
233
|
+
# Create structured array for table
|
|
234
|
+
norders = spectra_set.norders
|
|
235
|
+
max_pixels = max([s.npixel() for s in spectra_set.spectra.values()])
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# Create binary table
|
|
239
|
+
cols = []
|
|
240
|
+
for aperture_id in spectra_set.get_orders():
|
|
241
|
+
spec = spectra_set.get_spectrum(aperture_id)
|
|
242
|
+
|
|
243
|
+
# Pad arrays to same length
|
|
244
|
+
wl = np.pad(spec.wavelength, (0, max_pixels - len(spec.wavelength)))
|
|
245
|
+
fl = np.pad(spec.flux, (0, max_pixels - len(spec.flux)))
|
|
246
|
+
|
|
247
|
+
cols.append(fits.Column(name=f'WAV_{aperture_id:02d}', format='D', array=wl))
|
|
248
|
+
cols.append(fits.Column(name=f'FLUX_{aperture_id:02d}', format='D', array=fl))
|
|
249
|
+
|
|
250
|
+
if spec.error is not None:
|
|
251
|
+
err = np.pad(spec.error, (0, max_pixels - len(spec.error)))
|
|
252
|
+
cols.append(fits.Column(name=f'ERR_{aperture_id:02d}', format='D', array=err))
|
|
253
|
+
|
|
254
|
+
# Create HDUs
|
|
255
|
+
primary_hdu = fits.PrimaryHDU()
|
|
256
|
+
table_hdu = fits.BinTableHDU.from_columns(cols)
|
|
257
|
+
table_hdu.header['NORDERS'] = norders
|
|
258
|
+
|
|
259
|
+
hdul = fits.HDUList([primary_hdu, table_hdu])
|
|
260
|
+
hdul.writeto(str(output_path), overwrite=True)
|
|
261
|
+
|
|
262
|
+
logger.info(f"Saved {norders} spectra to {output_path}")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# 执行光谱提取并保存相关产物。
|
|
266
|
+
def process_extraction_stage(science_image: np.ndarray,
|
|
267
|
+
apertures: ApertureSet,
|
|
268
|
+
output_dir_base: str,
|
|
269
|
+
wavelength_calib: Optional[WaveCalib] = None,
|
|
270
|
+
flat_field: Optional[FlatField] = None,
|
|
271
|
+
method_override: Optional[str] = None,
|
|
272
|
+
output_filename: str = 'extracted_spectra.fits',
|
|
273
|
+
plot_prefix: str = 'extracted',
|
|
274
|
+
optimal_sigma: float = 3.0,
|
|
275
|
+
extraction_method: str = 'optimal',
|
|
276
|
+
save_plots: bool = True,
|
|
277
|
+
fig_format: str = 'png') -> SpectraSet:
|
|
278
|
+
"""
|
|
279
|
+
Execute spectrum extraction stage.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
science_image: 2D science image
|
|
283
|
+
apertures: Detected apertures
|
|
284
|
+
output_dir_base: Base output directory
|
|
285
|
+
wavelength_calib: Wavelength calibration
|
|
286
|
+
flat_field: Flat field data
|
|
287
|
+
method_override: Override default extraction method
|
|
288
|
+
output_filename: Name for output FITS file
|
|
289
|
+
plot_prefix: Prefix for diagnostic plots
|
|
290
|
+
optimal_sigma: Sigma for Gaussian profile in optimal extraction
|
|
291
|
+
extraction_method: Default extraction method
|
|
292
|
+
save_plots: Whether to save diagnostic plots
|
|
293
|
+
fig_format: Format for diagnostic plots
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
SpectraSet with extracted 1D spectra
|
|
297
|
+
"""
|
|
298
|
+
extractor = SpectrumExtractor(optimal_sigma=optimal_sigma, method=extraction_method)
|
|
299
|
+
|
|
300
|
+
# Extract spectra
|
|
301
|
+
spectra_set = extractor.extract_aperture_set(science_image, apertures,
|
|
302
|
+
wavelength_calib, flat_field,
|
|
303
|
+
method_override=method_override)
|
|
304
|
+
|
|
305
|
+
# Save spectra
|
|
306
|
+
spectra_file = Path(output_dir_base) / 'step5_extraction' / output_filename
|
|
307
|
+
spectra_file.parent.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
extractor.save_spectra(str(spectra_file), spectra_set)
|
|
309
|
+
|
|
310
|
+
# Save diagnostic plots if enabled
|
|
311
|
+
if save_plots:
|
|
312
|
+
out_dir = spectra_file.parent
|
|
313
|
+
if wavelength_calib is not None:
|
|
314
|
+
pdf_path = out_dir / f"{plot_prefix}_all_apertures.pdf"
|
|
315
|
+
plot_spectra_to_pdf(spectra_set, str(pdf_path), title_prefix="Extracted Spectrum")
|
|
316
|
+
else:
|
|
317
|
+
# If no wavelength calibration, plot raw pixels
|
|
318
|
+
pdf_name = f"{plot_prefix}.pdf"
|
|
319
|
+
pdf_path = out_dir / pdf_name
|
|
320
|
+
plot_spectra_to_pdf(spectra_set, str(pdf_path), title_prefix="Extracted Spectrum (pixels)", xlabel="Pixel")
|
|
321
|
+
|
|
322
|
+
return spectra_set
|
|
323
|
+
|
|
324
|
+
# 从FITS文件载入提取光谱。
|
|
325
|
+
def load_extracted_spectra(filepath: str) -> SpectraSet:
|
|
326
|
+
"""Load extracted spectra from FITS file."""
|
|
327
|
+
from astropy.io import fits
|
|
328
|
+
from specproc.core.data_structures import Spectrum, SpectraSet
|
|
329
|
+
|
|
330
|
+
spectra_set = SpectraSet()
|
|
331
|
+
|
|
332
|
+
with fits.open(filepath) as hdul:
|
|
333
|
+
if len(hdul) < 2:
|
|
334
|
+
raise ValueError("FITS file does not contain a binary table")
|
|
335
|
+
|
|
336
|
+
table_hdu = hdul[1]
|
|
337
|
+
|
|
338
|
+
# Extract unique aperture IDs from column names
|
|
339
|
+
aperture_ids = set()
|
|
340
|
+
for col in table_hdu.columns:
|
|
341
|
+
if col.name.startswith('FLUX_'):
|
|
342
|
+
try:
|
|
343
|
+
aperture_ids.add(int(col.name.split('_')[1]))
|
|
344
|
+
except ValueError:
|
|
345
|
+
pass
|
|
346
|
+
|
|
347
|
+
for ap_id in sorted(list(aperture_ids)):
|
|
348
|
+
wav_col = f'WAV_{ap_id:02d}'
|
|
349
|
+
flux_col = f'FLUX_{ap_id:02d}'
|
|
350
|
+
err_col = f'ERR_{ap_id:02d}'
|
|
351
|
+
|
|
352
|
+
if wav_col in table_hdu.data.columns.names and flux_col in table_hdu.data.columns.names:
|
|
353
|
+
wav = table_hdu.data[wav_col]
|
|
354
|
+
flux = table_hdu.data[flux_col]
|
|
355
|
+
err = table_hdu.data[err_col] if err_col in table_hdu.data.columns.names else None
|
|
356
|
+
|
|
357
|
+
if len(wav.shape) > 1 and wav.shape[0] == 1:
|
|
358
|
+
wav = wav[0]
|
|
359
|
+
if len(flux.shape) > 1 and flux.shape[0] == 1:
|
|
360
|
+
flux = flux[0]
|
|
361
|
+
if err is not None and len(err.shape) > 1 and err.shape[0] == 1:
|
|
362
|
+
err = err[0]
|
|
363
|
+
|
|
364
|
+
spec = Spectrum(
|
|
365
|
+
aperture=ap_id,
|
|
366
|
+
order=ap_id, # order is same as aperture for now
|
|
367
|
+
wavelength=wav,
|
|
368
|
+
flux=flux,
|
|
369
|
+
error=err,
|
|
370
|
+
background=None,
|
|
371
|
+
mask=None
|
|
372
|
+
)
|
|
373
|
+
spectra_set.add_spectrum(spec)
|
|
374
|
+
|
|
375
|
+
return spectra_set
|