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,615 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Basic reduction stage — Step 1 of the spectral reduction pipeline.
|
|
3
|
+
|
|
4
|
+
Combines three sub-steps that are applied before order tracing:
|
|
5
|
+
1. Overscan subtraction (OverscanCorrectionStage / process_overscan_stage)
|
|
6
|
+
2. Bias subtraction (BiasCorrector / process_bias_stage)
|
|
7
|
+
3. Cosmic-ray removal (_detect_cosmics / _fix_cosmics / process_cosmic_stage)
|
|
8
|
+
|
|
9
|
+
The unified entry point is process_basic_reduction_stage(), which runs
|
|
10
|
+
whichever of the three sub-steps are enabled in the configuration.
|
|
11
|
+
"""
|
|
12
|
+
# 完成过扫描、偏置与宇宙射线等基础校正。
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
import matplotlib.pyplot as plt
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import List, Tuple, Optional
|
|
21
|
+
|
|
22
|
+
from specproc.utils.overscan import OverscanCorrector
|
|
23
|
+
from specproc.utils.fits_io import read_fits_image, write_fits_image, combine_fits_images
|
|
24
|
+
from specproc.utils.image_processing import combine_images
|
|
25
|
+
from specproc.config.config_manager import ConfigManager
|
|
26
|
+
from specproc.plotting.spectra_plotter import (
|
|
27
|
+
plot_2d_image_to_file,
|
|
28
|
+
plot_overscan_corrected_image,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Use astroscrappy for L.A.Cosmic
|
|
32
|
+
try:
|
|
33
|
+
import astroscrappy
|
|
34
|
+
except ImportError:
|
|
35
|
+
astroscrappy = None
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Sub-step 1: Overscan correction
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
# 负责原始图像的过扫描校正。
|
|
45
|
+
class OverscanCorrectionStage:
|
|
46
|
+
"""Handles overscan correction for raw images."""
|
|
47
|
+
|
|
48
|
+
# 保存过扫描区域及图像裁剪参数。
|
|
49
|
+
def __init__(self,
|
|
50
|
+
overscan_start_column: int = -1,
|
|
51
|
+
overscan_left_start: int = -1, overscan_left_end: int = -1,
|
|
52
|
+
overscan_right_start: int = -1, overscan_right_end: int = -1,
|
|
53
|
+
overscan_top_start: int = -1, overscan_top_end: int = -1,
|
|
54
|
+
overscan_bottom_start: int = -1, overscan_bottom_end: int = -1,
|
|
55
|
+
overscan_method: str = 'mean_only',
|
|
56
|
+
overscan_smooth_window: int = -1,
|
|
57
|
+
overscan_poly_order: int = 3,
|
|
58
|
+
overscan_poly_type: str = 'legendre',
|
|
59
|
+
trim_x_start: int = -1, trim_x_end: int = -1,
|
|
60
|
+
trim_y_start: int = -1, trim_y_end: int = -1,
|
|
61
|
+
save_plots: bool = True,
|
|
62
|
+
fig_format: str = 'png'):
|
|
63
|
+
self.overscan_start_column = overscan_start_column
|
|
64
|
+
self.overscan_left_start = overscan_left_start
|
|
65
|
+
self.overscan_left_end = overscan_left_end
|
|
66
|
+
self.overscan_right_start = overscan_right_start
|
|
67
|
+
self.overscan_right_end = overscan_right_end
|
|
68
|
+
self.overscan_top_start = overscan_top_start
|
|
69
|
+
self.overscan_top_end = overscan_top_end
|
|
70
|
+
self.overscan_bottom_start = overscan_bottom_start
|
|
71
|
+
self.overscan_bottom_end = overscan_bottom_end
|
|
72
|
+
self.overscan_method = overscan_method
|
|
73
|
+
self.overscan_smooth_window = overscan_smooth_window
|
|
74
|
+
self.overscan_poly_order = overscan_poly_order
|
|
75
|
+
self.overscan_poly_type = overscan_poly_type
|
|
76
|
+
self.trim_x_start = trim_x_start
|
|
77
|
+
self.trim_x_end = trim_x_end
|
|
78
|
+
self.trim_y_start = trim_y_start
|
|
79
|
+
self.trim_y_end = trim_y_end
|
|
80
|
+
self.save_plots = save_plots
|
|
81
|
+
self.fig_format = fig_format
|
|
82
|
+
self.has_overscan_config = False
|
|
83
|
+
|
|
84
|
+
self.corrector = self._create_corrector()
|
|
85
|
+
|
|
86
|
+
# 根据配置创建过扫描校正器。
|
|
87
|
+
def _create_corrector(self) -> OverscanCorrector:
|
|
88
|
+
"""Create OverscanCorrector with configuration."""
|
|
89
|
+
overscan_config = {}
|
|
90
|
+
|
|
91
|
+
if self.overscan_start_column > 0:
|
|
92
|
+
overscan_config['right'] = (self.overscan_start_column - 1, None)
|
|
93
|
+
overscan_config['left'] = None
|
|
94
|
+
overscan_config['top'] = None
|
|
95
|
+
overscan_config['bottom'] = None
|
|
96
|
+
self.has_overscan_config = True
|
|
97
|
+
logger.info(f"Simplified overscan configuration: start_column={self.overscan_start_column} (1-based)")
|
|
98
|
+
else:
|
|
99
|
+
regions = {
|
|
100
|
+
'left': (self.overscan_left_start, self.overscan_left_end),
|
|
101
|
+
'right': (self.overscan_right_start, self.overscan_right_end),
|
|
102
|
+
'top': (self.overscan_top_start, self.overscan_top_end),
|
|
103
|
+
'bottom': (self.overscan_bottom_start, self.overscan_bottom_end)
|
|
104
|
+
}
|
|
105
|
+
for region, (start, end) in regions.items():
|
|
106
|
+
if start >= 0 and end > start:
|
|
107
|
+
overscan_config[region] = (start, end)
|
|
108
|
+
self.has_overscan_config = True
|
|
109
|
+
else:
|
|
110
|
+
overscan_config[region] = None
|
|
111
|
+
|
|
112
|
+
logger.info(f"Overscan configuration: {overscan_config}")
|
|
113
|
+
return OverscanCorrector(overscan_config)
|
|
114
|
+
|
|
115
|
+
# 校正单幅图像的过扫描信号。
|
|
116
|
+
def correct_image(self, image: np.ndarray, trim: bool = True) -> np.ndarray:
|
|
117
|
+
"""Correct single image for overscan."""
|
|
118
|
+
has_overscan = self.has_overscan_config
|
|
119
|
+
|
|
120
|
+
if not has_overscan:
|
|
121
|
+
logger.info("No overscan region configured, skipping overscan correction")
|
|
122
|
+
return image
|
|
123
|
+
|
|
124
|
+
overscan_bias = self.corrector.estimate_overscan_bias(
|
|
125
|
+
image, method=self.overscan_method,
|
|
126
|
+
smooth_window=self.overscan_smooth_window if self.overscan_smooth_window > 0 else None,
|
|
127
|
+
poly_order=self.overscan_poly_order,
|
|
128
|
+
poly_type=self.overscan_poly_type,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
corrected = self.corrector.apply_overscan_correction(image, overscan_bias)
|
|
132
|
+
|
|
133
|
+
if trim and has_overscan:
|
|
134
|
+
corrected = self.corrector.trim_image(corrected)
|
|
135
|
+
|
|
136
|
+
corrected = self._apply_user_crop(corrected)
|
|
137
|
+
return corrected
|
|
138
|
+
|
|
139
|
+
# 按用户边界裁剪图像区域。
|
|
140
|
+
def _apply_user_crop(self, image: np.ndarray) -> np.ndarray:
|
|
141
|
+
"""Apply optional crop bounds from config using DS9 1-based indexing."""
|
|
142
|
+
rows, cols = image.shape
|
|
143
|
+
|
|
144
|
+
x1 = self.trim_x_start
|
|
145
|
+
x2 = self.trim_x_end
|
|
146
|
+
y1 = self.trim_y_start
|
|
147
|
+
y2 = self.trim_y_end
|
|
148
|
+
|
|
149
|
+
if min(x1, x2, y1, y2) < 1:
|
|
150
|
+
return image
|
|
151
|
+
|
|
152
|
+
x1p = max(0, x1 - 1)
|
|
153
|
+
x2p = min(cols, x2)
|
|
154
|
+
y1p = max(0, y1 - 1)
|
|
155
|
+
y2p = min(rows, y2)
|
|
156
|
+
|
|
157
|
+
if x2p <= x1p or y2p <= y1p:
|
|
158
|
+
logger.warning(
|
|
159
|
+
f"Invalid crop range (DS9): x=[{x1},{x2}], y=[{y1},{y2}]. Skip cropping."
|
|
160
|
+
)
|
|
161
|
+
return image
|
|
162
|
+
|
|
163
|
+
cropped = image[y1p:y2p, x1p:x2p]
|
|
164
|
+
logger.info(
|
|
165
|
+
f"Applied user crop (DS9): x=[{x1},{x2}], y=[{y1},{y2}] -> shape {cropped.shape}"
|
|
166
|
+
)
|
|
167
|
+
return cropped
|
|
168
|
+
|
|
169
|
+
# 校正FITS文件并保存图像与诊断图。
|
|
170
|
+
def correct_file(self, input_path: str, output_path: Optional[str] = None) -> str:
|
|
171
|
+
"""Correct single FITS file for overscan."""
|
|
172
|
+
logger.info(f"Reading raw image: {input_path}")
|
|
173
|
+
raw_image, header = read_fits_image(input_path)
|
|
174
|
+
|
|
175
|
+
corrected = self.correct_image(raw_image, trim=True)
|
|
176
|
+
|
|
177
|
+
header['OVRSCAN'] = (True, 'Overscan correction completed')
|
|
178
|
+
|
|
179
|
+
if output_path is None:
|
|
180
|
+
output_path = input_path
|
|
181
|
+
|
|
182
|
+
write_fits_image(output_path, corrected, header=header, overwrite=True, dtype='float32')
|
|
183
|
+
logger.info(f"Saved overscan-corrected image: {output_path}")
|
|
184
|
+
|
|
185
|
+
if self.save_plots and output_path != input_path:
|
|
186
|
+
out_dir = Path(output_path).parent
|
|
187
|
+
stem = Path(input_path).stem
|
|
188
|
+
|
|
189
|
+
raw_plot_file = out_dir / f'{stem}_raw.{self.fig_format}'
|
|
190
|
+
plot_2d_image_to_file(raw_image, str(raw_plot_file), "Raw Input Image")
|
|
191
|
+
|
|
192
|
+
plot_file = out_dir / f'{stem}_overscan_corrected.{self.fig_format}'
|
|
193
|
+
if self.overscan_start_column > 0:
|
|
194
|
+
original_width = raw_image.shape[1]
|
|
195
|
+
plot_overscan_corrected_image(
|
|
196
|
+
corrected, str(plot_file), "Overscan Corrected Image",
|
|
197
|
+
overscan_start_col=self.overscan_start_column,
|
|
198
|
+
original_width=original_width,
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
plot_2d_image_to_file(corrected, str(plot_file), "Overscan Corrected Image")
|
|
202
|
+
|
|
203
|
+
if hasattr(self.corrector, 'overscan_profile') and self.corrector.overscan_profile:
|
|
204
|
+
from specproc.plotting.spectra_plotter import plot_overscan_profile
|
|
205
|
+
profile_file = out_dir / f'{stem}_overscan_profile.{self.fig_format}'
|
|
206
|
+
plot_overscan_profile(
|
|
207
|
+
self.corrector.overscan_profile,
|
|
208
|
+
str(profile_file),
|
|
209
|
+
title=f"Overscan Profile - {stem}",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return output_path
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# 批量执行图像过扫描校正。
|
|
216
|
+
def process_overscan_stage(image_filenames: List[str],
|
|
217
|
+
output_dir_base: str,
|
|
218
|
+
overscan_start_column: int = -1,
|
|
219
|
+
overscan_left_start: int = -1, overscan_left_end: int = -1,
|
|
220
|
+
overscan_right_start: int = -1, overscan_right_end: int = -1,
|
|
221
|
+
overscan_top_start: int = -1, overscan_top_end: int = -1,
|
|
222
|
+
overscan_bottom_start: int = -1, overscan_bottom_end: int = -1,
|
|
223
|
+
overscan_method: str = 'mean_only',
|
|
224
|
+
overscan_smooth_window: int = -1,
|
|
225
|
+
overscan_poly_order: int = 3,
|
|
226
|
+
overscan_poly_type: str = 'legendre',
|
|
227
|
+
trim_x_start: int = -1, trim_x_end: int = -1,
|
|
228
|
+
trim_y_start: int = -1, trim_y_end: int = -1,
|
|
229
|
+
save_plots: bool = True,
|
|
230
|
+
fig_format: str = 'png') -> List[str]:
|
|
231
|
+
"""Execute overscan correction for multiple images."""
|
|
232
|
+
stage = OverscanCorrectionStage(
|
|
233
|
+
overscan_start_column=overscan_start_column,
|
|
234
|
+
overscan_left_start=overscan_left_start, overscan_left_end=overscan_left_end,
|
|
235
|
+
overscan_right_start=overscan_right_start, overscan_right_end=overscan_right_end,
|
|
236
|
+
overscan_top_start=overscan_top_start, overscan_top_end=overscan_top_end,
|
|
237
|
+
overscan_bottom_start=overscan_bottom_start, overscan_bottom_end=overscan_bottom_end,
|
|
238
|
+
overscan_method=overscan_method,
|
|
239
|
+
overscan_smooth_window=overscan_smooth_window,
|
|
240
|
+
overscan_poly_order=overscan_poly_order,
|
|
241
|
+
overscan_poly_type=overscan_poly_type,
|
|
242
|
+
trim_x_start=trim_x_start, trim_x_end=trim_x_end,
|
|
243
|
+
trim_y_start=trim_y_start, trim_y_end=trim_y_end,
|
|
244
|
+
save_plots=save_plots,
|
|
245
|
+
fig_format=fig_format
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if not stage.has_overscan_config:
|
|
249
|
+
logger.info("No overscan region configured; skipping overscan stage and using original files.")
|
|
250
|
+
return list(image_filenames)
|
|
251
|
+
|
|
252
|
+
logger.info(
|
|
253
|
+
"Overscan configuration summary: "
|
|
254
|
+
f"start_column={stage.overscan_start_column}, method={stage.overscan_method}, "
|
|
255
|
+
f"smooth_window={stage.overscan_smooth_window}, poly_type={stage.overscan_poly_type}, "
|
|
256
|
+
f"poly_order={stage.overscan_poly_order}"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
overscan_path = Path(output_dir_base) / 'step1_basic' / 'overscan_corrected'
|
|
260
|
+
overscan_path.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
|
|
262
|
+
corrected_files = []
|
|
263
|
+
for filename in image_filenames:
|
|
264
|
+
try:
|
|
265
|
+
input_path = Path(filename)
|
|
266
|
+
output_file = overscan_path / input_path.name
|
|
267
|
+
corrected_file = stage.correct_file(str(filename), str(output_file))
|
|
268
|
+
corrected_files.append(corrected_file)
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.error(f"Error correcting {filename}: {e}")
|
|
271
|
+
corrected_files.append(filename)
|
|
272
|
+
|
|
273
|
+
logger.info(f"✓ Overscan correction complete: {len(corrected_files)} images processed")
|
|
274
|
+
return corrected_files
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# Sub-step 2: Bias correction
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
# 负责偏置帧合并与图像扣偏。
|
|
282
|
+
class BiasCorrector:
|
|
283
|
+
"""Handles bias frame combination and correction."""
|
|
284
|
+
|
|
285
|
+
# 保存过扫描区域及图像裁剪参数。
|
|
286
|
+
def __init__(self,
|
|
287
|
+
combine_method: str = 'median',
|
|
288
|
+
combine_sigma: float = 3.0,
|
|
289
|
+
save_plots: bool = True,
|
|
290
|
+
fig_format: str = 'png'):
|
|
291
|
+
self.combine_method = combine_method
|
|
292
|
+
self.combine_sigma = combine_sigma
|
|
293
|
+
self.save_plots = save_plots
|
|
294
|
+
self.fig_format = fig_format
|
|
295
|
+
self.master_bias = None
|
|
296
|
+
self.bias_uncertainty = None
|
|
297
|
+
|
|
298
|
+
# 合并偏置帧并估算主偏置与误差。
|
|
299
|
+
def combine_bias_frames(self, bias_filenames: List[str]) -> Tuple[np.ndarray, np.ndarray]:
|
|
300
|
+
"""Combine multiple bias frames to create master bias."""
|
|
301
|
+
logger.info(f"Combining {len(bias_filenames)} bias frames...")
|
|
302
|
+
|
|
303
|
+
if not bias_filenames:
|
|
304
|
+
logger.warning("No bias frames provided")
|
|
305
|
+
return None, None
|
|
306
|
+
|
|
307
|
+
bias_images = []
|
|
308
|
+
for filename in bias_filenames:
|
|
309
|
+
try:
|
|
310
|
+
img, _ = read_fits_image(filename)
|
|
311
|
+
bias_images.append(img)
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.warning(f"Failed to read bias frame {filename}: {e}")
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
if not bias_images:
|
|
317
|
+
raise RuntimeError("Could not read any bias frames")
|
|
318
|
+
|
|
319
|
+
images_array = np.array(bias_images)
|
|
320
|
+
logger.info(f"Successfully read {len(images_array)} bias frames")
|
|
321
|
+
|
|
322
|
+
master_bias, uncertainty = combine_images(images_array, method=self.combine_method, sigma=self.combine_sigma)
|
|
323
|
+
|
|
324
|
+
self.master_bias = master_bias
|
|
325
|
+
self.bias_uncertainty = uncertainty
|
|
326
|
+
|
|
327
|
+
logger.info(f"Master bias created: shape {master_bias.shape}, mean={np.mean(master_bias):.1f}")
|
|
328
|
+
return master_bias, uncertainty
|
|
329
|
+
|
|
330
|
+
# 从图像中扣除主偏置图。
|
|
331
|
+
def apply_bias_correction(self, science_image: np.ndarray,
|
|
332
|
+
master_bias: Optional[np.ndarray] = None) -> np.ndarray:
|
|
333
|
+
"""Apply bias correction to a science image."""
|
|
334
|
+
bias = master_bias if master_bias is not None else self.master_bias
|
|
335
|
+
|
|
336
|
+
if bias is None:
|
|
337
|
+
logger.warning("No master bias available, returning uncorrected image")
|
|
338
|
+
return science_image
|
|
339
|
+
|
|
340
|
+
if science_image.shape != bias.shape:
|
|
341
|
+
raise ValueError(
|
|
342
|
+
f"Image shape {science_image.shape} does not match bias shape {bias.shape}"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
corrected = science_image.astype(float) - bias.astype(float)
|
|
346
|
+
logger.debug(f"Bias corrected image: mean={np.mean(corrected):.1f}")
|
|
347
|
+
return corrected
|
|
348
|
+
|
|
349
|
+
# 保存主偏置图及可选诊断图。
|
|
350
|
+
def save_master_bias(self, output_path: str):
|
|
351
|
+
"""Save master bias to FITS file."""
|
|
352
|
+
if self.master_bias is None:
|
|
353
|
+
raise RuntimeError("No master bias to save")
|
|
354
|
+
|
|
355
|
+
header = {
|
|
356
|
+
'EXTNAME': 'MASTER BIAS',
|
|
357
|
+
'ORIGIN': 'SpecProc',
|
|
358
|
+
'BIASCR': (True, 'Master bias created'),
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
write_fits_image(output_path, self.master_bias, header=header, dtype='float32')
|
|
362
|
+
logger.info(f"Saved master bias to {output_path}")
|
|
363
|
+
|
|
364
|
+
if self.save_plots:
|
|
365
|
+
out_dir = Path(output_path).parent
|
|
366
|
+
plot_file = out_dir / f'master_bias.{self.fig_format}'
|
|
367
|
+
plot_2d_image_to_file(self.master_bias, str(plot_file), "Master Bias Frame")
|
|
368
|
+
|
|
369
|
+
# 从文件载入预先计算的主偏置图。
|
|
370
|
+
def load_master_bias(self, filepath: str):
|
|
371
|
+
"""Load pre-computed master bias from file."""
|
|
372
|
+
self.master_bias, header = read_fits_image(filepath)
|
|
373
|
+
logger.info(f"Loaded master bias from {filepath}")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# 执行偏置合并与校正阶段。
|
|
377
|
+
def process_bias_stage(bias_filenames: List[str],
|
|
378
|
+
output_dir_base: str,
|
|
379
|
+
science_file: str = None,
|
|
380
|
+
combine_method: str = 'median',
|
|
381
|
+
combine_sigma: float = 3.0,
|
|
382
|
+
save_plots: bool = True,
|
|
383
|
+
fig_format: str = 'png') -> Tuple[np.ndarray, str]:
|
|
384
|
+
"""Execute bias correction stage."""
|
|
385
|
+
corrector = BiasCorrector(
|
|
386
|
+
combine_method=combine_method,
|
|
387
|
+
combine_sigma=combine_sigma,
|
|
388
|
+
save_plots=save_plots,
|
|
389
|
+
fig_format=fig_format
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
master_bias, uncertainty = corrector.combine_bias_frames(bias_filenames)
|
|
393
|
+
|
|
394
|
+
output_path = Path(output_dir_base) / 'step1_basic' / 'bias_subtracted'
|
|
395
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
396
|
+
master_bias_path = output_path / 'master_bias.fits'
|
|
397
|
+
corrector.save_master_bias(str(master_bias_path))
|
|
398
|
+
|
|
399
|
+
if science_file is not None:
|
|
400
|
+
science_image, science_header = read_fits_image(science_file)
|
|
401
|
+
logger.info(f"Applying bias correction to science image: {science_file}")
|
|
402
|
+
|
|
403
|
+
bias_corrected = corrector.apply_bias_correction(science_image, master_bias)
|
|
404
|
+
|
|
405
|
+
output_science_path = output_path / Path(science_file).name
|
|
406
|
+
science_header['BIASCOR'] = (True, 'Bias correction applied')
|
|
407
|
+
write_fits_image(str(output_science_path), bias_corrected,
|
|
408
|
+
header=science_header, dtype='float32')
|
|
409
|
+
logger.info(f"Saved bias-corrected science image to {output_science_path}")
|
|
410
|
+
|
|
411
|
+
if save_plots:
|
|
412
|
+
plot_file = output_path / f'{Path(science_file).stem}_bias_corrected.{fig_format}'
|
|
413
|
+
plot_2d_image_to_file(bias_corrected, str(plot_file), "Bias Corrected Science Image")
|
|
414
|
+
|
|
415
|
+
return master_bias, str(master_bias_path)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
# Sub-step 3: Cosmic-ray removal
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
# 批量检测并去除图像宇宙射线。
|
|
423
|
+
def process_cosmic_stage(image_filenames: List[str],
|
|
424
|
+
output_dir_base: str,
|
|
425
|
+
output_subdir: str = 'step1_basic/cosmic_corrected',
|
|
426
|
+
cosmic_sigclip: float = 5.0,
|
|
427
|
+
cosmic_objlim: float = 5.0,
|
|
428
|
+
cosmic_gain: float = 1.0,
|
|
429
|
+
cosmic_readnoise: float = 5.0,
|
|
430
|
+
save_plots: bool = True,
|
|
431
|
+
fig_format: str = 'png') -> List[str]:
|
|
432
|
+
"""Execute cosmic ray correction stage for a list of images."""
|
|
433
|
+
if astroscrappy is None:
|
|
434
|
+
logger.error("`astroscrappy` package not found. Skipping cosmic ray removal.")
|
|
435
|
+
logger.error("Please install it via: pip install astroscrappy")
|
|
436
|
+
return image_filenames
|
|
437
|
+
|
|
438
|
+
logger.info("=" * 60)
|
|
439
|
+
logger.info("STEP 1: COSMIC RAY REMOVAL")
|
|
440
|
+
logger.info("=" * 60)
|
|
441
|
+
|
|
442
|
+
cosmic_path = Path(output_dir_base) / output_subdir
|
|
443
|
+
cosmic_path.mkdir(parents=True, exist_ok=True)
|
|
444
|
+
|
|
445
|
+
corrected_files = []
|
|
446
|
+
|
|
447
|
+
for filename in image_filenames:
|
|
448
|
+
try:
|
|
449
|
+
input_path = Path(filename)
|
|
450
|
+
output_file = cosmic_path / input_path.name
|
|
451
|
+
|
|
452
|
+
image, header = read_fits_image(str(filename))
|
|
453
|
+
|
|
454
|
+
logger.info(f"Using L.A.Cosmic with sigclip={cosmic_sigclip}, objlim={cosmic_objlim}")
|
|
455
|
+
|
|
456
|
+
# Prepare arguments for astroscrappy. If gain/readnoise are 0,
|
|
457
|
+
# do not pass them, allowing astroscrappy to auto-detect from header.
|
|
458
|
+
lacosmic_kwargs = {
|
|
459
|
+
'sigclip': cosmic_sigclip,
|
|
460
|
+
'objlim': cosmic_objlim,
|
|
461
|
+
'cleantype': 'medmask'
|
|
462
|
+
}
|
|
463
|
+
if cosmic_gain > 0:
|
|
464
|
+
lacosmic_kwargs['gain'] = cosmic_gain
|
|
465
|
+
if cosmic_readnoise > 0:
|
|
466
|
+
lacosmic_kwargs['readnoise'] = cosmic_readnoise
|
|
467
|
+
|
|
468
|
+
mask, corrected = astroscrappy.detect_cosmics(image, **lacosmic_kwargs)
|
|
469
|
+
|
|
470
|
+
header['COSMICR'] = (True, 'Cosmic ray removal completed (L.A.Cosmic)')
|
|
471
|
+
write_fits_image(str(output_file), corrected, header=header,
|
|
472
|
+
overwrite=True, dtype='float32')
|
|
473
|
+
|
|
474
|
+
if save_plots:
|
|
475
|
+
plot_file = cosmic_path / f"{input_path.stem}_cosmic_corrected.{fig_format}"
|
|
476
|
+
plot_2d_image_to_file(corrected, str(plot_file), "Cosmic Corrected Image")
|
|
477
|
+
|
|
478
|
+
marked_file = cosmic_path / f"{input_path.stem}_cosmic_marked.{fig_format}"
|
|
479
|
+
fig, ax = plt.subplots(figsize=(10, 8))
|
|
480
|
+
vmin = np.percentile(image, 1)
|
|
481
|
+
vmax = np.percentile(image, 99)
|
|
482
|
+
ax.imshow(image, cmap='viridis', origin='lower', vmin=vmin, vmax=vmax)
|
|
483
|
+
ys, xs = np.where(mask)
|
|
484
|
+
if xs.size > 0:
|
|
485
|
+
ax.scatter(
|
|
486
|
+
xs, ys, s=10,
|
|
487
|
+
facecolors='none', edgecolors='red',
|
|
488
|
+
linewidths=0.6, alpha=0.9,
|
|
489
|
+
label=f'Cosmic pixels: {xs.size}',
|
|
490
|
+
)
|
|
491
|
+
ax.legend(loc='upper right')
|
|
492
|
+
ax.set_title('Detected Cosmic Rays (marked in red)')
|
|
493
|
+
ax.set_xlabel('Column')
|
|
494
|
+
ax.set_ylabel('Row')
|
|
495
|
+
fig.tight_layout()
|
|
496
|
+
fig.savefig(str(marked_file), dpi=150)
|
|
497
|
+
plt.close(fig)
|
|
498
|
+
|
|
499
|
+
corrected_files.append(str(output_file))
|
|
500
|
+
logger.info(
|
|
501
|
+
f"Cosmic correction: {filename} -> {output_file} ({np.sum(mask)} pixels fixed)"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
except Exception as e:
|
|
505
|
+
logger.error(f"Error cosmic-correcting {filename}: {e}")
|
|
506
|
+
corrected_files.append(str(filename))
|
|
507
|
+
|
|
508
|
+
logger.info(f"✓ Cosmic correction complete: {len(corrected_files)} images processed")
|
|
509
|
+
return corrected_files
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# ---------------------------------------------------------------------------
|
|
513
|
+
# Unified Step-1 entry point
|
|
514
|
+
# ---------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
# 按配置依次执行启用的基础校正。
|
|
517
|
+
def process_basic_reduction_stage(science_filenames: List[str],
|
|
518
|
+
output_dir_base: str,
|
|
519
|
+
bias_filenames: Optional[List[str]] = None,
|
|
520
|
+
do_overscan: bool = True,
|
|
521
|
+
do_bias: bool = True,
|
|
522
|
+
do_cosmic: bool = True,
|
|
523
|
+
overscan_kwargs: dict = None,
|
|
524
|
+
bias_kwargs: dict = None,
|
|
525
|
+
cosmic_kwargs: dict = None) -> Tuple[List[str], Optional[str]]:
|
|
526
|
+
"""
|
|
527
|
+
Run all enabled sub-steps of Step 1 (basic pre-processing) in order.
|
|
528
|
+
|
|
529
|
+
Parameters
|
|
530
|
+
----------
|
|
531
|
+
science_filenames : Raw (or partially-corrected) science FITS file list.
|
|
532
|
+
output_dir_base : Base directory for outputs.
|
|
533
|
+
bias_filenames : Bias frames for master-bias creation (needed if do_bias=True).
|
|
534
|
+
do_overscan : Run overscan correction.
|
|
535
|
+
do_bias : Run bias subtraction.
|
|
536
|
+
do_cosmic : Run cosmic-ray removal.
|
|
537
|
+
|
|
538
|
+
Returns
|
|
539
|
+
-------
|
|
540
|
+
(processed_science_files, master_bias_path)
|
|
541
|
+
master_bias_path is None when do_bias is False.
|
|
542
|
+
"""
|
|
543
|
+
logger.info("=" * 60)
|
|
544
|
+
logger.info("STEP 1: BASIC PRE-PROCESSING")
|
|
545
|
+
logger.info("=" * 60)
|
|
546
|
+
|
|
547
|
+
t_start_step1 = time.time()
|
|
548
|
+
|
|
549
|
+
overscan_kwargs = overscan_kwargs or {}
|
|
550
|
+
bias_kwargs = bias_kwargs or {}
|
|
551
|
+
cosmic_kwargs = cosmic_kwargs or {}
|
|
552
|
+
|
|
553
|
+
current_files = list(science_filenames)
|
|
554
|
+
master_bias_path: Optional[str] = None
|
|
555
|
+
|
|
556
|
+
if do_overscan:
|
|
557
|
+
t0_over = time.time()
|
|
558
|
+
current_files = process_overscan_stage(current_files, output_dir_base, **overscan_kwargs)
|
|
559
|
+
logger.info(f" [Timing] Overscan correction took {time.time() - t0_over:.2f} seconds.")
|
|
560
|
+
|
|
561
|
+
if do_bias and bias_filenames:
|
|
562
|
+
t0_bias = time.time()
|
|
563
|
+
# Apply bias correction to each science file individually.
|
|
564
|
+
processed = []
|
|
565
|
+
first = True
|
|
566
|
+
for sci_file in current_files:
|
|
567
|
+
if first:
|
|
568
|
+
_, master_bias_path = process_bias_stage(bias_filenames, output_dir_base, sci_file, **bias_kwargs)
|
|
569
|
+
first = False
|
|
570
|
+
else:
|
|
571
|
+
# Reuse the master bias already created; just apply it.
|
|
572
|
+
corrector = BiasCorrector(**bias_kwargs)
|
|
573
|
+
corrector.load_master_bias(master_bias_path)
|
|
574
|
+
sci_img, sci_hdr = read_fits_image(sci_file)
|
|
575
|
+
corrected = corrector.apply_bias_correction(sci_img)
|
|
576
|
+
out_dir = Path(output_dir_base) / 'step1_basic' / 'bias_subtracted'
|
|
577
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
578
|
+
out_file = str(out_dir / Path(sci_file).name)
|
|
579
|
+
sci_hdr['BIASCOR'] = (True, 'Bias correction applied')
|
|
580
|
+
write_fits_image(out_file, corrected, header=sci_hdr, dtype='float32')
|
|
581
|
+
processed.append(out_file)
|
|
582
|
+
continue
|
|
583
|
+
# First file: path was written inside process_bias_stage; reconstruct it.
|
|
584
|
+
out_dir = Path(output_dir_base) / 'step1_basic' / 'bias_subtracted'
|
|
585
|
+
processed.append(str(out_dir / Path(sci_file).name))
|
|
586
|
+
current_files = processed
|
|
587
|
+
logger.info(f" [Timing] Bias correction took {time.time() - t0_bias:.2f} seconds.")
|
|
588
|
+
|
|
589
|
+
if do_cosmic:
|
|
590
|
+
t0_cosmic = time.time()
|
|
591
|
+
current_files = process_cosmic_stage(current_files, output_dir_base, **cosmic_kwargs)
|
|
592
|
+
logger.info(f" [Timing] Cosmic ray removal took {time.time() - t0_cosmic:.2f} seconds.")
|
|
593
|
+
|
|
594
|
+
t_end_step1 = time.time()
|
|
595
|
+
total_time = t_end_step1 - t_start_step1
|
|
596
|
+
|
|
597
|
+
# Calculate total output file size
|
|
598
|
+
total_size_bytes = 0
|
|
599
|
+
for f in current_files:
|
|
600
|
+
p = Path(f)
|
|
601
|
+
if p.exists():
|
|
602
|
+
total_size_bytes += p.stat().st_size
|
|
603
|
+
if master_bias_path:
|
|
604
|
+
p_bias = Path(master_bias_path)
|
|
605
|
+
if p_bias.exists():
|
|
606
|
+
total_size_bytes += p_bias.stat().st_size
|
|
607
|
+
|
|
608
|
+
total_size_mb = total_size_bytes / (1024 * 1024)
|
|
609
|
+
speed_mb_s = total_size_mb / total_time if total_time > 0 else 0
|
|
610
|
+
|
|
611
|
+
logger.info(f"✓ Step 1 basic reduction complete: {len(current_files)} science files")
|
|
612
|
+
logger.info(f" [Stats] Total Output Size: {total_size_mb:.2f} MB")
|
|
613
|
+
logger.info(f" [Stats] Total Duration: {total_time:.2f} s")
|
|
614
|
+
logger.info(f" [Stats] Average Processing Speed: {speed_mb_s:.2f} MB/s")
|
|
615
|
+
return current_files, master_bias_path
|