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.
Files changed (61) hide show
  1. specproc/__init__.py +9 -0
  2. specproc/calib_data/linelists/FeAr.dat +375 -0
  3. specproc/calib_data/linelists/README +79 -0
  4. specproc/calib_data/linelists/airvac.cl +61 -0
  5. specproc/calib_data/linelists/argon.dat +161 -0
  6. specproc/calib_data/linelists/ctiohear.dat +106 -0
  7. specproc/calib_data/linelists/ctiohenear.dat +146 -0
  8. specproc/calib_data/linelists/cuar.dat +459 -0
  9. specproc/calib_data/linelists/henear.dat +93 -0
  10. specproc/calib_data/linelists/henearhres.dat +300 -0
  11. specproc/calib_data/linelists/idhenear.dat +127 -0
  12. specproc/calib_data/linelists/krypton.dat +109 -0
  13. specproc/calib_data/linelists/lowskylines.dat +577 -0
  14. specproc/calib_data/linelists/ohlines.dat +268 -0
  15. specproc/calib_data/linelists/skylines.dat +1385 -0
  16. specproc/calib_data/linelists/thar-noao.dat +4169 -0
  17. specproc/calib_data/linelists/thar.dat +3057 -0
  18. specproc/calib_data/linelists/thorium.dat +3057 -0
  19. specproc/calib_data/linelists/vacidhenear.dat +127 -0
  20. specproc/calib_data/linelists/vacthar.dat +3057 -0
  21. specproc/calib_data/linelists/vacthorium.dat +3057 -0
  22. specproc/calib_data/linelists/xenon.dat +89 -0
  23. specproc/calib_data/telescopes/lijiang24hrs/lijiang24_hrs_thar_lines.csv +550 -0
  24. 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
  25. specproc/calib_data/telescopes/xinglong216bfosc//346/212/245/345/221/212/345/233/276.docx +0 -0
  26. 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
  27. specproc/calib_data/telescopes/xinglong216hrs/wlcalib_20211123011_A.png +0 -0
  28. specproc/calib_data/telescopes/xinglong216hrs/xinglong216_thar_lines.csv +517 -0
  29. specproc/config/__init__.py +1 -0
  30. specproc/config/config_manager.py +431 -0
  31. specproc/core/__init__.py +1 -0
  32. specproc/core/basic_reduction.py +615 -0
  33. specproc/core/data_structures.py +525 -0
  34. specproc/core/de_blazing.py +210 -0
  35. specproc/core/extraction.py +375 -0
  36. specproc/core/flat_correction.py +1255 -0
  37. specproc/core/line_identification.py +302 -0
  38. specproc/core/order_stitching.py +187 -0
  39. specproc/core/order_tracing.py +1587 -0
  40. specproc/core/processing_pipeline.py +1183 -0
  41. specproc/core/scattered_light.py +654 -0
  42. specproc/core/slit_pipeline.py +762 -0
  43. specproc/core/wave_calibration.py +961 -0
  44. specproc/gui/__init__.py +1 -0
  45. specproc/gui/line_identifier.py +731 -0
  46. specproc/gui/main_window.py +2315 -0
  47. specproc/gui/settings_dialog.py +946 -0
  48. specproc/main.py +388 -0
  49. specproc/plotting/__init__.py +1 -0
  50. specproc/plotting/spectra_plotter.py +483 -0
  51. specproc/utils/__init__.py +1 -0
  52. specproc/utils/airvac.py +123 -0
  53. specproc/utils/fits_io.py +269 -0
  54. specproc/utils/image_processing.py +426 -0
  55. specproc/utils/overscan.py +600 -0
  56. specproc-1.0.0.dist-info/METADATA +668 -0
  57. specproc-1.0.0.dist-info/RECORD +61 -0
  58. specproc-1.0.0.dist-info/WHEEL +5 -0
  59. specproc-1.0.0.dist-info/entry_points.txt +5 -0
  60. specproc-1.0.0.dist-info/licenses/LICENSE +674 -0
  61. specproc-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1183 @@
