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,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