1
+ """
2
+ Main processing pipeline orchestrator.
3
+
4
+ Coordinates all processing stages and manages workflow execution.
5
+ """
6
+ # 负责协调各阶段并跟踪光谱归约流程。
7
+
8
+ import logging
9
+ import pickle
10
+ from pathlib import Path
11
+ from typing import List, Callable, Optional, Union, Dict
12
+ import numpy as np
13
+ from specproc.config.config_manager import ConfigManager
14
+ from specproc.core.data_structures import ProcessingState, WaveCalib, SpectraSet
15
+ from specproc.core.basic_reduction import (
16
+ process_overscan_stage,
17
+ process_bias_stage,
18
+ process_cosmic_stage,
19
+ )
20
+ from specproc.core.order_tracing import process_order_tracing_stage
21
+ from specproc.core.flat_correction import process_flat_correction_stage
22
+ from specproc.core.wave_calibration import WavelengthCalibrator, process_wavelength_stage
23
+ from specproc.core.scattered_light import process_background_stage
24
+ from specproc.core.extraction import process_extraction_stage
25
+ from specproc.core.de_blazing import process_de_blazing_stage
26
+ from specproc.core.order_stitching import process_order_stitching_stage
27
+ from specproc.utils.fits_io import read_fits_image, write_fits_image
28
+ from specproc.plotting.spectra_plotter import plot_2d_image_to_file
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ # 管理光谱数据归约的各处理阶段。
34
+ class ProcessingPipeline:
35
+ """Main spectral reduction pipeline."""
36
+
37
+ # 初始化配置、状态与进度回调。
38
+ def __init__(self, config: ConfigManager):
39
+ """
40
+ Initialize processing pipeline.
41
+
42
+ Args:
43
+ config: Configuration manager
44
+ """
45
+ self.config = config
46
+ self.state = ProcessingState()
47
+ self.progress_callback = None
48
+
49
+ # 设置流程进度通知回调。
50
+ def set_progress_callback(self, callback: Callable[[float, str], None]):
51
+ """
52
+ Set callback for progress updates.
53
+
54
+ Args:
55
+ callback: Function taking (progress_fraction, stage_name)
56
+ """
57
+ self.progress_callback = callback
58
+
59
+ # 更新并通知当前处理进度。
60
+ def _report_progress(self, progress: float, stage: str):
61
+ """Report progress to callback."""
62
+ self.state.progress = progress
63
+ self.state.current_stage = stage
64
+ if self.progress_callback:
65
+ self.progress_callback(progress, stage)
66
+ logger.info(f"{stage}: {progress*100:.1f}%")
67
+
68
+ # 执行原始图像过扫描校正。
69
+ def stage_overscan_correction(self, raw_filenames: List[str]) -> List[str]:
70
+ """
71
+ Execute overscan correction stage (STAGE 0 - must be first!).
72
+
73
+ Args:
74
+ raw_filenames: List of raw image paths
75
+
76
+ Returns:
77
+ List of overscan-corrected image paths
78
+ """
79
+ logger.info("=" * 50)
80
+ logger.info("STEP 1: BASIC PRE-PROCESSING (OVERSCAN)")
81
+ logger.info("=" * 50)
82
+
83
+ self._report_progress(0.01, "Overscan Correction")
84
+
85
+ try:
86
+ # --- Extract config for overscan stage ---
87
+ overscan_kwargs = {
88
+ 'overscan_start_column': self.config.get_int('data', 'overscan_start_column', -1),
89
+ 'overscan_method': self.config.get('data', 'overscan_method', 'mean_only'),
90
+ 'overscan_smooth_window': self.config.get_int('data', 'overscan_smooth_window', -1),
91
+ 'overscan_poly_order': self.config.get_int('data', 'overscan_poly_order', 3),
92
+ 'overscan_poly_type': self.config.get('data', 'overscan_poly_type', 'legendre'),
93
+ 'trim_x_start': self.config.get_int('data', 'trim_x_start', -1),
94
+ 'trim_x_end': self.config.get_int('data', 'trim_x_end', -1),
95
+ 'trim_y_start': self.config.get_int('data', 'trim_y_start', -1),
96
+ 'trim_y_end': self.config.get_int('data', 'trim_y_end', -1),
97
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
98
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png')
99
+ }
100
+ corrected_files = process_overscan_stage(
101
+ raw_filenames, self.config.get_output_path(), **overscan_kwargs
102
+ )
103
+
104
+ self._report_progress(0.05, "Overscan Correction Complete")
105
+ logger.info("✓ Overscan correction stage complete")
106
+
107
+ return corrected_files
108
+
109
+ except Exception as e:
110
+ logger.error(f"Error in overscan correction: {e}")
111
+ # Return original files if overscan correction fails
112
+ return raw_filenames
113
+
114
+ # 合并偏置帧并生成主偏置图。
115
+ def stage_bias_correction(self, bias_filenames: List[str]) -> np.ndarray:
116
+ """
117
+ Execute bias correction stage.
118
+
119
+ Note: Bias frames are short exposure (0s), so no cosmic ray correction needed.
120
+ Use mean/median combination directly.
121
+
122
+ Args:
123
+ bias_filenames: List of bias frame paths
124
+
125
+ Returns:
126
+ Master bias image
127
+ """
128
+ logger.info("=" * 50)
129
+ logger.info("STEP 1: BASIC PRE-PROCESSING (BIAS)")
130
+ logger.info("=" * 50)
131
+
132
+ self._report_progress(0.05, "Bias Correction")
133
+
134
+ try:
135
+ # --- Extract config for bias stage ---
136
+ bias_kwargs = {
137
+ 'combine_method': self.config.get('reduce.bias', 'combine_method', 'median'),
138
+ 'combine_sigma': self.config.get_float('reduce.bias', 'combine_sigma', 3.0),
139
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
140
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png'),
141
+ }
142
+ master_bias, bias_file = process_bias_stage(
143
+ bias_filenames, self.config.get_output_path(), **bias_kwargs
144
+ )
145
+
146
+ self.state.bias_frame = master_bias
147
+ self.state.bias_done = True
148
+
149
+ self._report_progress(0.15, "Bias Correction Complete")
150
+ logger.info("✓ Bias correction stage complete")
151
+
152
+ return master_bias
153
+
154
+ except Exception as e:
155
+ logger.error(f"Error in bias correction: {e}")
156
+ raise
157
+
158
+ # 用主偏置图校正并保存图像。
159
+ def _apply_master_bias_to_files(self, input_files: List[str], master_bias: np.ndarray,
160
+ output_subdir: str) -> List[str]:
161
+ """Apply master bias to a list of files and save corrected copies."""
162
+ if not input_files:
163
+ return []
164
+
165
+ output_dir = Path(self.config.get_output_path()) / 'step1_basic' / 'bias_subtracted'
166
+ output_dir.mkdir(parents=True, exist_ok=True)
167
+ save_plots = self.config.get_bool('reduce', 'save_plots', True)
168
+ fig_format = self.config.get('reduce', 'fig_format', 'png')
169
+
170
+ corrected_files = []
171
+ for filename in input_files:
172
+ image, header = read_fits_image(filename)
173
+
174
+ if image.shape != master_bias.shape:
175
+ raise ValueError(
176
+ f"Cannot apply bias to {filename}: image shape {image.shape} "
177
+ f"!= master bias shape {master_bias.shape}"
178
+ )
179
+
180
+ corrected = image.astype(np.float32) - master_bias.astype(np.float32)
181
+ header['BIASCOR'] = (True, 'Bias correction applied')
182
+
183
+ output_file = output_dir / Path(filename).name
184
+ write_fits_image(str(output_file), corrected, header=header, dtype='float32')
185
+
186
+ if save_plots:
187
+ plot_file = output_dir / f"{Path(filename).stem}_bias_corrected.{fig_format}"
188
+ plot_2d_image_to_file(
189
+ corrected,
190
+ str(plot_file),
191
+ f"Bias Corrected {output_subdir.capitalize()} Image"
192
+ )
193
+
194
+ corrected_files.append(str(output_file))
195
+
196
+ logger.info(
197
+ f"Applied master bias to {len(corrected_files)} frame(s) for {output_subdir}; "
198
+ f"saved in {output_dir}"
199
+ )
200
+ return corrected_files
201
+
202
+ # 处理平场并建立光谱孔径。
203
+ def stage_flat_fielding(self, flat_filenames: List[str]) -> tuple:
204
+ """
205
+ Execute flat fielding stage.
206
+
207
+ Note: Flat frames should have cosmic ray correction applied before this stage.
208
+
209
+ Args:
210
+ flat_filenames: List of flat frame paths
211
+
212
+ Returns:
213
+ Tuple of (FlatField, ApertureSet)
214
+ """
215
+ logger.info("=" * 50)
216
+ logger.info("STEP 2: ORDER TRACING")
217
+ logger.info("=" * 50)
218
+
219
+ self._report_progress(0.15, "Flat Fielding")
220
+
221
+ try:
222
+ # --- Extract config for flat / order tracing stage ---
223
+ output_dir_base = self.config.get_output_path()
224
+ flat_kwargs = {
225
+ 'output_dir_base': output_dir_base,
226
+ 'combine_method': self.config.get('reduce.flat', 'combine_method', 'median'),
227
+ 'combine_sigma': self.config.get_float('reduce.bias', 'combine_sigma', 3.0),
228
+ 'mosaic_maxcount': self.config.get_float('reduce.flat', 'mosaic_maxcount', 65535),
229
+ 'snr_threshold': self.config.get_float('reduce.trace', 'snr_threshold', 5.0),
230
+ 'gap_fill_factor': self.config.get_float('reduce.trace', 'gap_fill_factor', 1.6),
231
+ 'min_trace_coverage': self.config.get_float('reduce.trace', 'min_trace_coverage', 0.20),
232
+ 'trace_degree': self.config.get_int('reduce.trace', 'degree', 4),
233
+ 'width_cheb_degree': self.config.get_int('reduce.trace', 'width_cheb_degree', 3),
234
+ 'n_extend_below': self.config.get_int('reduce.trace', 'n_extend_below', 0),
235
+ 'n_extend_above': self.config.get_int('reduce.trace', 'n_extend_above', 0),
236
+ 'aperture_boundary_snr': self.config.get_float('reduce.trace', 'aperture_boundary_snr', 3.0),
237
+ 'gap_fill_factor_interp': self.config.get_float('reduce.trace', 'gap_fill_factor', 1.35),
238
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
239
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png'),
240
+ }
241
+ flat_field, apertures = process_order_tracing_stage(
242
+ flat_filenames, **flat_kwargs
243
+ )
244
+
245
+ self.state.flat_field = flat_field
246
+ self.state.apertures = apertures
247
+ self.state.flat_done = True
248
+ self.state.trace_done = True
249
+
250
+ self._report_progress(0.30, "Flat Fielding Complete")
251
+ logger.info("✓ Flat fielding stage complete")
252
+
253
+ return flat_field, apertures
254
+
255
+ except Exception as e:
256
+ logger.error(f"Error in flat fielding: {e}")
257
+ raise
258
+
259
+ # 从标定帧建立波长定标模型。
260
+ def stage_wavelength_calibration(self, calib_filename: str) -> WaveCalib:
261
+ """
262
+ Execute wavelength calibration stage on calibration frame.
263
+
264
+ Args:
265
+ calib_filename: Path to calibration (ThAr) frame
266
+
267
+ Returns:
268
+ WaveCalib object
269
+ """
270
+ self._report_progress(0.30, "Wavelength Calibration")
271
+
272
+ try:
273
+ logger.info(f"Building wavelength solution from calibration frame: {Path(calib_filename).name}")
274
+ lamp_img, _ = read_fits_image(calib_filename)
275
+ calib_name = Path(calib_filename).stem
276
+
277
+ extract_kwargs = {
278
+ 'optimal_sigma': self.config.get_float('reduce.extract', 'optimal_sigma', 3.0),
279
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
280
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png'),
281
+ }
282
+ lamp_spectra = process_extraction_stage(
283
+ lamp_img, self.state.apertures,
284
+ output_dir_base=self.config.get_output_path(),
285
+ wavelength_calib=None,
286
+ flat_field=self.state.flat_field,
287
+ method_override='optimal',
288
+ output_filename=f'{calib_name}_1D_optimal.fits',
289
+ plot_prefix=f'{calib_name}_1D_optimal',
290
+ **extract_kwargs
291
+ )
292
+
293
+ wave_calib = process_wavelength_stage(
294
+ lamp_spectra=lamp_spectra,
295
+ config=self.config,
296
+ output_dir_base=self.config.get_output_path(),
297
+ lamp_type=self.config.get('telescope.linelist', 'linelist_type', 'ThAr'),
298
+ save_plots=self.config.get_bool('reduce', 'save_plots', True),
299
+ fig_format=self.config.get('reduce', 'fig_format', 'png'),
300
+ )
301
+
302
+ self.state.wavelength_calib = wave_calib
303
+ self.state.wavelength_done = True
304
+
305
+ self._report_progress(0.30, "Wavelength Calibration Complete")
306
+ logger.info(f"✓ Wavelength solution ready: {calib_name}")
307
+
308
+ return wave_calib
309
+
310
+ except Exception as e:
311
+ logger.error(f"Error in wavelength calibration: {e}")
312
+ raise
313
+
314
+ # 为提取光谱应用波长定标。
315
+ def stage_wavelength_calibration_on_spectra(self, spectra_set: SpectraSet,
316
+ wave_calib: WaveCalib,
317
+ science_name: str = 'science',
318
+ method: str = 'optimal') -> SpectraSet:
319
+ """
320
+ Apply wavelength calibration to extracted science spectra.
321
+
322
+ This is the correct order: extract 1D spectra first, then apply wavelength calibration.
323
+ Following the gamse standard pipeline.
324
+
325
+ Args:
326
+ spectra_set: Extracted spectra without wavelength information
327
+ wave_calib: Wavelength calibration model to apply
328
+
329
+ Returns:
330
+ SpectraSet with wavelength-calibrated spectra
331
+ """
332
+ self._report_progress(0.82, "Applying Wavelength Calibration")
333
+
334
+ try:
335
+ # --- Apply Order Offset (Renumbering) ---
336
+ delta_m = getattr(wave_calib, 'delta_m', 0)
337
+ if delta_m != 0:
338
+ spectra_set.shift_orders(delta_m)
339
+
340
+ # Apply wavelength calibration to each extracted spectrum
341
+ calibrator = WavelengthCalibrator()
342
+ calibrator.wave_calib = wave_calib
343
+
344
+ calibrated_spectra = SpectraSet()
345
+
346
+ for spectrum in spectra_set.spectra.values():
347
+ # Create pixel array
348
+ n_pixels = len(spectrum.flux)
349
+ pixel_array = np.arange(n_pixels)
350
+
351
+ # Apply wavelength calibration
352
+ wavelengths = calibrator.apply_wavelength_calibration(
353
+ spectrum.flux, pixel_array, aperture_y=float(spectrum.aperture)
354
+ )
355
+
356
+ # Update spectrum with wavelengths
357
+ calibrated_spectrum = spectrum.copy()
358
+ calibrated_spectrum.wavelength = wavelengths
359
+
360
+ calibrated_spectra.add_spectrum(calibrated_spectrum)
361
+
362
+ # === 将波长定标后的科学光谱独立保存到 Step 7 对应目录 ===
363
+ if self.config.get_bool('reduce.save_intermediate', 'save_wlcalib', True):
364
+ out_dir = Path(self.config.get_output_path()) / 'step7_wavelength'
365
+ out_dir.mkdir(parents=True, exist_ok=True)
366
+
367
+ from specproc.core.de_blazing import save_deblazed_spectra
368
+ save_deblazed_spectra(
369
+ str(out_dir / f'{science_name}_1D_{method}_wavecal.fits'),
370
+ calibrated_spectra,
371
+ content_desc='Wavelength calibrated spectra',
372
+ log_desc='wavelength-calibrated'
373
+ )
374
+
375
+ if self.config.get_bool('reduce', 'save_plots', True):
376
+ from specproc.plotting.spectra_plotter import plot_spectra_to_pdf
377
+ pdf_path = out_dir / f"{science_name}_1D_{method}_wavecal.pdf"
378
+ plot_spectra_to_pdf(calibrated_spectra, str(pdf_path),
379
+ title_prefix="Wavelength Calibrated Spectrum", xlabel=r"Wavelength ($\AA$)",
380
+ id_label="Order")
381
+
382
+ self._report_progress(0.88, "Wavelength Calibration Complete")
383
+ logger.info(
384
+ f"Applied wavelength calibration: target={science_name}, method={method}, "
385
+ f"orders={len(calibrated_spectra.spectra)}"
386
+ )
387
+
388
+ return calibrated_spectra
389
+
390
+ except Exception as e:
391
+ logger.error(f"Error in wavelength calibration of spectra: {e}")
392
+ raise
393
+
394
+ # 去除科学图像中的宇宙射线。
395
+ def stage_cosmic_correction_science(self, science_image: np.ndarray,
396
+ science_name: str = 'science') -> np.ndarray:
397
+ """
398
+ Execute cosmic ray correction stage for science images only.
399
+
400
+ This stage is placed after background subtraction and before spectrum extraction.
401
+
402
+ Why only science images need cosmic ray correction:
403
+ - Bias frames: 0s exposure, no cosmic rays accumulate
404
+ - Flat frames: Multiple frames combined, cosmic rays removed during combination
405
+ - ThAr frames: Lamp spectra, typically no cosmic ray correction needed
406
+ - Science frames: Long exposure (minutes to hours), single frame, cosmic rays must be removed
407
+
408
+ Args:
409
+ science_image: Background-corrected 2D science image
410
+
411
+ Returns:
412
+ Cosmic-ray corrected science image
413
+ """
414
+ logger.info("=" * 50)
415
+ logger.info("STEP 1 (sub): COSMIC RAY CORRECTION (Science Frame)")
416
+ logger.info("=" * 50)
417
+
418
+ self._report_progress(0.45, "Cosmic Ray Correction")
419
+
420
+ try:
421
+ # Reuse the shared Step 1 cosmic detector so selected-step execution
422
+ # and full-pipeline execution behave consistently.
423
+ cosmic_kwargs = {
424
+ 'cosmic_sigclip': self.config.get_float('reduce', 'cosmic_sigclip', 5.0),
425
+ 'cosmic_objlim': self.config.get_float('reduce', 'cosmic_objlim', 5.0),
426
+ 'cosmic_gain': self.config.get_float('reduce', 'cosmic_gain', 1.0),
427
+ 'cosmic_readnoise': self.config.get_float('reduce', 'cosmic_readnoise', 5.0),
428
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
429
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png'),
430
+ }
431
+
432
+ out_dir = Path(self.config.get_output_path()) / 'step1_basic' / 'cosmic_corrected'
433
+ out_dir.mkdir(parents=True, exist_ok=True)
434
+
435
+ # Create a temporary file to pass to process_cosmic_stage
436
+ temp_input_path = out_dir / f"{science_name}_precosmic.fits"
437
+ write_fits_image(str(temp_input_path), science_image, dtype='float32')
438
+
439
+ corrected_files = process_cosmic_stage(
440
+ [str(temp_input_path)],
441
+ output_dir_base=self.config.get_output_path(),
442
+ **cosmic_kwargs
443
+ )
444
+
445
+ corrected_image, _ = read_fits_image(corrected_files[0])
446
+ temp_input_path.unlink() # Clean up temporary file
447
+
448
+ if self.config.get_bool('reduce', 'save_plots', True):
449
+ fig_format = self.config.get('reduce', 'fig_format', 'png')
450
+ plot_2d_image_to_file(
451
+ corrected_image,
452
+ str(out_dir / f'{science_name}_science_cosmic_corrected.{fig_format}'),
453
+ 'Science After Cosmic Ray Correction'
454
+ )
455
+
456
+ self._report_progress(0.48, "Cosmic Ray Correction Complete")
457
+ logger.info("✓ Cosmic ray correction complete")
458
+
459
+ return corrected_image
460
+
461
+ except Exception as e:
462
+ logger.error(f"Error in cosmic ray correction: {e}")
463
+ raise
464
+
465
+ # 从科学图像提取一维光谱。
466
+ def stage_extraction(self, science_image: np.ndarray, science_name: str = 'science',
467
+ calib_image_paths: Optional[List[str]] = None,
468
+ master_flat_path: Optional[str] = None) -> SpectraSet:
469
+ """
470
+ Execute spectrum extraction stage and output both sum/optimal products.
471
+
472
+ Args:
473
+ science_image: Background-corrected 2D science image
474
+ science_name: Base name of the science image
475
+ calib_image_paths: List of paths to calibration images
476
+ master_flat_path: Path to the MasterFlat image
477
+
478
+ Returns:
479
+ SpectraSet with extracted 1D spectra (without wavelength calibration)
480
+ """
481
+ logger.info("=" * 50)
482
+ logger.info("STEP 5: SPECTRUM EXTRACTION")
483
+ logger.info("=" * 50)
484
+
485
+ self._report_progress(0.74, "Spectrum Extraction")
486
+
487
+ if self.state.apertures is None:
488
+ raise RuntimeError("Apertures not available. Run flat fielding stage first.")
489
+
490
+ try:
491
+ # --- Extract config for extraction stage ---
492
+ output_dir_base = self.config.get_output_path()
493
+ extract_kwargs = {
494
+ 'output_dir_base': output_dir_base,
495
+ 'optimal_sigma': self.config.get_float('reduce.extract', 'optimal_sigma', 3.0),
496
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
497
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png'),
498
+ }
499
+
500
+ # 1. Science Extraction
501
+ extracted_sum = process_extraction_stage(
502
+ science_image, self.state.apertures,
503
+ wavelength_calib=None,
504
+ flat_field=self.state.flat_field,
505
+ method_override='sum',
506
+ output_filename=f'{science_name}_1D_sum.fits',
507
+ plot_prefix=f'{science_name}_1D_sum',
508
+ **extract_kwargs,
509
+ )
510
+
511
+ extracted_optimal = process_extraction_stage(
512
+ science_image, self.state.apertures,
513
+ wavelength_calib=None,
514
+ flat_field=self.state.flat_field,
515
+ method_override='optimal',
516
+ output_filename=f'{science_name}_1D_optimal.fits',
517
+ plot_prefix=f'{science_name}_1D_optimal',
518
+ **extract_kwargs,
519
+ )
520
+
521
+ # 2. MasterFlat Extraction
522
+ if master_flat_path and Path(master_flat_path).exists():
523
+ try:
524
+ mf_img, _ = read_fits_image(master_flat_path)
525
+ mf_name = "MasterFlat"
526
+ process_extraction_stage(
527
+ mf_img, self.state.apertures,
528
+ wavelength_calib=None, flat_field=self.state.flat_field,
529
+ method_override='sum', output_filename=f'{mf_name}_1D_sum.fits',
530
+ plot_prefix=f'{mf_name}_1D_sum', **extract_kwargs
531
+ )
532
+ process_extraction_stage(
533
+ mf_img, self.state.apertures,
534
+ wavelength_calib=None, flat_field=self.state.flat_field,
535
+ method_override='optimal', output_filename=f'{mf_name}_1D_optimal.fits',
536
+ plot_prefix=f'{mf_name}_1D_optimal', **extract_kwargs
537
+ )
538
+ logger.info(f"✓ Extracted MasterFlat 1D spectra")
539
+ except Exception as e:
540
+ logger.warning(f"Failed to extract MasterFlat: {e}")
541
+
542
+ # 3. Calibration Extraction
543
+ if calib_image_paths:
544
+ for calib_image_path in calib_image_paths:
545
+ if Path(calib_image_path).exists():
546
+ try:
547
+ c_img, _ = read_fits_image(calib_image_path)
548
+ c_name = Path(calib_image_path).stem
549
+ process_extraction_stage(
550
+ c_img, self.state.apertures,
551
+ wavelength_calib=None, flat_field=self.state.flat_field,
552
+ method_override='sum', output_filename=f'{c_name}_1D_sum.fits',
553
+ plot_prefix=f'{c_name}_1D_sum', **extract_kwargs
554
+ )
555
+ process_extraction_stage(
556
+ c_img, self.state.apertures,
557
+ wavelength_calib=None, flat_field=self.state.flat_field,
558
+ method_override='optimal', output_filename=f'{c_name}_1D_optimal.fits',
559
+ plot_prefix=f'{c_name}_1D_optimal', **extract_kwargs
560
+ )
561
+ logger.info(f"✓ Extracted Calibration 1D spectra for {c_name}")
562
+ except Exception as e:
563
+ logger.warning(f"Failed to extract Calibration {Path(calib_image_path).name}: {e}")
564
+
565
+ # Keep optimal extraction as default downstream product.
566
+ extracted_spectra = extracted_optimal
567
+ self.state.extracted_spectra = extracted_spectra
568
+ self.state.extraction_done = True
569
+
570
+ self._report_progress(0.80, "Spectrum Extraction Complete")
571
+ logger.info(
572
+ f"✓ Spectrum extraction stage complete (sum={extracted_sum.norders} orders, "
573
+ f"optimal={extracted_optimal.norders} orders)"
574
+ )
575
+
576
+ return extracted_spectra
577
+
578
+ except Exception as e:
579
+ logger.error(f"Error in spectrum extraction: {e}")
580
+ raise
581
+
582
+ # 校正提取光谱的闪耀响应。
583
+ def stage_de_blazing(self, spectra_set: SpectraSet, science_name: str = 'science') -> SpectraSet:
584
+ """
585
+ Execute de-blazing correction stage.
586
+
587
+ Args:
588
+ spectra_set: Extracted spectra
589
+ science_name: Base name of the science image
590
+
591
+ Returns:
592
+ De-blazed spectra
593
+ """
594
+ logger.info("=" * 50)
595
+ logger.info("STEP 6: DE-BLAZING (Blaze Function Correction)")
596
+ logger.info("=" * 50)
597
+
598
+ self._report_progress(0.90, "De-blazing Correction")
599
+
600
+ try:
601
+ # --- Extract config for de-blazing stage ---
602
+ de_blazed_spectra = process_de_blazing_stage(
603
+ spectra_set,
604
+ output_dir_base=self.config.get_output_path(),
605
+ flat_field=self.state.flat_field,
606
+ save_deblaze=self.config.get_bool('reduce.save_intermediate', 'save_deblaze', True),
607
+ output_filename=f'{science_name}_1D_optimal_Deblaze.fits',
608
+ plot_prefix=f'{science_name}_1D_optimal_Deblaze',
609
+ save_plots=self.config.get_bool('reduce', 'save_plots', True),
610
+ fig_format=self.config.get('reduce', 'fig_format', 'png'),
611
+ )
612
+
613
+ self.state.de_blazed_spectra = de_blazed_spectra
614
+ self.state.de_blazing_done = True
615
+
616
+ self._report_progress(0.94, "De-blazing Complete")
617
+ logger.info("✓ De-blazing stage complete")
618
+
619
+ return de_blazed_spectra
620
+
621
+ except Exception as e:
622
+ logger.error(f"Error in de-blazing: {e}")
623
+ raise
624
+
625
+ # 保存平场模型及校正产物。
626
+ def _save_step4_flat_products(self, out_dir: Path):
627
+ """Delegate Step-4 flat-model persistence to flat_correction module."""
628
+ if self.state.flat_field is None:
629
+ return
630
+ from specproc.core.flat_correction import save_flat_correction_products
631
+ save_flat_correction_products(
632
+ self.state.flat_field, self.state.apertures, out_dir,
633
+ fig_format=self.config.get('reduce', 'fig_format', 'png'),
634
+ save_plots=self.config.get_bool('reduce', 'save_plots', True),
635
+ )
636
+
637
+ # 建模并扣除平场与科学图像的散射光。
638
+ def stage_scattered_light_subtraction_science(self, science_image: np.ndarray,
639
+ science_name: str = 'science') -> np.ndarray:
640
+ """Step 3: build/subtract scattered-light models for master flat and current science frame."""
641
+ logger.info("=" * 50)
642
+ logger.info("STEP 3: SCATTERED LIGHT MODELING AND SUBTRACTION")
643
+ logger.info("=" * 50)
644
+
645
+ self._report_progress(0.50, "Step 3: Master Flat Scattered Light")
646
+
647
+ if self.state.flat_field is None or self.state.apertures is None:
648
+ raise RuntimeError("Flat/apertures unavailable for Step 3")
649
+
650
+ # --- Extract config for background stage ---
651
+ output_dir_base = self.config.get_output_path()
652
+ # 读取用户设置的mask参数
653
+ mask_margin_pixels = self.config.get_int('reduce.background', 'mask_margin_pixels', 1)
654
+ n_mask_below = self.config.get_int('reduce.trace', 'n_mask_below', 4)
655
+ n_mask_above = self.config.get_int('reduce.trace', 'n_mask_above', 4)
656
+
657
+ out_dir = Path(output_dir_base) / 'step3_scatterlight'
658
+ out_dir.mkdir(parents=True, exist_ok=True)
659
+
660
+ from specproc.core.scattered_light import create_widened_mask
661
+ h, w = self.state.flat_field.flat_data.shape
662
+
663
+ step3_mask, lo_traces, hi_traces, full_ids = create_widened_mask(
664
+ self.state.apertures, self.state.flat_field.flat_data, mask_margin_pixels,
665
+ n_mask_below, n_mask_above
666
+ )
667
+
668
+ write_fits_image(str(out_dir / 'Scattered_Light_Mask.fits'), step3_mask, dtype='uint8')
669
+
670
+ if self.config.get_bool('reduce', 'save_plots', True):
671
+ import matplotlib.pyplot as plt
672
+ fig_format = self.config.get('reduce', 'fig_format', 'png')
673
+ plt.figure(figsize=(12, 10))
674
+ plt.imshow(step3_mask, aspect='auto', origin='lower', cmap='gray', vmin=0, vmax=1)
675
+
676
+ x_anno = w - 1
677
+ for i, ap_id in enumerate(full_ids):
678
+ yc = 0.5 * (lo_traces[i, x_anno] + hi_traces[i, x_anno])
679
+ if np.isfinite(yc) and 0 <= yc < h:
680
+ plt.text(x_anno + 5, yc, str(ap_id), color='red', fontsize=3.5, ha='left', va='center', clip_on=False)
681
+
682
+ plt.title(f'Step 3 Widened Mask (margin={mask_margin_pixels})\nWhite=Masked (Orders + Virtual), Black=Background')
683
+ plt.xlabel('Pixel (X)')
684
+ plt.ylabel('Pixel (Y)')
685
+ plt.xlim(0, w - 1)
686
+ plt.ylim(0, h - 1)
687
+ plot_file = out_dir / f'scattered_light_mask.{fig_format}'
688
+ plt.savefig(str(plot_file), dpi=150, bbox_inches='tight')
689
+ plt.close()
690
+
691
+ bg_kwargs = {
692
+ 'output_subdir': 'step3_scatterlight',
693
+ 'mask_margin_scale': 1.0,
694
+ 'mask_margin_pixels': 0, # Already widened by us
695
+ 'order_mask': step3_mask,
696
+ 'poly_order': self.config.get_int('reduce.background', 'poly_order', 3),
697
+ 'bg_method': self.config.get('reduce.background', 'method', 'convolution'),
698
+ 'sigma_clip_val': self.config.get_float('reduce.background', 'sigma_clip', 3.0),
699
+ 'maxiters': self.config.get_int('reduce.background', 'sigma_clip_maxiters', 4),
700
+ 'bspline_smooth': self.config.get_float('reduce.background', 'bspline_smooth', 1.0),
701
+ 'n_mask_below': n_mask_below,
702
+ 'n_mask_above': n_mask_above,
703
+ 'clip_mode': self.config.get('reduce.background', 'sigma_clip_mode', 'upper'),
704
+ 'split_row': self.config.get_int('data', 'detector_split_row', 2068),
705
+ 'kernel_sigma_x': self.config.get_float('reduce.background', 'kernel_sigma_x', 13.0),
706
+ 'kernel_sigma_y': self.config.get_float('reduce.background', 'kernel_sigma_y', 13.0),
707
+ 'spline_smooth_factor': self.config.get_float('reduce.background', 'spline_smooth_factor', 1.0),
708
+ 'spline_post_smooth_x': self.config.get_float('reduce.background', 'spline_post_smooth_x', 5.0),
709
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
710
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png'),
711
+ }
712
+ flat_background = process_background_stage(
713
+ self.state.flat_field.flat_data,
714
+ output_dir_base,
715
+ apertures=self.state.apertures,
716
+ output_tag='MasterFlat_',
717
+ **bg_kwargs,
718
+ )
719
+ flat_clean = np.clip(
720
+ self.state.flat_field.flat_data.astype(np.float32) - flat_background.astype(np.float32),
721
+ 1e-6,
722
+ None,
723
+ )
724
+ self.state.flat_field.scattered_light = flat_background
725
+
726
+ master_flat_path = Path(output_dir_base) / 'step2_trace' / 'MasterFlat.fits'
727
+ _, flat_header = read_fits_image(str(master_flat_path)) if master_flat_path.exists() else (None, None)
728
+ if flat_header is not None:
729
+ flat_header['BKGSCAT'] = (True, 'Scattered light background subtracted')
730
+
731
+ write_fits_image(str(out_dir / 'MasterFlat.fits'), flat_clean.astype(np.float32), header=flat_header, dtype='float32')
732
+
733
+ self._report_progress(0.56, "Step 3: Science Scattered Light")
734
+ science_background = process_background_stage(
735
+ science_image,
736
+ output_dir_base,
737
+ output_tag=f'{science_name}_',
738
+ apertures=self.state.apertures,
739
+ **bg_kwargs,
740
+ )
741
+
742
+ corrected = science_image.astype(np.float32) - science_background.astype(np.float32)
743
+
744
+ # The corrected image with original filename is already saved by process_background_stage.
745
+
746
+ self._report_progress(0.60, "Science Scattered Light Subtraction Complete")
747
+ return corrected
748
+
749
+ # 选择已完成校正的标定文件。
750
+ def get_best_calib_file(self, raw_calib_path: str) -> str:
751
+ """Find the most processed version of the calibration file."""
752
+ if not raw_calib_path:
753
+ return raw_calib_path
754
+ calib_name = Path(raw_calib_path).stem
755
+ orig_name = Path(raw_calib_path).name
756
+ out_dir = Path(self.config.get_output_path())
757
+
758
+ flat_path = out_dir / 'step4_flat_corrected' / orig_name
759
+ if flat_path.exists():
760
+ return str(flat_path)
761
+
762
+ bias_path = out_dir / 'step1_basic' / 'bias_subtracted' / orig_name
763
+ if bias_path.exists():
764
+ return str(bias_path)
765
+
766
+ overscan_path = out_dir / 'step1_basic' / 'overscan_corrected' / orig_name
767
+ if overscan_path.exists():
768
+ return str(overscan_path)
769
+
770
+ return raw_calib_path
771
+
772
+ # 依次执行完整光谱归约流程。
773
+ def run_full_pipeline(self, raw_image_paths: Union[str, List[str]],
774
+ bias_filenames: List[str],
775
+ flat_filenames: List[str],
776
+ calib_filenames: List[str]) -> Dict[str, SpectraSet]:
777
+ """
778
+ Execute complete spectral reduction pipeline.
779
+
780
+ Args:
781
+ raw_image_paths: List of paths to science FITS files
782
+ bias_filenames: List of bias frame paths
783
+ flat_filenames: List of flat frame paths
784
+ calib_filenames: List of wavelength calibration frame paths
785
+
786
+ Returns:
787
+ Dict mapping science filenames to SpectraSet with final extracted spectra
788
+ """
789
+ if isinstance(raw_image_paths, str):
790
+ raw_image_paths = [raw_image_paths]
791
+
792
+ if self.config.get('telescope', 'instrument', '').strip().lower() == 'slit':
793
+ from specproc.core.slit_pipeline import SlitPipeline
794
+ return SlitPipeline(self.config).run(
795
+ raw_image_paths, bias_filenames, flat_filenames, calib_filenames)
796
+
797
+ logger.info("=" * 60)
798
+ logger.info("STARTING FULL SPECTRAL REDUCTION PIPELINE")
799
+ logger.info("=" * 60)
800
+
801
+ # Reset state
802
+ self.state.reset()
803
+
804
+ # Create output directories
805
+ self.config.create_directories()
806
+
807
+ try:
808
+ # Step 1: Overscan subtraction & trimming for all inputs
809
+ all_raw_files = raw_image_paths + bias_filenames + flat_filenames + calib_filenames
810
+ corrected_files = self.stage_overscan_correction(all_raw_files)
811
+
812
+ # Extract corrected filenames
813
+ n_sci = len(raw_image_paths)
814
+ science_corrected_files = corrected_files[:n_sci]
815
+ bias_filenames_corr = corrected_files[n_sci:n_sci+len(bias_filenames)]
816
+ flat_start = n_sci + len(bias_filenames)
817
+ flat_end = flat_start + len(flat_filenames)
818
+ flat_filenames_corr = corrected_files[flat_start:flat_end]
819
+ calib_filenames_corr = corrected_files[flat_end:]
820
+
821
+ # Step 2: Bias subtraction
822
+ master_bias = self.stage_bias_correction(bias_filenames_corr)
823
+
824
+ # Apply bias to science/flat/calib frames.
825
+ science_filenames_bias = self._apply_master_bias_to_files(
826
+ science_corrected_files, master_bias, 'science'
827
+ )
828
+ if science_filenames_bias:
829
+ science_corrected_files = science_filenames_bias
830
+
831
+ flat_filenames_bias = self._apply_master_bias_to_files(flat_filenames_corr, master_bias, 'flat')
832
+ calib_corrected = self._apply_master_bias_to_files(calib_filenames_corr, master_bias, 'calib')
833
+ if calib_corrected:
834
+ calib_filenames_corr = calib_corrected
835
+
836
+ # Optional Step 1 sub-step: cosmic-ray removal on science frames.
837
+ science_pre_flat_files = science_corrected_files
838
+ if self.config.get_bool('reduce', 'cosmic_enabled', True):
839
+ self._report_progress(0.20, "Cosmic Ray Correction")
840
+ cosmic_kwargs = {
841
+ 'cosmic_sigclip': self.config.get_float('reduce', 'cosmic_sigclip', 5.0),
842
+ 'cosmic_objlim': self.config.get_float('reduce', 'cosmic_objlim', 5.0),
843
+ 'cosmic_gain': self.config.get_float('reduce', 'cosmic_gain', 1.0),
844
+ 'cosmic_readnoise': self.config.get_float('reduce', 'cosmic_readnoise', 5.0),
845
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
846
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png'),
847
+ }
848
+ science_pre_flat_files = process_cosmic_stage(
849
+ science_corrected_files,
850
+ output_dir_base=self.config.get_output_path(),
851
+ output_subdir='step1_basic/cosmic_corrected',
852
+ **cosmic_kwargs,
853
+ )
854
+
855
+ # Determine which flats to use for master flat:
856
+ # If overscan/bias correction was run (i.e. flat_filenames_bias exists and is not empty), use those;
857
+ # otherwise, fall back to raw flat_filenames.
858
+ if flat_filenames_bias and all(Path(f).exists() for f in flat_filenames_bias):
859
+ master_flat_files = flat_filenames_bias
860
+ else:
861
+ master_flat_files = flat_filenames
862
+
863
+ # Step 2: Order tracing and flat model products
864
+ flat_field, apertures = self.stage_flat_fielding(master_flat_files)
865
+
866
+ # Step 3: Scattered light subtraction
867
+ self._report_progress(0.40, "Scattered Light Subtraction")
868
+ logger.info("=" * 50)
869
+ logger.info("STEP 3: SCATTERED LIGHT MODELING AND SUBTRACTION")
870
+ logger.info("=" * 50)
871
+
872
+ out_dir = Path(self.config.get_output_path()) / 'step3_scatterlight'
873
+ out_dir.mkdir(parents=True, exist_ok=True)
874
+ from specproc.core.scattered_light import create_widened_mask, process_background_stage
875
+
876
+ mask_margin_pixels = self.config.get_int('reduce.background', 'mask_margin_pixels', 1)
877
+ n_mask_below = self.config.get_int('reduce.trace', 'n_mask_below', 4)
878
+ n_mask_above = self.config.get_int('reduce.trace', 'n_mask_above', 4)
879
+
880
+ step3_mask, _, _, _ = create_widened_mask(
881
+ apertures, flat_field.flat_data, mask_margin_pixels, n_mask_below, n_mask_above
882
+ )
883
+ write_fits_image(str(out_dir / 'Scattered_Light_Mask.fits'), step3_mask, dtype='uint8')
884
+
885
+ bg_kwargs = {
886
+ 'output_subdir': 'step3_scatterlight',
887
+ 'mask_margin_scale': 1.0,
888
+ 'mask_margin_pixels': 0,
889
+ 'order_mask': step3_mask,
890
+ 'poly_order': self.config.get_int('reduce.background', 'poly_order', 3),
891
+ 'bg_method': self.config.get('reduce.background', 'method', 'convolution'),
892
+ 'sigma_clip_val': self.config.get_float('reduce.background', 'sigma_clip', 3.0),
893
+ 'maxiters': self.config.get_int('reduce.background', 'sigma_clip_maxiters', 4),
894
+ 'bspline_smooth': self.config.get_float('reduce.background', 'bspline_smooth', 1.0),
895
+ 'n_mask_below': n_mask_below,
896
+ 'n_mask_above': n_mask_above,
897
+ 'clip_mode': self.config.get('reduce.background', 'sigma_clip_mode', 'upper'),
898
+ 'split_row': self.config.get_int('data', 'detector_split_row', 2068),
899
+ 'kernel_sigma_x': self.config.get_float('reduce.background', 'kernel_sigma_x', 13.0),
900
+ 'kernel_sigma_y': self.config.get_float('reduce.background', 'kernel_sigma_y', 13.0),
901
+ 'spline_smooth_factor': self.config.get_float('reduce.background', 'spline_smooth_factor', 1.0),
902
+ 'spline_post_smooth_x': self.config.get_float('reduce.background', 'spline_post_smooth_x', 5.0),
903
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
904
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png'),
905
+ }
906
+
907
+ self._report_progress(0.45, "Step 3: Master Flat Scattered Light")
908
+ flat_background = process_background_stage(
909
+ flat_field.flat_data, self.config.get_output_path(),
910
+ apertures=apertures, output_tag='MasterFlat_', **bg_kwargs
911
+ )
912
+ flat_clean = np.clip(
913
+ flat_field.flat_data.astype(np.float32) - flat_background.astype(np.float32), 1e-6, None
914
+ )
915
+ flat_field.scattered_light = flat_background
916
+
917
+ master_flat_path = Path(self.config.get_output_path()) / 'step2_trace' / 'MasterFlat.fits'
918
+ _, flat_header = read_fits_image(str(master_flat_path)) if master_flat_path.exists() else (None, None)
919
+ if flat_header is not None:
920
+ flat_header['BKGSCAT'] = (True, 'Scattered light background subtracted')
921
+ write_fits_image(str(out_dir / 'MasterFlat.fits'), flat_clean.astype(np.float32), header=flat_header, dtype='float32')
922
+
923
+ self._report_progress(0.50, "Step 3: Science Scattered Light")
924
+ science_scatter_files = []
925
+ for sci_file in science_pre_flat_files:
926
+ sci_img, sci_header = read_fits_image(sci_file)
927
+ sci_name = Path(sci_file).stem
928
+ sci_bg = process_background_stage(
929
+ sci_img, self.config.get_output_path(),
930
+ output_tag=f'{sci_name}_', apertures=apertures, **bg_kwargs
931
+ )
932
+ sci_clean = sci_img.astype(np.float32) - sci_bg.astype(np.float32)
933
+ if sci_header is not None:
934
+ sci_header['BKGSCAT'] = (True, 'Scattered light background subtracted')
935
+ out_path = out_dir / Path(sci_file).name
936
+ write_fits_image(str(out_path), sci_clean, header=sci_header, dtype='float32')
937
+ science_scatter_files.append(str(out_path))
938
+
939
+ # Step 4: Build 2D flat-field model and apply it
940
+ self._report_progress(0.60, "2D Flat Fielding")
941
+ logger.info("=" * 50)
942
+ logger.info("STEP 4: 2D FLAT-FIELD CORRECTION")
943
+ logger.info("=" * 50)
944
+
945
+ # --- Build the flat model ONCE using the cleaned master flat ---
946
+ logger.info("Building 2D flat-field model from MasterFlat...")
947
+ from specproc.core.flat_correction import process_flat_correction_stage
948
+ flat_corr_kwargs = {
949
+ 'blaze_smooth_factor': self.config.get_float('reduce.flat', 'blaze_smooth_factor', 1.0),
950
+ 'width_smooth_window': self.config.get_int('reduce.flat', 'width_smooth_window', 41),
951
+ 'profile_bin_step': self.config.get_float('reduce.flat', 'profile_bin_step', 0.01),
952
+ 'n_profile_segments': self.config.get_int('reduce.flat', 'n_profile_segments', 100),
953
+ 'profile_smooth_sigma': self.config.get_float('reduce.flat', 'profile_smooth_sigma', 6.0),
954
+ 'pixel_flat_min': self.config.get_float('reduce.flat', 'pixel_flat_min', 0.5),
955
+ 'pixel_flat_max': self.config.get_float('reduce.flat', 'pixel_flat_max', 1.5),
956
+ 'fringe_orders': self.config.get_int('reduce.flat', 'fringe_orders', 20),
957
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
958
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png'),
959
+ }
960
+ # This call builds the model and saves all diagnostics.
961
+ # The returned 'corrected_flat' is the pixel_flat map itself.
962
+ # The important part is that flat_field object is updated in-place.
963
+ _ = process_flat_correction_stage(
964
+ science_image=flat_clean,
965
+ flat_field=flat_field,
966
+ output_dir_base=self.config.get_output_path(),
967
+ apertures=apertures,
968
+ science_name="MasterFlat", # Use a specific name for diagnostics
969
+ **flat_corr_kwargs
970
+ )
971
+ logger.info("✓ 2D flat-field model built and diagnostics saved.")
972
+
973
+ # --- Apply the correction map to all science and calib frames ---
974
+ flat_corr_map = flat_field.pixel_flat
975
+ if flat_corr_map is None:
976
+ raise RuntimeError("Failed to build pixel_flat map in Step 4.")
977
+
978
+ safe_flat = flat_corr_map.astype(np.float32)
979
+ bad = (~np.isfinite(safe_flat)) | (safe_flat <= 0.05)
980
+ if np.any(bad):
981
+ safe_flat = safe_flat.copy()
982
+ safe_flat[bad] = 1.0
983
+
984
+ out_dir_f = Path(self.config.get_output_path()) / 'step4_flat_corrected'
985
+ out_dir_f.mkdir(parents=True, exist_ok=True)
986
+
987
+ # Apply to science files
988
+ science_flat2d_files = []
989
+ logger.info("Applying 2D flat correction to science frames...")
990
+ for sci_file in science_scatter_files:
991
+ sci_img, sci_header = read_fits_image(sci_file)
992
+ sci_name = Path(sci_file).stem
993
+ corrected = sci_img.astype(np.float32) / safe_flat
994
+
995
+ out_path = out_dir_f / Path(sci_file).name
996
+ if sci_header is not None:
997
+ sci_header['FLATCOR'] = (True, '2D pixel flat correction applied')
998
+ write_fits_image(str(out_path), corrected, header=sci_header, dtype='float32')
999
+
1000
+ if self.config.get_bool('reduce', 'save_plots', True):
1001
+ from specproc.plotting.spectra_plotter import plot_2d_image_to_file
1002
+ fig_format = self.config.get('reduce', 'fig_format', 'png')
1003
+ plot_2d_image_to_file(corrected, str(out_dir_f / f"{sci_name}_flat2d_corrected.{fig_format}"), f"Science After 2D Flat Correction: {sci_name}")
1004
+
1005
+ science_flat2d_files.append(str(out_path))
1006
+
1007
+ # Apply to calib files
1008
+ calib_filenames_final = []
1009
+ logger.info("Applying 2D flat correction to calibration frames...")
1010
+ for c_file in calib_filenames_corr:
1011
+ calib_img, calib_header = read_fits_image(c_file)
1012
+ calib_name = Path(c_file).stem
1013
+ corrected = calib_img.astype(np.float32) / safe_flat
1014
+
1015
+ out_path = out_dir_f / Path(c_file).name
1016
+ if calib_header is not None:
1017
+ calib_header['FLATCOR'] = (True, '2D pixel flat correction applied')
1018
+ write_fits_image(str(out_path), corrected, header=calib_header, dtype='float32')
1019
+
1020
+ if self.config.get_bool('reduce', 'save_plots', True):
1021
+ from specproc.plotting.spectra_plotter import plot_2d_image_to_file
1022
+ fig_format = self.config.get('reduce', 'fig_format', 'png')
1023
+ plot_2d_image_to_file(corrected, str(out_dir_f / f"{calib_name}_flat2d_corrected.{fig_format}"), f"Calibration Frame After 2D Flat Correction: {calib_name}")
1024
+
1025
+ calib_filenames_final.append(str(out_path))
1026
+
1027
+ # Step 5: 1D Extraction
1028
+ self._report_progress(0.70, "Spectrum Extraction")
1029
+ logger.info("=" * 50)
1030
+ logger.info("STEP 5: SPECTRUM EXTRACTION")
1031
+ logger.info("=" * 50)
1032
+ extract_kwargs = {
1033
+ 'output_dir_base': self.config.get_output_path(),
1034
+ 'optimal_sigma': self.config.get_float('reduce.extract', 'optimal_sigma', 3.0),
1035
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
1036
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png'),
1037
+ }
1038
+
1039
+ mf_path = Path(self.config.get_output_path()) / 'step4_flat_corrected' / 'MasterFlat.fits'
1040
+ if mf_path.exists():
1041
+ mf_img, _ = read_fits_image(str(mf_path))
1042
+ process_extraction_stage(mf_img, apertures, wavelength_calib=None, flat_field=flat_field, method_override='sum', output_filename='MasterFlat_1D_sum.fits', plot_prefix='MasterFlat_1D_sum', **extract_kwargs)
1043
+ process_extraction_stage(mf_img, apertures, wavelength_calib=None, flat_field=flat_field, method_override='optimal', output_filename='MasterFlat_1D_optimal.fits', plot_prefix='MasterFlat_1D_optimal', **extract_kwargs)
1044
+
1045
+ for c_file in calib_filenames_final:
1046
+ c_img, _ = read_fits_image(c_file)
1047
+ c_name = Path(c_file).stem
1048
+ process_extraction_stage(c_img, apertures, wavelength_calib=None, flat_field=flat_field, method_override='sum', output_filename=f'{c_name}_1D_sum.fits', plot_prefix=f'{c_name}_1D_sum', **extract_kwargs)
1049
+ process_extraction_stage(c_img, apertures, wavelength_calib=None, flat_field=flat_field, method_override='optimal', output_filename=f'{c_name}_1D_optimal.fits', plot_prefix=f'{c_name}_1D_optimal', **extract_kwargs)
1050
+
1051
+ extracted_science_dict = {}
1052
+ for sci_file in science_flat2d_files:
1053
+ sci_img, _ = read_fits_image(sci_file)
1054
+ sci_name = Path(sci_file).stem
1055
+ process_extraction_stage(sci_img, apertures, wavelength_calib=None, flat_field=flat_field, method_override='sum', output_filename=f'{sci_name}_1D_sum.fits', plot_prefix=f'{sci_name}_1D_sum', **extract_kwargs)
1056
+ opt_spec = process_extraction_stage(sci_img, apertures, wavelength_calib=None, flat_field=flat_field, method_override='optimal', output_filename=f'{sci_name}_1D_optimal.fits', plot_prefix=f'{sci_name}_1D_optimal', **extract_kwargs)
1057
+ extracted_science_dict[sci_name] = opt_spec
1058
+
1059
+ # Step 6: De-blazing
1060
+ self._report_progress(0.80, "De-blazing")
1061
+ logger.info("=" * 50)
1062
+ logger.info("STEP 6: DE-BLAZING")
1063
+ logger.info("=" * 50)
1064
+ def do_deblaze(target_name, spectra_obj=None):
1065
+ from specproc.core.extraction import load_extracted_spectra
1066
+ db_opt = None
1067
+ for method in ['sum', 'optimal']:
1068
+ spectra = spectra_obj if (method == 'optimal' and spectra_obj is not None) else None
1069
+ if spectra is None:
1070
+ try: spectra = load_extracted_spectra(str(Path(self.config.get_output_path()) / 'step5_extraction' / f'{target_name}_1D_{method}.fits'))
1071
+ except Exception: pass
1072
+ if spectra is not None:
1073
+ db_spec = process_de_blazing_stage(
1074
+ spectra, self.config.get_output_path(), flat_field=flat_field,
1075
+ save_deblaze=self.config.get_bool('reduce.save_intermediate', 'save_deblaze', True),
1076
+ output_filename=f'{target_name}_1D_{method}_Deblaze.fits',
1077
+ plot_prefix=f'{target_name}_1D_{method}_Deblaze',
1078
+ save_plots=self.config.get_bool('reduce', 'save_plots', True),
1079
+ fig_format=self.config.get('reduce', 'fig_format', 'png')
1080
+ )
1081
+ if method == 'optimal': db_opt = db_spec
1082
+ return db_opt
1083
+
1084
+ do_deblaze("MasterFlat")
1085
+ for c_file in calib_filenames_final:
1086
+ do_deblaze(Path(c_file).stem)
1087
+
1088
+ deblazed_science_dict = {}
1089
+ for sci_name, opt_spec in extracted_science_dict.items():
1090
+ db_opt = do_deblaze(sci_name, opt_spec)
1091
+ if db_opt is not None:
1092
+ deblazed_science_dict[sci_name] = db_opt
1093
+
1094
+ # Step 7: Wavelength Calibration
1095
+ self._report_progress(0.90, "Wavelength Calibration")
1096
+ logger.info("=" * 50)
1097
+ logger.info("STEP 7: WAVELENGTH CALIBRATION")
1098
+ logger.info("=" * 50)
1099
+
1100
+ import os
1101
+ from astropy.time import Time
1102
+ time_key = self.config.get('data', 'statime_key', 'DATE-OBS')
1103
+ def get_file_time(filepath):
1104
+ if not filepath or not Path(filepath).exists(): return 0
1105
+ try:
1106
+ _, hdr = read_fits_image(filepath)
1107
+ if hdr and time_key in hdr:
1108
+ return Time(hdr[time_key]).unix
1109
+ except Exception: pass
1110
+ return os.path.getmtime(filepath)
1111
+
1112
+ calibrations = []
1113
+ for c_file in (calib_filenames_final if calib_filenames_final else calib_filenames):
1114
+ if not c_file: continue
1115
+ wave_calib = self.stage_wavelength_calibration(c_file)
1116
+ c_time = get_file_time(c_file)
1117
+ calibrations.append((c_time, wave_calib, Path(c_file).stem))
1118
+
1119
+ if not calibrations:
1120
+ raise RuntimeError("No wavelength calibrations could be generated.")
1121
+
1122
+ calibrated_science_dict = {}
1123
+
1124
+ targets = [("MasterFlat", None)]
1125
+ for c_file in (calib_filenames_final if calib_filenames_final else calib_filenames):
1126
+ if c_file: targets.append((Path(c_file).stem, c_file))
1127
+ for sci_name in extracted_science_dict.keys():
1128
+ sci_file_match = next((f for f in science_scatter_files if Path(f).stem == sci_name), None)
1129
+ if not sci_file_match:
1130
+ sci_file_match = next((f for f in raw_image_paths if Path(f).stem == sci_name), None)
1131
+ targets.append((sci_name, sci_file_match))
1132
+
1133
+ for target_name, target_file in targets:
1134
+ target_time = get_file_time(target_file)
1135
+ closest_calib = min(calibrations, key=lambda x: abs(x[0] - target_time))
1136
+ wave_calib = closest_calib[1]
1137
+ logger.info(f"Using calibration {closest_calib[2]} for target {target_name} (closest in time)")
1138
+
1139
+ calib_opt = None
1140
+ for method in ['sum', 'optimal']:
1141
+ from specproc.core.extraction import load_extracted_spectra
1142
+ spectra = None
1143
+ db_path = Path(self.config.get_output_path()) / 'step6_deblazing' / f'{target_name}_1D_{method}_Deblaze.fits'
1144
+ if db_path.exists():
1145
+ try: spectra = load_extracted_spectra(str(db_path))
1146
+ except Exception: pass
1147
+ if spectra is None:
1148
+ ex_path = Path(self.config.get_output_path()) / 'step5_extraction' / f'{target_name}_1D_{method}.fits'
1149
+ if ex_path.exists():
1150
+ try: spectra = load_extracted_spectra(str(ex_path))
1151
+ except Exception: pass
1152
+
1153
+ if spectra is not None:
1154
+ calib_spec = self.stage_wavelength_calibration_on_spectra(
1155
+ spectra, wave_calib, science_name=target_name, method=method)
1156
+ if method == 'optimal':
1157
+ calib_opt = calib_spec
1158
+
1159
+ if calib_opt is not None and target_name in extracted_science_dict:
1160
+ calibrated_science_dict[target_name] = calib_opt
1161
+
1162
+ self._report_progress(1.0, "Pipeline Complete")
1163
+
1164
+ logger.info("=" * 60)
1165
+ logger.info("PIPELINE EXECUTION COMPLETED SUCCESSFULLY")
1166
+ logger.info("=" * 60)
1167
+
1168
+ return calibrated_science_dict
1169
+
1170
+ except Exception as e:
1171
+ logger.error(f"Pipeline execution failed: {e}")
1172
+ logger.error("=" * 60)
1173
+ raise
1174
+
1175
+ # 返回当前流程处理状态。
1176
+ def get_state(self) -> ProcessingState:
1177
+ """Get current processing state."""
1178
+ return self.state
1179
+
1180
+ # 重置所有流程状态。
1181
+ def reset(self):
1182
+ """Reset pipeline state."""
1183
+ self.state.reset()