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,2315 @@
1
+ """
2
+ Main PyQt GUI window for SpecProc application.
3
+
4
+ Provides the primary user interface with file management, process control,
5
+ and result visualization.
6
+ """
7
+ # 提供主界面、文件管理和处理流程控制。
8
+
9
+ import sys
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import List, Optional
13
+ import time
14
+ import json
15
+ import numpy as np
16
+
17
+ from PyQt5.QtWidgets import (
18
+ QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
19
+ QPushButton, QLabel, QFileDialog, QListWidget, QListWidgetItem,
20
+ QProgressBar, QTextEdit, QTabWidget, QSplitter, QMessageBox,
21
+ QMenuBar, QMenu, QStatusBar, QCheckBox, QGroupBox, QDialog, QFrame
22
+ )
23
+ from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer
24
+ from PyQt5.QtGui import QIcon, QFont
25
+
26
+ from specproc.config.config_manager import ConfigManager
27
+ from specproc.core.processing_pipeline import ProcessingPipeline
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ # 在线程中执行数据处理流程。
33
+ class ProcessingWorker(QThread):
34
+ """Worker thread for pipeline execution."""
35
+
36
+ progress_updated = pyqtSignal(float, str)
37
+ execution_complete = pyqtSignal(bool, str)
38
+
39
+ # 初始化工作线程。
40
+ def __init__(self, pipeline: ProcessingPipeline, raw_images: List[str],
41
+ biases: List[str], flats: List[str], calibs: List[str]):
42
+ """
43
+ Initialize worker thread.
44
+
45
+ Args:
46
+ pipeline: ProcessingPipeline instance
47
+ raw_images: List of science image paths
48
+ biases: List of bias frame paths
49
+ flats: List of flat frame paths
50
+ calibs: List of wavelength calibration frame paths
51
+ """
52
+ super().__init__()
53
+ self.pipeline = pipeline
54
+ self.raw_images = raw_images
55
+ self.biases = biases
56
+ self.flats = flats
57
+ self.calibs = calibs
58
+
59
+ # 在线程中运行完整处理流程。
60
+ def run(self):
61
+ """Execute pipeline in worker thread."""
62
+ try:
63
+ # Set progress callback
64
+ self.pipeline.set_progress_callback(
65
+ lambda p, s: self.progress_updated.emit(p, s)
66
+ )
67
+
68
+ # Run pipeline
69
+ self.pipeline.run_full_pipeline(
70
+ self.raw_images, self.biases, self.flats, self.calibs
71
+ )
72
+
73
+ self.execution_complete.emit(True, "Pipeline execution completed successfully")
74
+ except Exception as e:
75
+ logger.error(f"Pipeline execution error: {e}")
76
+ self.execution_complete.emit(False, f"Error: {str(e)}")
77
+
78
+
79
+ # 管理主界面及数据处理流程。
80
+ class MainWindow(QMainWindow):
81
+ """Main application window."""
82
+
83
+ # 初始化工作线程。
84
+ def __init__(self, config: Optional[ConfigManager] = None):
85
+ """
86
+ Initialize main window.
87
+
88
+ Args:
89
+ config: Optional configuration manager
90
+ """
91
+ super().__init__()
92
+ self.config = config or ConfigManager()
93
+ self.pipeline = ProcessingPipeline(self.config)
94
+ self.worker_thread = None
95
+
96
+ # File lists
97
+ self.raw_files = []
98
+ self.bias_files = []
99
+ self.flat_files = []
100
+ self.calib_file = []
101
+ self.last_selected_dir = str(Path.cwd())
102
+
103
+ # Store original file paths to handle deleted processed files
104
+ # Format: {filename: original_path}
105
+ self.original_file_paths = {}
106
+
107
+ # Processing state tracking
108
+ self.processing_state = {
109
+ 'stage_0_completed': False, # Overscan correction
110
+ 'stage_1_completed': False, # Bias correction
111
+ 'stage_2_completed': False, # Flat fielding
112
+ 'stage_3_completed': False, # Wavelength calibration
113
+ 'stage_4_completed': False, # Background subtraction
114
+ 'stage_5_completed': False, # Spectrum extraction
115
+
116
+ # Processed file paths
117
+ 'overscan_corrected_files': [], # Stage 0 output
118
+ 'master_bias_path': None, # Stage 1 output
119
+ 'master_flat_path': None, # Stage 2 output
120
+ 'wavelength_calib_path': None, # Stage 3 output
121
+ 'background_model_path': None, # Stage 4 output
122
+ 'extracted_spectra_path': None, # Stage 5 output
123
+ }
124
+
125
+ self.init_ui()
126
+ self.setup_logging()
127
+
128
+ # Check overscan configuration and update UI accordingly
129
+ self._update_overscan_checkbox_from_config()
130
+
131
+ # 构建主窗口界面。
132
+ def init_ui(self):
133
+ """Initialize user interface."""
134
+ self.setWindowTitle("自动化光谱处理软件SpecProc")
135
+ self.setGeometry(100, 100, 1400, 900)
136
+
137
+ # Create main widget
138
+ main_widget = QWidget()
139
+ self.setCentralWidget(main_widget)
140
+
141
+ # Main layout
142
+ layout = QHBoxLayout()
143
+ main_widget.setLayout(layout)
144
+
145
+ # Left panel: file management
146
+ left_panel = self._create_file_panel()
147
+ layout.addWidget(left_panel)
148
+
149
+ # Right panel: process control & results
150
+ right_panel = self._create_process_panel()
151
+ layout.addWidget(right_panel)
152
+
153
+ # Create menu bar
154
+ self._create_menu_bar()
155
+
156
+ # Create status bar
157
+ self.statusBar = QStatusBar()
158
+ self.setStatusBar(self.statusBar)
159
+ self.statusBar.showMessage("Ready")
160
+
161
+ # 构建文件选择面板。
162
+ def _create_file_panel(self) -> QWidget:
163
+ """Create file management panel."""
164
+ panel = QWidget()
165
+ layout = QVBoxLayout()
166
+ layout.setContentsMargins(10, 10, 10, 10)
167
+ layout.setSpacing(5)
168
+ panel.setLayout(layout)
169
+
170
+ # ===== BIAS FRAMES =====
171
+ bias_group = QGroupBox("📊 Bias Frames")
172
+ bias_layout = QVBoxLayout()
173
+
174
+ bias_btn_layout = QHBoxLayout()
175
+ bias_add_btn = QPushButton("➕ Add Bias Frames...")
176
+ bias_add_btn.clicked.connect(self._select_bias_files)
177
+ bias_del_btn = QPushButton("❌ Remove Selected")
178
+ bias_del_btn.clicked.connect(self._remove_bias_file)
179
+ bias_btn_layout.addWidget(bias_add_btn)
180
+ bias_btn_layout.addWidget(bias_del_btn)
181
+ bias_layout.addLayout(bias_btn_layout)
182
+
183
+ self.bias_list = QListWidget()
184
+ self.bias_list.setMinimumHeight(40)
185
+ self.bias_list.setMaximumHeight(120)
186
+ self.bias_list.setSelectionMode(QListWidget.ExtendedSelection)
187
+ bias_layout.addWidget(self.bias_list, 1)
188
+
189
+ bias_group.setLayout(bias_layout)
190
+ layout.addWidget(bias_group)
191
+
192
+ # Add separator with spacing
193
+ layout.addSpacing(8)
194
+ separator1 = QFrame()
195
+ separator1.setFrameShape(QFrame.HLine)
196
+ separator1.setFrameShadow(QFrame.Sunken)
197
+ separator1.setContentsMargins(10, 0, 10, 0)
198
+ layout.addWidget(separator1)
199
+ layout.addSpacing(8)
200
+
201
+ # ===== FLAT FRAMES =====
202
+ flat_group = QGroupBox("📹 Flat Frames")
203
+ flat_layout = QVBoxLayout()
204
+
205
+ flat_btn_layout = QHBoxLayout()
206
+ flat_add_btn = QPushButton("➕ Add Flat Frames...")
207
+ flat_add_btn.clicked.connect(self._select_flat_files)
208
+ flat_del_btn = QPushButton("❌ Remove Selected")
209
+ flat_del_btn.clicked.connect(self._remove_flat_file)
210
+ flat_btn_layout.addWidget(flat_add_btn)
211
+ flat_btn_layout.addWidget(flat_del_btn)
212
+ flat_layout.addLayout(flat_btn_layout)
213
+
214
+ self.flat_list = QListWidget()
215
+ self.flat_list.setMinimumHeight(40)
216
+ self.flat_list.setMaximumHeight(120)
217
+ self.flat_list.setSelectionMode(QListWidget.ExtendedSelection)
218
+ flat_layout.addWidget(self.flat_list, 1)
219
+
220
+ flat_group.setLayout(flat_layout)
221
+ layout.addWidget(flat_group)
222
+
223
+ # Add separator with spacing
224
+ layout.addSpacing(8)
225
+ separator2 = QFrame()
226
+ separator2.setFrameShape(QFrame.HLine)
227
+ separator2.setFrameShadow(QFrame.Sunken)
228
+ separator2.setContentsMargins(10, 0, 10, 0)
229
+ layout.addWidget(separator2)
230
+ layout.addSpacing(8)
231
+
232
+ # ===== CALIBRATION FRAMES =====
233
+ calib_group = QGroupBox("🔬 Wavelength Calibration Frames")
234
+ calib_layout = QVBoxLayout()
235
+
236
+ calib_btn_layout = QHBoxLayout()
237
+ calib_add_btn = QPushButton("➕ Add Calibration Frame(s)...")
238
+ calib_add_btn.clicked.connect(self._select_calib_files)
239
+ calib_del_btn = QPushButton("❌ Remove Selected")
240
+ calib_del_btn.clicked.connect(self._remove_calib_file)
241
+ calib_btn_layout.addWidget(calib_add_btn)
242
+ calib_btn_layout.addWidget(calib_del_btn)
243
+ calib_layout.addLayout(calib_btn_layout)
244
+
245
+ self.calib_list = QListWidget()
246
+ self.calib_list.setMinimumHeight(40)
247
+ self.calib_list.setMaximumHeight(80)
248
+ self.calib_list.setSelectionMode(QListWidget.ExtendedSelection)
249
+ calib_layout.addWidget(self.calib_list, 1)
250
+
251
+ calib_group.setLayout(calib_layout)
252
+ layout.addWidget(calib_group)
253
+
254
+ # Add separator with spacing
255
+ layout.addSpacing(8)
256
+ separator3 = QFrame()
257
+ separator3.setFrameShape(QFrame.HLine)
258
+ separator3.setFrameShadow(QFrame.Sunken)
259
+ separator3.setContentsMargins(10, 0, 10, 0)
260
+ layout.addWidget(separator3)
261
+ layout.addSpacing(8)
262
+
263
+ # ===== SCIENCE IMAGES =====
264
+ science_group = QGroupBox("🌟 Science Images")
265
+ science_layout = QVBoxLayout()
266
+
267
+ raw_btn_layout = QHBoxLayout()
268
+ raw_add_btn = QPushButton("➕ Add Science Image(s)...")
269
+ raw_add_btn.clicked.connect(self._select_raw_files)
270
+ raw_del_btn = QPushButton("❌ Remove Selected")
271
+ raw_del_btn.clicked.connect(self._remove_raw_file)
272
+ raw_btn_layout.addWidget(raw_add_btn)
273
+ raw_btn_layout.addWidget(raw_del_btn)
274
+ science_layout.addLayout(raw_btn_layout)
275
+
276
+ self.raw_list = QListWidget()
277
+ self.raw_list.setMinimumHeight(40)
278
+ self.raw_list.setSelectionMode(QListWidget.ExtendedSelection)
279
+ science_layout.addWidget(self.raw_list, 1)
280
+
281
+ science_group.setLayout(science_layout)
282
+ layout.addWidget(science_group, 2)
283
+
284
+ return panel
285
+
286
+ # 构建处理控制面板。
287
+ def _create_process_panel(self) -> QWidget:
288
+ """Create processing control panel."""
289
+ panel = QWidget()
290
+ layout = QVBoxLayout()
291
+ panel.setLayout(layout)
292
+
293
+ # ===== PROCESS CONTROL BUTTONS =====
294
+ button_layout = QHBoxLayout()
295
+
296
+ self.settings_btn = QPushButton("⚙ Settings")
297
+ self.settings_btn.clicked.connect(self._show_settings)
298
+ button_layout.addWidget(self.settings_btn)
299
+
300
+ self.run_all_btn = QPushButton("▶️ Run All Steps")
301
+ self.run_all_btn.clicked.connect(self._run_full_pipeline)
302
+ button_layout.addWidget(self.run_all_btn)
303
+
304
+ self.stop_btn = QPushButton("⏹ Stop")
305
+ self.stop_btn.clicked.connect(self._stop_processing)
306
+ self.stop_btn.setEnabled(False)
307
+ button_layout.addWidget(self.stop_btn)
308
+
309
+ layout.addLayout(button_layout)
310
+
311
+ # ===== PROGRESS INDICATOR =====
312
+ layout.addWidget(QLabel("Progress:"))
313
+ self.progress_bar = QProgressBar()
314
+ self.progress_bar.setRange(0, 100)
315
+ self.progress_bar.setValue(0)
316
+ layout.addWidget(self.progress_bar)
317
+
318
+ # Stage indicator
319
+ self.stage_label = QLabel("Idle")
320
+ stage_font = QFont()
321
+ stage_font.setBold(True)
322
+ self.stage_label.setFont(stage_font)
323
+ layout.addWidget(self.stage_label)
324
+
325
+ # ===== PROCESSING STEPS SELECTION =====
326
+ steps_group = QGroupBox("📋 Processing Steps")
327
+ steps_layout = QVBoxLayout()
328
+
329
+ # Checkboxes for each stage
330
+ self.stage_checkboxes = {}
331
+ stages = [
332
+ ("stage_0", "Step 1: Basic Pre-processing", True),
333
+ ("stage_1", "Step 2: Aperture Tracing", True),
334
+ ("stage_2", "Step 3: Scattered Light Subtraction", True),
335
+ ("stage_3", "Step 4: 2D Flat-Field Correction", True),
336
+ ("stage_4", "Step 5: 1D Extraction", True),
337
+ ("stage_5", "Step 6: De-blazing", True),
338
+ ("stage_6", "Step 7: Wavelength Calibration", True),
339
+ ]
340
+
341
+ # Step1 sub-step toggles: overscan / bias / cosmic
342
+ self.step1_substep_checkboxes = {}
343
+ step1_sub_layout = QHBoxLayout()
344
+ step1_sub_layout.setContentsMargins(22, 0, 0, 0)
345
+
346
+ for stage_id, stage_name, default_checked in stages:
347
+ checkbox = QCheckBox(stage_name)
348
+ checkbox.setChecked(default_checked)
349
+ self.stage_checkboxes[stage_id] = checkbox
350
+ steps_layout.addWidget(checkbox)
351
+
352
+ # Keep Step1 options immediately after Step1 in the UI.
353
+ if stage_id == "stage_0":
354
+ step1_sub_layout.addWidget(QLabel("("))
355
+ for key, text in [
356
+ ("overscan", "Overscan"),
357
+ ("bias", "Bias"),
358
+ ("cosmic", "Cosmic"),
359
+ ]:
360
+ cb = QCheckBox(text)
361
+ cb.setChecked(True)
362
+ cb.stateChanged.connect(self._refresh_step1_checkbox_text)
363
+ self.step1_substep_checkboxes[key] = cb
364
+ step1_sub_layout.addWidget(cb)
365
+ step1_sub_layout.addWidget(QLabel(")"))
366
+ step1_sub_layout.addStretch()
367
+ steps_layout.addLayout(step1_sub_layout)
368
+
369
+ # Special handling for stage_0 (overscan) checkbox
370
+ if 'stage_0' in self.stage_checkboxes:
371
+ stage_0_checkbox = self.stage_checkboxes['stage_0']
372
+ stage_0_checkbox.stateChanged.connect(self._on_overscan_checkbox_changed)
373
+
374
+ self._refresh_step1_checkbox_text()
375
+
376
+ # Button layout for steps
377
+ steps_button_layout = QHBoxLayout()
378
+
379
+ self.run_selected_btn = QPushButton("▶️ Run Selected Steps")
380
+ self.run_selected_btn.clicked.connect(self._run_selected_pipeline)
381
+ steps_button_layout.addWidget(self.run_selected_btn)
382
+
383
+ select_all_btn = QPushButton("☑️ Select All")
384
+ select_all_btn.clicked.connect(self._select_all_stages)
385
+ steps_button_layout.addWidget(select_all_btn)
386
+
387
+ clear_all_btn = QPushButton("☐ Clear All")
388
+ clear_all_btn.clicked.connect(self._clear_all_stages)
389
+ steps_button_layout.addWidget(clear_all_btn)
390
+
391
+ steps_layout.addLayout(steps_button_layout)
392
+ steps_group.setLayout(steps_layout)
393
+ layout.addWidget(steps_group)
394
+
395
+ # ===== TABS FOR RESULTS =====
396
+ self.tabs = QTabWidget()
397
+
398
+ # Log tab
399
+ self.log_text = QTextEdit()
400
+ self.log_text.setReadOnly(True)
401
+ self.tabs.addTab(self.log_text, "📋 Log")
402
+
403
+ # Results tab (placeholder)
404
+ results_widget = QTextEdit()
405
+ results_widget.setText("Results will appear here after processing")
406
+ results_widget.setReadOnly(True)
407
+ self.tabs.addTab(results_widget, "📊 Results")
408
+
409
+ layout.addWidget(self.tabs)
410
+
411
+ return panel
412
+
413
+ # 构建菜单栏。
414
+ def _create_menu_bar(self):
415
+ """Create menu bar."""
416
+ menubar = self.menuBar()
417
+
418
+ # File menu
419
+ file_menu = menubar.addMenu("File")
420
+ open_config_action = file_menu.addAction("Open Configuration...")
421
+ open_config_action.triggered.connect(self._open_config)
422
+ settings_action = file_menu.addAction("Settings...")
423
+ settings_action.triggered.connect(self._show_settings)
424
+ file_menu.addSeparator()
425
+ exit_action = file_menu.addAction("Exit")
426
+ exit_action.triggered.connect(self.close)
427
+
428
+ # Help menu
429
+ help_menu = menubar.addMenu("Help")
430
+ about_action = help_menu.addAction("About")
431
+ about_action.triggered.connect(self._show_about)
432
+
433
+ # 选择偏置帧并加入列表。
434
+ def _select_bias_files(self):
435
+ """Select bias frame files."""
436
+ files, _ = QFileDialog.getOpenFileNames(
437
+ self, "Select Bias Frames", self.last_selected_dir, "FITS Files (*.fits *.fit)"
438
+ )
439
+ if files:
440
+ self.last_selected_dir = str(Path(files[0]).parent)
441
+ for f in files:
442
+ self.bias_files.append(f)
443
+ self.original_file_paths[Path(f).name] = f # Save original path
444
+ self.bias_list.addItem(Path(f).name)
445
+ self.statusBar.showMessage(f"Added {len(files)} bias frames")
446
+
447
+ # 移除选中的偏置帧。
448
+ def _remove_bias_file(self):
449
+ """Remove selected bias file from list."""
450
+ for item in self.bias_list.selectedItems():
451
+ try:
452
+ idx = self.bias_list.row(item)
453
+ if idx < len(self.bias_files):
454
+ self.bias_files.pop(idx)
455
+ self.bias_list.takeItem(idx)
456
+ self.statusBar.showMessage(f"Bias frames: {len(self.bias_files)} remaining")
457
+ except Exception as e:
458
+ self.statusBar.showMessage(f"Error removing bias file: {e}")
459
+
460
+ # 选择平场帧并加入列表。
461
+ def _select_flat_files(self):
462
+ """Select flat frame files."""
463
+ files, _ = QFileDialog.getOpenFileNames(
464
+ self, "Select Flat Frames", self.last_selected_dir, "FITS Files (*.fits *.fit)"
465
+ )
466
+ if files:
467
+ self.last_selected_dir = str(Path(files[0]).parent)
468
+ for f in files:
469
+ self.flat_files.append(f)
470
+ self.original_file_paths[Path(f).name] = f # Save original path
471
+ self.flat_list.addItem(Path(f).name)
472
+ self.statusBar.showMessage(f"Added {len(files)} flat frames")
473
+
474
+ # 移除选中的平场帧。
475
+ def _remove_flat_file(self):
476
+ """Remove selected flat file from list."""
477
+ for item in self.flat_list.selectedItems():
478
+ try:
479
+ idx = self.flat_list.row(item)
480
+ if idx < len(self.flat_files):
481
+ self.flat_files.pop(idx)
482
+ self.flat_list.takeItem(idx)
483
+ self.statusBar.showMessage(f"Flat frames: {len(self.flat_files)} remaining")
484
+ except Exception as e:
485
+ self.statusBar.showMessage(f"Error removing flat file: {e}")
486
+
487
+ # 选择波长标定帧并加入列表。
488
+ def _select_calib_files(self):
489
+ """Select wavelength calibration frame(s)."""
490
+ files, _ = QFileDialog.getOpenFileNames(
491
+ self, "Select Wavelength Calibration Frame(s)", self.last_selected_dir, "FITS Files (*.fits *.fit)"
492
+ )
493
+ if files:
494
+ self.last_selected_dir = str(Path(files[0]).parent)
495
+ # Initialize as list if not already
496
+ if not isinstance(self.calib_file, list):
497
+ self.calib_file = []
498
+ # Append new files to existing list
499
+ for f in files:
500
+ self.calib_file.append(f)
501
+ self.original_file_paths[Path(f).name] = f # Save original path
502
+ self.calib_list.addItem(Path(f).name)
503
+ self.statusBar.showMessage(f"Added {len(files)} calibration frame(s), total: {len(self.calib_file)}")
504
+
505
+ # 移除选中的标定帧。
506
+ def _remove_calib_file(self):
507
+ """Remove selected calibration file from list."""
508
+ for item in self.calib_list.selectedItems():
509
+ try:
510
+ idx = self.calib_list.row(item)
511
+ if isinstance(self.calib_file, list) and idx < len(self.calib_file):
512
+ self.calib_file.pop(idx)
513
+ self.calib_list.takeItem(idx)
514
+ self.statusBar.showMessage(f"Calibration frames: {len(self.calib_file)} remaining")
515
+ except Exception as e:
516
+ self.statusBar.showMessage(f"Error removing calibration file: {e}")
517
+
518
+ # 选择科学图像并加入列表。
519
+ def _select_raw_files(self):
520
+ """Select science image file(s)."""
521
+ files, _ = QFileDialog.getOpenFileNames(
522
+ self, "Select Science Image(s)", self.last_selected_dir, "FITS Files (*.fits *.fit)"
523
+ )
524
+ if files:
525
+ self.last_selected_dir = str(Path(files[0]).parent)
526
+ # Reset processing state when new files are selected
527
+ self._reset_processing_state()
528
+ # Append new files to existing list
529
+ for f in files:
530
+ self.raw_files.append(f)
531
+ self.original_file_paths[Path(f).name] = f # Save original path
532
+ self.raw_list.addItem(Path(f).name)
533
+ self.statusBar.showMessage(f"Added {len(files)} science image(s), total: {len(self.raw_files)}")
534
+
535
+ # 移除选中的科学图像。
536
+ def _remove_raw_file(self):
537
+ """Remove selected science image from list."""
538
+ for item in self.raw_list.selectedItems():
539
+ try:
540
+ idx = self.raw_list.row(item)
541
+ if idx < len(self.raw_files):
542
+ self.raw_files.pop(idx)
543
+ self.raw_list.takeItem(idx)
544
+ self.statusBar.showMessage(f"Science images: {len(self.raw_files)} remaining")
545
+ except Exception as e:
546
+ self.statusBar.showMessage(f"Error removing science image: {e}")
547
+
548
+ # 重置各处理阶段状态。
549
+ def _reset_processing_state(self):
550
+ """Reset processing state when new files are selected or processing is restarted."""
551
+ # Keep original file paths - don't reset them as they point to raw data
552
+ # Reset processed file paths in the lists
553
+ self.processing_state = {
554
+ 'stage_0_completed': False, # Overscan correction
555
+ 'stage_1_completed': False, # Bias correction
556
+ 'stage_2_completed': False, # Flat fielding
557
+ 'stage_3_completed': False, # Wavelength calibration
558
+ 'stage_4_completed': False, # Background subtraction
559
+ 'stage_5_completed': False, # Spectrum extraction
560
+
561
+ # Processed file paths
562
+ 'overscan_corrected_files': [], # Stage 0 output
563
+ 'master_bias_path': None, # Stage 1 output
564
+ 'master_flat_path': None, # Stage 2 output
565
+ 'wavelength_calib_path': None, # Stage 3 output
566
+ 'background_model_path': None, # Stage 4 output
567
+ 'extracted_spectra_path': None, # Stage 5 output
568
+ }
569
+ self.statusBar.showMessage("Processing state reset")
570
+
571
+ # 检查过扫描输出文件状态。
572
+ def _get_overscan_file_status(self) -> tuple:
573
+ """
574
+ Get the status of overscan processed files.
575
+
576
+ Returns:
577
+ Tuple of (existing_files: list, missing_files: list)
578
+ - existing_files: list of filenames that already have processed files
579
+ - missing_files: list of filenames that need processing
580
+ """
581
+ from pathlib import Path
582
+
583
+ # Get all files that should be processed
584
+ all_files = []
585
+
586
+ # Bias files
587
+ for bias_file in self.bias_files:
588
+ all_files.append(Path(bias_file).name)
589
+
590
+ # Flat files
591
+ for flat_file in self.flat_files:
592
+ all_files.append(Path(flat_file).name)
593
+
594
+ # Calibration files
595
+ calib_list = self.calib_file if isinstance(self.calib_file, list) else [self.calib_file]
596
+ for calib_file in calib_list:
597
+ if calib_file:
598
+ all_files.append(Path(calib_file).name)
599
+
600
+ # Science files
601
+ for science_file in self.raw_files:
602
+ all_files.append(Path(science_file).name)
603
+
604
+ # Check each file in output directory
605
+ output_dir = self.config.get_output_path()
606
+ overscan_dir = Path(output_dir) / 'step1_basic' / 'overscan_corrected'
607
+
608
+ existing_files = []
609
+ missing_files = []
610
+
611
+ for filename in all_files:
612
+ processed_file = overscan_dir / filename
613
+ if processed_file.exists():
614
+ existing_files.append(filename)
615
+ else:
616
+ missing_files.append(filename)
617
+
618
+ return existing_files, missing_files
619
+
620
+ # 检查指定阶段的输出文件。
621
+ def _check_processed_files_exist(self, stage_id: str) -> bool:
622
+ """
623
+ Check if processed files for a given stage still exist.
624
+
625
+ Args:
626
+ stage_id: Stage identifier (e.g., 'stage_0')
627
+
628
+ Returns:
629
+ True if all processed files exist, False otherwise
630
+ """
631
+ from pathlib import Path
632
+
633
+ if stage_id == 'stage_0':
634
+ # Check if Stage 0 was marked as completed
635
+ if not self.processing_state.get('stage_0_completed', False):
636
+ return False # Not completed yet, need to process
637
+
638
+ # Check all files that should have been processed
639
+ # Get all input files
640
+ all_files = []
641
+
642
+ # Bias files
643
+ for bias_file in self.bias_files:
644
+ all_files.append(Path(bias_file).name)
645
+
646
+ # Flat files
647
+ for flat_file in self.flat_files:
648
+ all_files.append(Path(flat_file).name)
649
+
650
+ # Calibration files
651
+ calib_list = self.calib_file if isinstance(self.calib_file, list) else [self.calib_file]
652
+ for calib_file in calib_list:
653
+ if calib_file:
654
+ all_files.append(Path(calib_file).name)
655
+
656
+ # Science files
657
+ for science_file in self.raw_files:
658
+ all_files.append(Path(science_file).name)
659
+
660
+ # Check each file in output directory
661
+ output_dir = self.config.get_output_path()
662
+ overscan_dir = Path(output_dir) / 'step1_basic' / 'overscan_corrected'
663
+
664
+ if not overscan_dir.exists():
665
+ return False # Directory doesn't exist
666
+
667
+ for filename in all_files:
668
+ if not (overscan_dir / filename).exists():
669
+ return False # File missing
670
+
671
+ return True # All files exist
672
+
673
+ elif stage_id == 'stage_1' and self.processing_state['stage_1_completed']:
674
+ return Path(self.processing_state['master_bias_path']).exists() if self.processing_state['master_bias_path'] else False
675
+
676
+ elif stage_id == 'stage_2' and self.processing_state['stage_2_completed']:
677
+ return Path(self.processing_state['master_flat_path']).exists() if self.processing_state['master_flat_path'] else False
678
+
679
+ elif stage_id == 'stage_3' and self.processing_state['stage_3_completed']:
680
+ return Path(self.processing_state['wavelength_calib_path']).exists() if self.processing_state['wavelength_calib_path'] else False
681
+
682
+ elif stage_id == 'stage_4' and self.processing_state['stage_4_completed']:
683
+ return Path(self.processing_state['background_model_path']).exists() if self.processing_state['background_model_path'] else False
684
+
685
+ elif stage_id == 'stage_5' and self.processing_state['stage_5_completed']:
686
+ return Path(self.processing_state['extracted_spectra_path']).exists() if self.processing_state['extracted_spectra_path'] else False
687
+
688
+ return False # Stage not completed or no path stored
689
+
690
+ # 判断处理阶段是否需要重跑。
691
+ def _should_reprocess_stage(self, stage_id: str) -> tuple:
692
+ """
693
+ Determine if a stage should be reprocessed.
694
+ Note: stage_0 (overscan) has its own dialog logic in the processing section.
695
+
696
+ Args:
697
+ stage_id: Stage identifier
698
+
699
+ Returns:
700
+ Tuple of (should_reprocess: bool, user_choice_made: bool, force_all: bool)
701
+ - should_reprocess: True if stage should be reprocessed
702
+ - user_choice_made: True if user was asked and made a choice (Yes/No)
703
+ - force_all: True if user chose to reprocess all files (not just missing ones)
704
+ """
705
+ # stage_0 (overscan) has its own comprehensive dialog logic in the processing section
706
+ if stage_id == 'stage_0':
707
+ # Always return True to let the processing section handle the dialogs
708
+ return True, False, False
709
+
710
+ # If stage not marked as completed, definitely need to process
711
+ if not self.processing_state.get(f'{stage_id}_completed', False):
712
+ return True, False, False # Need to reprocess, no user choice needed, not force_all
713
+
714
+ # Check if processed files still exist
715
+ if not self._check_processed_files_exist(stage_id):
716
+ # Files missing, need to reprocess, no user choice needed
717
+ return True, False, False
718
+
719
+ # All files exist - always reprocess (overwrite) without asking
720
+ return True, False, True # Reprocess, no user choice made, force_all
721
+
722
+ # 验证输入并启动完整处理流程。
723
+ def _run_full_pipeline(self):
724
+ """Execute full processing pipeline."""
725
+ # Validate inputs
726
+ if not self.raw_files:
727
+ QMessageBox.warning(self, "Error", "Please select at least one science image")
728
+ return
729
+ if not self.bias_files:
730
+ QMessageBox.warning(self, "Error", "Please select bias frames")
731
+ return
732
+ if not self.flat_files:
733
+ QMessageBox.warning(self, "Error", "Please select flat frames")
734
+ return
735
+
736
+ # Handle calib files
737
+ calib_files = self.calib_file if isinstance(self.calib_file, list) else [self.calib_file]
738
+ calib_files = [f for f in calib_files if f]
739
+
740
+ if not calib_files:
741
+ QMessageBox.warning(self, "Error", "Please select a wavelength calibration frame")
742
+ return
743
+
744
+ # Ask if user wants to process all or just first
745
+ if len(self.raw_files) > 1:
746
+ auto_process = self.config.get_bool('reduce', 'auto_process_all_images', False)
747
+ if not auto_process:
748
+ reply = QMessageBox.question(
749
+ self, "Multiple Science Images",
750
+ f"Found {len(self.raw_files)} science image(s).\n"
751
+ "Process all of them sequentially?",
752
+ QMessageBox.Yes | QMessageBox.No
753
+ )
754
+ if reply == QMessageBox.No:
755
+ # Use only first
756
+ science_files = self.raw_files[:1]
757
+ else:
758
+ science_files = self.raw_files
759
+ else:
760
+ science_files = self.raw_files
761
+ else:
762
+ science_files = self.raw_files
763
+
764
+ # Disable controls
765
+ self.run_all_btn.setEnabled(False)
766
+ self.stop_btn.setEnabled(True)
767
+
768
+ # Calculate total images to process
769
+ # Calib file count
770
+ calib_count = 0
771
+ if self.calib_file:
772
+ if isinstance(self.calib_file, list):
773
+ calib_count = len(self.calib_file)
774
+ else:
775
+ calib_count = 1 # Single file as path
776
+
777
+ # Total images that will be processed (all images go through overscan)
778
+ total_imgs = len(science_files) + len(self.bias_files) + len(self.flat_files) + calib_count
779
+
780
+ # Calculate total data size
781
+ self.processing_total_size_bytes = 0
782
+ all_files_to_process = science_files + self.bias_files + self.flat_files + calib_files
783
+ for f_path in all_files_to_process:
784
+ try:
785
+ self.processing_total_size_bytes += Path(f_path).stat().st_size
786
+ except Exception as e:
787
+ logger.warning(f"Could not get size of file {f_path}: {e}")
788
+
789
+ # Process each science image
790
+ self.log_text.append("=" * 60)
791
+ self.log_text.append(f"STARTING PIPELINE - Processing {len(science_files)} science image(s)")
792
+ self.log_text.append(f"Total images to be processed: {total_imgs} (bias: {len(self.bias_files)}, flat: {len(self.flat_files)}, calib: {calib_count}, science: {len(science_files)})")
793
+ self.log_text.append("=" * 60)
794
+
795
+ self.processing_start_time = time.time()
796
+ self.worker_thread = ProcessingWorker(
797
+ self.pipeline,
798
+ science_files,
799
+ self.bias_files,
800
+ self.flat_files,
801
+ calib_files
802
+ )
803
+ self.worker_thread.progress_updated.connect(self._on_progress)
804
+ self.worker_thread.execution_complete.connect(
805
+ lambda success, msg: self._on_execution_complete(success, msg, len(science_files)-1, len(science_files), total_imgs)
806
+ )
807
+ self.worker_thread.start()
808
+
809
+ # 按所选阶段依次执行处理流程。
810
+ def _run_selected_pipeline(self):
811
+ """Execute only the selected processing steps."""
812
+ # Reset run-scoped cache to avoid reusing stale paths from previous runs.
813
+ self._flat_files_after_bias = None
814
+ self._flat_files_after_step1 = None
815
+ self._step3_mask = None
816
+
817
+ # Check which steps are selected
818
+ selected_stages = []
819
+ for stage_id, checkbox in self.stage_checkboxes.items():
820
+ if checkbox.isChecked():
821
+ selected_stages.append(stage_id)
822
+
823
+ step1_selected = "stage_0" in selected_stages
824
+ step1_do_overscan = step1_selected and self.step1_substep_checkboxes["overscan"].isChecked()
825
+ step1_do_bias = step1_selected and self.step1_substep_checkboxes["bias"].isChecked()
826
+ step1_do_cosmic = step1_selected and self.step1_substep_checkboxes["cosmic"].isChecked()
827
+
828
+ if not selected_stages:
829
+ QMessageBox.warning(self, "Error", "Please select at least one processing step")
830
+ return
831
+
832
+ # Validate inputs based on selected steps
833
+ if not self.raw_files:
834
+ QMessageBox.warning(self, "Error", "Please select at least one science image")
835
+ return
836
+
837
+ # Log selected steps
838
+ self.log_text.append("\n" + "=" * 60)
839
+ self.log_text.append("SELECTED STEPS:")
840
+ stage_names = {
841
+ "stage_0": "Step 1: Basic Pre-processing (Overscan/Bias/Cosmic)",
842
+ "stage_1": "Step 2: Aperture Tracing",
843
+ "stage_2": "Step 3: Scattered Light Subtraction",
844
+ "stage_3": "Step 4: 2D Flat-Field Correction",
845
+ "stage_4": "Step 5: 1D Extraction",
846
+ "stage_5": "Step 6: De-blazing",
847
+ "stage_6": "Step 7: Wavelength Calibration",
848
+ }
849
+ for stage_id in selected_stages:
850
+ self.log_text.append(f" ✓ {stage_names.get(stage_id, stage_id)}")
851
+ self.log_text.append("=" * 60)
852
+
853
+ # Validate required inputs
854
+ if step1_do_bias and not self.bias_files:
855
+ QMessageBox.warning(self, "Error", "Step 1 (Basic Pre-processing) requires bias frames")
856
+ return
857
+ if "stage_1" in selected_stages and not self.flat_files:
858
+ QMessageBox.warning(self, "Error", "Step 2 (Aperture Tracing) requires flat frames")
859
+ return
860
+ if "stage_5" in selected_stages:
861
+ # Check if we have any calibration files
862
+ has_calib = False
863
+ if isinstance(self.calib_file, list):
864
+ has_calib = len(self.calib_file) > 0
865
+ else:
866
+ has_calib = bool(self.calib_file) # Could be string or None
867
+
868
+ if not has_calib:
869
+ QMessageBox.warning(self, "Error", "Step 6 (Wavelength Calibration) requires calibration frames")
870
+ return
871
+
872
+ # Check if any selected stages need reprocessing
873
+ need_reprocess = False
874
+ user_chose_no = False
875
+ force_reprocess_stages = set() # Track which stages should force reprocess all files
876
+
877
+ for stage_id in selected_stages:
878
+ should_reprocess, user_choice_made, force_all = self._should_reprocess_stage(stage_id)
879
+
880
+ if user_choice_made and not should_reprocess:
881
+ # User was asked and chose NOT to reprocess this stage
882
+ user_chose_no = True
883
+ # User chose NOT to reprocess, cancel the entire operation
884
+ self.statusBar.showMessage("Processing cancelled by user")
885
+ self.log_text.append("\n" + "=" * 60)
886
+ self.log_text.append("PROCESSING CANCELLED")
887
+ self.log_text.append("User chose not to reprocess existing results")
888
+ self.log_text.append("=" * 60)
889
+ self.run_all_btn.setEnabled(True)
890
+ self.run_selected_btn.setEnabled(True)
891
+ self.stop_btn.setEnabled(False)
892
+ return
893
+
894
+ if should_reprocess:
895
+ need_reprocess = True
896
+ # Reset the stage completion flag
897
+ self.processing_state[f'{stage_id}_completed'] = False
898
+
899
+ # If user chose to reprocess all files (not just missing ones)
900
+ if force_all:
901
+ force_reprocess_stages.add(stage_id)
902
+
903
+ # If no stages need reprocessing (and user wasn't asked or said yes to all)
904
+ if not need_reprocess:
905
+ # This means all stages are already processed and files exist
906
+ # Show a brief status message but don't pop up a dialog
907
+ self.statusBar.showMessage("All selected steps are already processed")
908
+ self.log_text.append("\n" + "=" * 60)
909
+ self.log_text.append("PROCESSING SKIPPED")
910
+ self.log_text.append("All selected steps are already processed")
911
+ self.log_text.append("=" * 60)
912
+ self.run_all_btn.setEnabled(True)
913
+ self.run_selected_btn.setEnabled(True)
914
+ self.stop_btn.setEnabled(False)
915
+ return
916
+
917
+ # Ask about multiple science images
918
+ if len(self.raw_files) > 1:
919
+ auto_process = self.config.get_bool('reduce', 'auto_process_all_images', False)
920
+ if not auto_process:
921
+ reply = QMessageBox.question(
922
+ self, "Multiple Science Images",
923
+ f"Found {len(self.raw_files)} science image(s).\n"
924
+ "Process all of them sequentially?",
925
+ QMessageBox.Yes | QMessageBox.No
926
+ )
927
+ science_files = self.raw_files if reply == QMessageBox.Yes else self.raw_files[:1]
928
+ else:
929
+ science_files = self.raw_files
930
+ else:
931
+ science_files = self.raw_files
932
+
933
+ # Calculate total images to process
934
+ # Calib file count
935
+ calib_count = 0
936
+ if self.calib_file:
937
+ if isinstance(self.calib_file, list):
938
+ calib_count = len(self.calib_file)
939
+ else:
940
+ calib_count = 1 # Single file as path
941
+
942
+ # Total images that will be processed (all images go through overscan if stage_0 is selected)
943
+ # Otherwise, only science images are processed
944
+ if step1_do_overscan:
945
+ total_imgs = len(science_files) + len(self.bias_files) + len(self.flat_files) + calib_count
946
+ else:
947
+ total_imgs = len(science_files)
948
+
949
+ # Calculate total data size for processing statistics
950
+ calib_files_list = list(self.calib_file) if isinstance(self.calib_file, list) else ([self.calib_file] if self.calib_file else [])
951
+ all_input_files = science_files + self.bias_files + self.flat_files + calib_files_list
952
+ self.processing_total_size_bytes = 0
953
+ for f_path in all_input_files:
954
+ try:
955
+ self.processing_total_size_bytes += Path(f_path).stat().st_size
956
+ except Exception as e:
957
+ logger.warning(f"Could not get size of file {f_path}: {e}")
958
+ self.processing_start_time = time.time()
959
+
960
+ # Disable controls
961
+ self.run_all_btn.setEnabled(False)
962
+ self.run_selected_btn.setEnabled(False)
963
+ self.stop_btn.setEnabled(True)
964
+
965
+ # --- Data Flow Management for Selected Steps ---
966
+ # Initialize file lists for each type. These will be updated as steps run.
967
+ current_science_files = list(science_files)
968
+ current_flat_files = list(self.flat_files)
969
+ current_bias_files = list(self.bias_files)
970
+ current_calib_files = list(self.calib_file) if isinstance(self.calib_file, list) else [self.calib_file]
971
+
972
+ # --- Smart Input Finder for Step 1 ---
973
+ # Before running any Step 1 sub-step, determine the correct input files by
974
+ # checking for existing processed outputs from previous sub-steps.
975
+ output_dir = Path(self.config.get_output_path())
976
+ overscan_dir = output_dir / 'step1_basic' / 'overscan_corrected'
977
+ bias_dir = output_dir / 'step1_basic' / 'bias_subtracted'
978
+ cosmic_dir = output_dir / 'step1_basic' / 'cosmic_corrected'
979
+
980
+ # 查找文件最新的已处理版本。
981
+ def find_latest_input(raw_file_path, check_cosmic=False, check_bias=False, check_overscan=False):
982
+ """Find the most processed version of a file."""
983
+ raw_name = Path(raw_file_path).name
984
+ if check_cosmic:
985
+ cosmic_path = cosmic_dir / raw_name
986
+ if cosmic_path.exists():
987
+ return str(cosmic_path)
988
+ if check_bias:
989
+ bias_path = bias_dir / raw_name
990
+ if bias_path.exists():
991
+ return str(bias_path)
992
+ if check_overscan:
993
+ overscan_path = overscan_dir / raw_name
994
+ if overscan_path.exists():
995
+ return str(overscan_path)
996
+ return raw_file_path
997
+
998
+ # Before running any stage, find the correct input files.
999
+ # If a Step 1 sub-step is selected, its inputs are the raw files.
1000
+ # Otherwise, find the latest processed version.
1001
+ if not step1_do_overscan:
1002
+ # If not doing overscan, inputs for bias/cosmic/later stages might be overscan-corrected.
1003
+ current_science_files = [find_latest_input(f, check_overscan=True) for f in science_files]
1004
+ current_flat_files = [find_latest_input(f, check_overscan=True) for f in self.flat_files]
1005
+ current_bias_files = [find_latest_input(f, check_overscan=True) for f in self.bias_files]
1006
+ current_calib_files = [find_latest_input(f, check_overscan=True) for f in current_calib_files]
1007
+
1008
+ if not step1_do_bias:
1009
+ # If not doing bias, inputs for cosmic/later stages might be bias-corrected.
1010
+ current_science_files = [find_latest_input(f, check_overscan=True, check_bias=True) for f in science_files]
1011
+ current_flat_files = [find_latest_input(f, check_overscan=True, check_bias=True) for f in self.flat_files]
1012
+ current_calib_files = [find_latest_input(f, check_overscan=True, check_bias=True) for f in current_calib_files]
1013
+
1014
+ if not step1_do_cosmic:
1015
+ # If not doing cosmic, inputs for later stages might be cosmic-corrected.
1016
+ current_science_files = [find_latest_input(f, check_overscan=True, check_bias=True, check_cosmic=True) for f in science_files]
1017
+
1018
+
1019
+ if "stage_0" in selected_stages and step1_do_overscan:
1020
+ self.log_text.append("\n" + "=" * 60)
1021
+ self.log_text.append("STEP 1.1: OVERSCAN CORRECTION")
1022
+ self.log_text.append("Processing all image types: Bias, Flat, Calibration, Science")
1023
+ self.log_text.append("=" * 60)
1024
+
1025
+ try:
1026
+ from specproc.core.basic_reduction import process_overscan_stage
1027
+
1028
+ # Collect all files to process in the correct order
1029
+ all_files_to_process = []
1030
+ all_files_to_process.extend(current_bias_files)
1031
+ all_files_to_process.extend(current_flat_files)
1032
+ all_files_to_process.extend(current_calib_files)
1033
+ all_files_to_process.extend(current_science_files)
1034
+ all_files_to_process = sorted(list(set(all_files_to_process))) # Unique list
1035
+
1036
+ overscan_kwargs = {
1037
+ 'overscan_start_column': self.config.get_int('data', 'overscan_start_column', -1),
1038
+ 'overscan_method': self.config.get('data', 'overscan_method', 'mean_only'),
1039
+ 'overscan_smooth_window': self.config.get_int('data', 'overscan_smooth_window', -1),
1040
+ 'overscan_poly_order': self.config.get_int('data', 'overscan_poly_order', 3),
1041
+ 'overscan_poly_type': self.config.get('data', 'overscan_poly_type', 'legendre'),
1042
+ }
1043
+ processed_files = process_overscan_stage(
1044
+ all_files_to_process, self.config.get_output_path(), **overscan_kwargs
1045
+ )
1046
+
1047
+ # Create a map of original filename to processed path
1048
+ processed_map = {Path(p).name: p for p in processed_files}
1049
+
1050
+ # Update current file lists to point to the new overscan-corrected files
1051
+ current_science_files = [processed_map.get(Path(f).name, f) for f in current_science_files]
1052
+ current_flat_files = [processed_map.get(Path(f).name, f) for f in current_flat_files]
1053
+ current_bias_files = [processed_map.get(Path(f).name, f) for f in current_bias_files]
1054
+ current_calib_files = [processed_map.get(Path(f).name, f) for f in current_calib_files]
1055
+
1056
+ self.log_text.append(f" ✓ Overscan correction complete: {len(processed_files)} images processed")
1057
+
1058
+ except Exception as e:
1059
+ self.log_text.append(f" ✗ Overscan correction failed: {e}")
1060
+ raise
1061
+ elif "stage_0" in selected_stages:
1062
+ self.log_text.append("\n" + "=" * 60)
1063
+ self.log_text.append("STEP 1.1: OVERSCAN CORRECTION")
1064
+ self.log_text.append(" Skipped (option unchecked)")
1065
+ self.log_text.append("=" * 60)
1066
+
1067
+ # --- Step 1.2: Bias Correction ---
1068
+ master_bias = None
1069
+ if "stage_0" in selected_stages and step1_do_bias and self.bias_files:
1070
+ try:
1071
+ self.log_text.append("\n============================================================")
1072
+ self.log_text.append("STEP 1.2: BIAS SUBTRACTION")
1073
+ from specproc.core.basic_reduction import process_bias_stage, BiasCorrector, read_fits_image, write_fits_image
1074
+
1075
+ bias_kwargs = {
1076
+ 'combine_method': self.config.get('reduce.bias', 'combine_method', 'median'),
1077
+ 'combine_sigma': self.config.get_float('reduce.bias', 'combine_sigma', 3.0),
1078
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
1079
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png'),
1080
+ }
1081
+ master_bias, master_bias_path = process_bias_stage(
1082
+ current_bias_files, self.config.get_output_path(), **bias_kwargs
1083
+ )
1084
+ self._master_bias = master_bias # Cache for later use
1085
+
1086
+ # Apply bias to all other relevant file types
1087
+ corrector = BiasCorrector(**bias_kwargs)
1088
+ corrector.master_bias = master_bias
1089
+
1090
+ # Apply to science, flat, and calib files
1091
+ files_to_correct = {
1092
+ "science": current_science_files,
1093
+ "flat": current_flat_files,
1094
+ "calib": current_calib_files
1095
+ }
1096
+ output_dir = Path(self.config.get_output_path()) / 'step1_basic' / 'bias_subtracted'
1097
+ output_dir.mkdir(parents=True, exist_ok=True)
1098
+
1099
+ for file_type, file_list in files_to_correct.items():
1100
+ if not file_list: continue
1101
+ self.log_text.append(f" Applying bias correction to {len(file_list)} {file_type} frames...")
1102
+ corrected_list = []
1103
+ for f in file_list:
1104
+ img, hdr = read_fits_image(f)
1105
+ corrected_img = corrector.apply_bias_correction(img)
1106
+ out_path = output_dir / Path(f).name
1107
+ hdr['BIASCOR'] = (True, 'Bias correction applied')
1108
+
1109
+ # Manually save diagnostic plot for each corrected image
1110
+ if bias_kwargs['save_plots']:
1111
+ from specproc.plotting.spectra_plotter import plot_2d_image_to_file
1112
+ plot_file = output_dir / f'{Path(f).stem}_bias_corrected.{bias_kwargs["fig_format"]}'
1113
+ plot_2d_image_to_file(corrected_img, str(plot_file),
1114
+ f"Bias Corrected {file_type.capitalize()} Image")
1115
+
1116
+ write_fits_image(str(out_path), corrected_img, header=hdr, dtype='float32')
1117
+ corrected_list.append(str(out_path))
1118
+
1119
+ # Update the current file list for this type
1120
+ if file_type == "science": current_science_files = corrected_list
1121
+ elif file_type == "flat": current_flat_files = corrected_list
1122
+ elif file_type == "calib": current_calib_files = corrected_list
1123
+
1124
+ self.log_text.append(f" ✓ Bias correction application complete.")
1125
+
1126
+ except Exception as e:
1127
+ self.log_text.append(f" ✗ Bias correction failed: {e}")
1128
+ raise
1129
+ elif "stage_0" in selected_stages:
1130
+ self.log_text.append("\n============================================================")
1131
+ self.log_text.append("STEP 1.2: BIAS SUBTRACTION")
1132
+ self.log_text.append(" Skipped (option unchecked or no bias files)")
1133
+ self.log_text.append("============================================================")
1134
+
1135
+ # Apply cosmic-ray removal in Step1 basic preprocessing (science only).
1136
+ if "stage_0" in selected_stages and step1_do_cosmic:
1137
+ if self.config.get_bool('reduce', 'cosmic_enabled', True):
1138
+ from specproc.core.basic_reduction import process_cosmic_stage
1139
+
1140
+ self.log_text.append("\n============================================================")
1141
+ self.log_text.append("STEP 1.3: COSMIC RAY REMOVAL")
1142
+ self.log_text.append("============================================================")
1143
+ self.log_text.append(" Applying cosmic ray removal to SCIENCE frames only...")
1144
+
1145
+ cosmic_kwargs = {
1146
+ 'cosmic_sigclip': self.config.get_float('reduce', 'cosmic_sigclip', 5.0),
1147
+ 'cosmic_objlim': self.config.get_float('reduce', 'cosmic_objlim', 5.0),
1148
+ 'cosmic_gain': self.config.get_float('reduce', 'cosmic_gain', 1.0),
1149
+ 'cosmic_readnoise': self.config.get_float('reduce', 'cosmic_readnoise', 5.0),
1150
+ }
1151
+
1152
+ if current_science_files:
1153
+ science_cosmic_outputs = process_cosmic_stage(
1154
+ current_science_files,
1155
+ output_dir_base=self.config.get_output_path(),
1156
+ **cosmic_kwargs
1157
+ )
1158
+ current_science_files = science_cosmic_outputs
1159
+ self.log_text.append(" ✓ Cosmic ray removal complete.")
1160
+ else:
1161
+ self.log_text.append(" No science files to process for cosmic rays.")
1162
+ else:
1163
+ self.log_text.append("\n============================================================")
1164
+ self.log_text.append("STEP 1.3: COSMIC RAY REMOVAL")
1165
+ self.log_text.append("============================================================")
1166
+ self.log_text.append(" Cosmic ray removal disabled by config")
1167
+ elif "stage_0" in selected_stages:
1168
+ self.log_text.append("\n============================================================")
1169
+ self.log_text.append("STEP 1.3: COSMIC RAY REMOVAL")
1170
+ self.log_text.append("============================================================")
1171
+ self.log_text.append(" Skipped (option unchecked)")
1172
+
1173
+ # Update processing state for Step 1
1174
+ if "stage_0" in selected_stages:
1175
+ self.processing_state['stage_1_completed'] = True
1176
+ if master_bias is not None:
1177
+ self.processing_state['master_bias_path'] = str(master_bias_path)
1178
+
1179
+ self.log_text.append("\n============================================================")
1180
+ self.log_text.append("Step 1 pre-processing complete.")
1181
+ self.log_text.append(f" Output directory: {Path(self.config.get_output_path()) / 'step1_basic'}")
1182
+ self.log_text.append(f" Science files ready for next steps: {len(current_science_files)}")
1183
+ self.log_text.append(f" Flat files ready for next steps: {len(current_flat_files)}")
1184
+ self.log_text.append(f" Calibration files ready for next steps: {len(current_calib_files)}")
1185
+ self.log_text.append("============================================================")
1186
+
1187
+ if "stage_1" in selected_stages and self.flat_files:
1188
+ self.log_text.append("\n============================================================")
1189
+ self.log_text.append("STEP 2: APERTURE TRACING")
1190
+ from specproc.core.order_tracing import process_order_tracing_stage
1191
+ self.log_text.append(f" Using {len(current_flat_files)} flat frames as input...")
1192
+
1193
+ trace_kwargs = {
1194
+ 'output_dir_base': self.config.get_output_path(),
1195
+ 'combine_method': self.config.get('reduce.flat', 'combine_method', 'median'),
1196
+ 'combine_sigma': self.config.get_float('reduce.bias', 'combine_sigma', 3.0),
1197
+ 'mosaic_maxcount': self.config.get_float('reduce.flat', 'mosaic_maxcount', 65535),
1198
+ 'snr_threshold': self.config.get_float('reduce.trace', 'snr_threshold', 5.0),
1199
+ 'gap_fill_factor': self.config.get_float('reduce.trace', 'gap_fill_factor', 1.6),
1200
+ 'min_trace_coverage': self.config.get_float('reduce.trace', 'min_trace_coverage', 0.20),
1201
+ 'trace_degree': self.config.get_int('reduce.trace', 'degree', 4),
1202
+ 'width_cheb_degree': self.config.get_int('reduce.trace', 'width_cheb_degree', 3),
1203
+ 'aperture_boundary_snr': self.config.get_float('reduce.trace', 'aperture_boundary_snr', 3.0),
1204
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
1205
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png'),
1206
+ }
1207
+
1208
+ flat_field, apertures = process_order_tracing_stage(
1209
+ current_flat_files, **trace_kwargs
1210
+ )
1211
+ num_aps = getattr(apertures, 'napertures', getattr(apertures, 'norders', 0)) if apertures else 0
1212
+ self.log_text.append(f" ✓ Aperture tracing complete: {num_aps} apertures detected.")
1213
+ self._flat_field = flat_field
1214
+ self._apertures = apertures
1215
+ self.processing_state['stage_2_completed'] = True
1216
+
1217
+ # Decoupled model flow: only build flat profiles in Step 2 when Step 3 is not selected.
1218
+ if "stage_2" not in selected_stages and flat_field is not None and apertures is not None:
1219
+ try:
1220
+ from specproc.core.flat_correction import FlatCorrectionModelBuilder
1221
+ from specproc.utils.fits_io import write_fits_image
1222
+ from specproc.plotting.spectra_plotter import plot_2d_image_to_file
1223
+ import pickle
1224
+
1225
+ self.log_text.append(" Step 3 not selected: building blaze/cross profiles from Step 2 MasterFlat...")
1226
+
1227
+ flat_source = np.clip(flat_field.flat_data.astype(np.float32), 1e-6, None)
1228
+ processor = FlatCorrectionModelBuilder()
1229
+ processor.flat_data = flat_field.flat_data
1230
+ processor.flat_mask = flat_field.flat_mask
1231
+
1232
+ flat_sens, blaze_profiles, cross_profiles, smoothed_model, pixel_flat, illum = (
1233
+ processor.build_order_response_map(
1234
+ apertures,
1235
+ source_image=flat_source,
1236
+ blaze_smooth_factor=self.config.get_float('reduce.flat', 'blaze_smooth_factor', 1.0),
1237
+ width_smooth_window=self.config.get_int('reduce.flat', 'width_smooth_window', 41),
1238
+ profile_bin_step=self.config.get_float('reduce.flat', 'profile_bin_step', 0.01),
1239
+ n_profile_segments=self.config.get_int('reduce.flat', 'n_profile_segments', 100),
1240
+ profile_smooth_sigma=self.config.get_float('reduce.flat', 'profile_smooth_sigma', 6.0),
1241
+ pixel_flat_min=self.config.get_float('reduce.flat', 'pixel_flat_min', 0.5),
1242
+ pixel_flat_max=self.config.get_float('reduce.flat', 'pixel_flat_max', 1.5),
1243
+ fringe_orders=self.config.get_int('reduce.flat', 'fringe_orders', 20),
1244
+ )
1245
+ )
1246
+
1247
+ flat_field.flat_sens = flat_sens
1248
+ flat_field.blaze_profiles = blaze_profiles
1249
+ flat_field.cross_profiles = cross_profiles
1250
+ flat_field.smoothed_model = smoothed_model
1251
+ flat_field.pixel_flat = pixel_flat
1252
+ flat_field.illumination_flat = illum
1253
+ self._flat_field = flat_field
1254
+ self.pipeline.state.flat_field = flat_field
1255
+
1256
+ step2_dir = Path(self.config.get_output_path()) / 'step2_trace'
1257
+ step2_dir.mkdir(parents=True, exist_ok=True)
1258
+ with open(step2_dir / 'blaze_profiles.pkl', 'wb') as fb:
1259
+ pickle.dump(blaze_profiles, fb)
1260
+ with open(step2_dir / 'cross_profiles.pkl', 'wb') as fc:
1261
+ pickle.dump(cross_profiles, fc)
1262
+ if smoothed_model is not None:
1263
+ write_fits_image(str(step2_dir / 'model_flat_2d.fits'), smoothed_model.astype('float32'), dtype='float32')
1264
+ write_fits_image(str(step2_dir / 'flat_smoothed_model.fits'), smoothed_model.astype('float32'), dtype='float32')
1265
+ if pixel_flat is not None:
1266
+ write_fits_image(str(step2_dir / 'flat_pixel_2d.fits'), pixel_flat.astype('float32'), dtype='float32')
1267
+
1268
+ if self.config.get_bool('reduce', 'save_plots', True):
1269
+ fig_format = self.config.get('reduce', 'fig_format', 'png')
1270
+ if smoothed_model is not None:
1271
+ plot_2d_image_to_file(smoothed_model, str(step2_dir / f'flat_smoothed_model.{fig_format}'), 'Reconstructed 2D Flat Model (Step 2)')
1272
+ if pixel_flat is not None:
1273
+ plot_2d_image_to_file(pixel_flat, str(step2_dir / f'flat_pixel_2d.{fig_format}'), 'Pixel-to-Pixel Flat (Step 2)', vmin=0.85, vmax=1.15)
1274
+
1275
+ self.log_text.append(" ✓ Saved flat model products to step2_trace (blaze/cross/model/pixel-flat + diagnostics)")
1276
+ except Exception as e:
1277
+ self.log_text.append(f" ! Failed to build Step 2 flat model products: {e}")
1278
+
1279
+ # Attempt to load flat_field and apertures if needed by downstream stages
1280
+ needs_flat = any(s in selected_stages for s in ["stage_2", "stage_3", "stage_4", "stage_5"])
1281
+ if needs_flat:
1282
+ flat_field = getattr(self, '_flat_field', getattr(self.pipeline.state, 'flat_field', None))
1283
+ apertures = getattr(self, '_apertures', getattr(self.pipeline.state, 'apertures', None))
1284
+
1285
+ if flat_field is None or apertures is None:
1286
+ self.log_text.append(" Loading MasterFlat and Apertures from disk...")
1287
+ try:
1288
+ from specproc.utils.fits_io import read_fits_image
1289
+ from specproc.core.data_structures import ApertureSet, ApertureLocation, FlatField
1290
+
1291
+ output_dir = Path(self.config.get_output_path())
1292
+ flat_path_step3 = output_dir / 'step3_scatterlight' / 'MasterFlat.fits'
1293
+ flat_path_step2 = output_dir / 'step2_trace' / 'MasterFlat.fits'
1294
+ flat_path = flat_path_step3 if flat_path_step3.exists() else flat_path_step2
1295
+ coefs_path_ap = output_dir / 'step2_trace' / 'Apertures_trace_coefs.json'
1296
+ coefs_path_ord = output_dir / 'step2_trace' / 'Orders_trace_coefs.json'
1297
+ coefs_path = coefs_path_ap if coefs_path_ap.exists() else coefs_path_ord
1298
+
1299
+ if flat_path.exists() and coefs_path.exists():
1300
+ flat_img, _ = read_fits_image(str(flat_path))
1301
+
1302
+ with open(coefs_path, 'r') as f:
1303
+ coefs_data = json.load(f)
1304
+
1305
+ loaded_apertures = ApertureSet()
1306
+ width = flat_img.shape[1]
1307
+ apertures_data = coefs_data.get('apertures', coefs_data.get('orders', {}))
1308
+ for ap_id_str, ap_data in apertures_data.items():
1309
+ ap = ApertureLocation(
1310
+ aperture=int(ap_id_str), order=int(ap_id_str), # Order is a placeholder until Step 7
1311
+ is_chebyshev=True, domain=(0.0, float(width - 1))
1312
+ )
1313
+ if 'center_arr' in ap_data:
1314
+ ap.center_arr = np.array(ap_data['center_arr'])
1315
+ if 'w_up_cheb' in ap_data:
1316
+ ap.w_up_cheb_coef = np.array(ap_data['w_up_cheb'])
1317
+ if 'w_low_cheb' in ap_data:
1318
+ ap.w_low_cheb_coef = np.array(ap_data['w_low_cheb'])
1319
+ loaded_apertures.add_aperture(ap)
1320
+
1321
+ flat_field = FlatField(
1322
+ flat_data=flat_img, flat_mask=None, flat_norm=None,
1323
+ flat_sens=None, scattered_light=None, smoothed_model=None,
1324
+ pixel_flat=None, illumination_flat=None, aperture_set=loaded_apertures
1325
+ )
1326
+ apertures = loaded_apertures
1327
+
1328
+ # Load decoupled flat-model products.
1329
+ # Priority: Step 3 -> Step 2 -> legacy Step 4 outputs.
1330
+ model_dirs = [
1331
+ output_dir / 'step3_scatterlight',
1332
+ output_dir / 'step2_trace',
1333
+ output_dir / 'step4_flat_corrected',
1334
+ ]
1335
+ loaded_model_dir = None
1336
+ for model_dir in model_dirs:
1337
+ blaze_pkl = model_dir / 'blaze_profiles.pkl'
1338
+ if blaze_pkl.exists():
1339
+ try:
1340
+ import pickle
1341
+ with open(blaze_pkl, 'rb') as fb:
1342
+ flat_field.blaze_profiles = pickle.load(fb)
1343
+ loaded_model_dir = model_dir.name
1344
+ break
1345
+ except Exception as e:
1346
+ self.log_text.append(f" ! Failed to load {blaze_pkl.name} from {model_dir.name}: {e}")
1347
+
1348
+ for model_dir in model_dirs:
1349
+ cross_pkl = model_dir / 'cross_profiles.pkl'
1350
+ if cross_pkl.exists():
1351
+ try:
1352
+ import pickle
1353
+ with open(cross_pkl, 'rb') as fb:
1354
+ flat_field.cross_profiles = pickle.load(fb)
1355
+ break
1356
+ except Exception as e:
1357
+ self.log_text.append(f" ! Failed to load {cross_pkl.name} from {model_dir.name}: {e}")
1358
+
1359
+ for model_dir in model_dirs:
1360
+ model_2d_path = model_dir / 'model_flat_2d.fits'
1361
+ if model_2d_path.exists():
1362
+ try:
1363
+ model_img, _ = read_fits_image(str(model_2d_path))
1364
+ flat_field.smoothed_model = model_img
1365
+ break
1366
+ except Exception as e:
1367
+ self.log_text.append(f" ! Failed to load {model_2d_path.name} from {model_dir.name}: {e}")
1368
+
1369
+ for model_dir in model_dirs:
1370
+ pixel_flat_path = model_dir / 'flat_pixel_2d.fits'
1371
+ if pixel_flat_path.exists():
1372
+ try:
1373
+ pf_img, _ = read_fits_image(str(pixel_flat_path))
1374
+ flat_field.pixel_flat = pf_img
1375
+ break
1376
+ except Exception as e:
1377
+ self.log_text.append(f" ! Failed to load {pixel_flat_path.name} from {model_dir.name}: {e}")
1378
+
1379
+ if loaded_model_dir is not None:
1380
+ self.log_text.append(f" ✓ Loaded blaze profiles from {loaded_model_dir}")
1381
+
1382
+ self._flat_field = flat_field
1383
+ self._apertures = apertures
1384
+ self.pipeline.state.flat_field = flat_field
1385
+ self.pipeline.state.apertures = apertures
1386
+ self.log_text.append(f" ✓ Successfully loaded {flat_path.name} and Apertures from disk.")
1387
+ else:
1388
+ self.log_text.append(" ! Could not find MasterFlat.fits or Orders_trace_coefs.json on disk. Run Step 2 first.")
1389
+ except Exception as e:
1390
+ self.log_text.append(f" ✗ Failed to load from disk: {e}")
1391
+
1392
+ if "stage_2" in selected_stages:
1393
+ flat_field = getattr(self, '_flat_field', getattr(self.pipeline.state, 'flat_field', None))
1394
+ apertures = getattr(self, '_apertures', getattr(self.pipeline.state, 'apertures', None))
1395
+ if flat_field is not None and apertures is not None:
1396
+ self.log_text.append("\n============================================================")
1397
+ self.log_text.append("STEP 3: SCATTERED LIGHT SUBTRACTION (MASTER FLAT)")
1398
+ try:
1399
+ from specproc.core.scattered_light import create_widened_mask
1400
+ from specproc.core.scattered_light import process_background_stage
1401
+ from specproc.utils.fits_io import write_fits_image
1402
+
1403
+ out_dir = Path(self.config.get_output_path()) / 'step3_scatterlight'
1404
+ out_dir.mkdir(parents=True, exist_ok=True)
1405
+
1406
+ mask_margin_pixels = self.config.get_int('reduce.background', 'mask_margin_pixels', 1)
1407
+ n_mask_below = self.config.get_int('reduce.trace', 'n_mask_below', 4)
1408
+ n_mask_above = self.config.get_int('reduce.trace', 'n_mask_above', 4)
1409
+ h, w = flat_field.flat_data.shape
1410
+
1411
+ step3_mask, lo_traces, hi_traces, full_ids = create_widened_mask(
1412
+ apertures, flat_field.flat_data, mask_margin_pixels,
1413
+ n_mask_below, n_mask_above
1414
+ )
1415
+
1416
+ write_fits_image(str(out_dir / 'Scattered_Light_Mask.fits'), step3_mask, dtype='uint8')
1417
+ self._step3_mask = step3_mask
1418
+
1419
+ if self.config.get_bool('reduce', 'save_plots', True):
1420
+ import matplotlib.pyplot as plt
1421
+ fig_format = self.config.get('reduce', 'fig_format', 'png')
1422
+ plt.figure(figsize=(12, 10))
1423
+ plt.imshow(step3_mask, aspect='auto', origin='lower', cmap='gray', vmin=0, vmax=1)
1424
+
1425
+ x_anno = w - 1
1426
+ for i, ap_id in enumerate(full_ids):
1427
+ yc = 0.5 * (lo_traces[i, x_anno] + hi_traces[i, x_anno])
1428
+ if np.isfinite(yc) and 0 <= yc < h:
1429
+ plt.text(x_anno + 5, yc, str(ap_id), color='red', fontsize=3.5, ha='left', va='center', clip_on=False)
1430
+
1431
+ plt.title(f'Step 3 Widened Mask (margin={mask_margin_pixels})\nWhite=Masked (Apertures + Virtual), Black=Background')
1432
+ plt.xlabel('Pixel (X)')
1433
+ plt.ylabel('Pixel (Y)')
1434
+ plt.xlim(0, w - 1)
1435
+ plt.ylim(0, h - 1)
1436
+ plot_file = out_dir / f'scattered_light_mask.{fig_format}'
1437
+ plt.savefig(str(plot_file), dpi=150, bbox_inches='tight')
1438
+ plt.close()
1439
+ self.log_text.append(f" ✓ Saved widened aperture mask and plot (margin={mask_margin_pixels})")
1440
+
1441
+ bg_kwargs = {
1442
+ 'output_subdir': 'step3_scatterlight',
1443
+ 'output_tag': 'MasterFlat_',
1444
+ 'mask_margin_scale': 1.0,
1445
+ 'mask_margin_pixels': 0, # Already widened
1446
+ 'order_mask': step3_mask,
1447
+ 'poly_order': self.config.get_int('reduce.background', 'poly_order', 3),
1448
+ 'bg_method': self.config.get('reduce.background', 'method', 'convolution'),
1449
+ 'sigma_clip_val': self.config.get_float('reduce.background', 'sigma_clip', 3.0),
1450
+ 'maxiters': self.config.get_int('reduce.background', 'sigma_clip_maxiters', 4),
1451
+ 'bspline_smooth': self.config.get_float('reduce.background', 'bspline_smooth', 1.0),
1452
+ 'n_mask_below': self.config.get_int('reduce.trace', 'n_mask_below', 4),
1453
+ 'n_mask_above': self.config.get_int('reduce.trace', 'n_mask_above', 4),
1454
+ 'clip_mode': self.config.get('reduce.background', 'sigma_clip_mode', 'upper'),
1455
+ 'split_row': self.config.get_int('data', 'detector_split_row', 2068),
1456
+ 'kernel_sigma_x': self.config.get_float('reduce.background', 'kernel_sigma_x', 13.0),
1457
+ 'kernel_sigma_y': self.config.get_float('reduce.background', 'kernel_sigma_y', 13.0),
1458
+ 'spline_smooth_factor': self.config.get_float('reduce.background', 'spline_smooth_factor', 1.0),
1459
+ 'spline_post_smooth_x': self.config.get_float('reduce.background', 'spline_post_smooth_x', 5.0),
1460
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
1461
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png')
1462
+ }
1463
+
1464
+ self.log_text.append(f" Subtracting scattered light from Master Flat...")
1465
+ flat_background = process_background_stage(
1466
+ science_image=flat_field.flat_data,
1467
+ output_dir_base=self.config.get_output_path(),
1468
+ apertures=apertures,
1469
+ **bg_kwargs
1470
+ )
1471
+ flat_clean = np.clip(
1472
+ flat_field.flat_data.astype(np.float32) - flat_background.astype(np.float32),
1473
+ 1e-6,
1474
+ None
1475
+ )
1476
+ flat_field.scattered_light = flat_background
1477
+ self._flat_field = flat_field
1478
+ self.pipeline.state.flat_field = flat_field
1479
+
1480
+ master_flat_path = Path(self.config.get_output_path()) / 'step2_trace' / 'MasterFlat.fits'
1481
+ from specproc.utils.fits_io import read_fits_image
1482
+ _, flat_header = read_fits_image(str(master_flat_path)) if master_flat_path.exists() else (None, None)
1483
+ if flat_header is not None:
1484
+ flat_header['BKGSCAT'] = (True, 'Scattered light background subtracted')
1485
+
1486
+ write_fits_image(str(out_dir / 'MasterFlat.fits'), flat_clean.astype('float32'), header=flat_header, dtype='float32')
1487
+ self.log_text.append(f" ✓ Master Flat scattered light subtraction complete.")
1488
+
1489
+ # Decoupled model flow: when Step 3 is selected, build profiles here (not in Step 2/4).
1490
+ from specproc.core.flat_correction import FlatCorrectionModelBuilder
1491
+ from specproc.plotting.spectra_plotter import plot_2d_image_to_file
1492
+ import pickle
1493
+
1494
+ self.log_text.append(" Building blaze/cross profiles from Step 3 MasterFlat...")
1495
+ processor = FlatCorrectionModelBuilder()
1496
+ processor.flat_data = flat_field.flat_data
1497
+ processor.flat_mask = flat_field.flat_mask
1498
+
1499
+ flat_sens, blaze_profiles, cross_profiles, smoothed_model, pixel_flat, illum = (
1500
+ processor.build_order_response_map(
1501
+ apertures,
1502
+ source_image=flat_clean,
1503
+ blaze_smooth_factor=self.config.get_float('reduce.flat', 'blaze_smooth_factor', 1.0),
1504
+ width_smooth_window=self.config.get_int('reduce.flat', 'width_smooth_window', 41),
1505
+ profile_bin_step=self.config.get_float('reduce.flat', 'profile_bin_step', 0.01),
1506
+ n_profile_segments=self.config.get_int('reduce.flat', 'n_profile_segments', 100),
1507
+ profile_smooth_sigma=self.config.get_float('reduce.flat', 'profile_smooth_sigma', 6.0),
1508
+ pixel_flat_min=self.config.get_float('reduce.flat', 'pixel_flat_min', 0.5),
1509
+ pixel_flat_max=self.config.get_float('reduce.flat', 'pixel_flat_max', 1.5),
1510
+ fringe_orders=self.config.get_int('reduce.flat', 'fringe_orders', 20),
1511
+ )
1512
+ )
1513
+
1514
+ flat_field.flat_sens = flat_sens
1515
+ flat_field.blaze_profiles = blaze_profiles
1516
+ flat_field.cross_profiles = cross_profiles
1517
+ flat_field.smoothed_model = smoothed_model
1518
+ flat_field.pixel_flat = pixel_flat
1519
+ flat_field.illumination_flat = illum
1520
+ self._flat_field = flat_field
1521
+ self.pipeline.state.flat_field = flat_field
1522
+
1523
+ with open(out_dir / 'blaze_profiles.pkl', 'wb') as fb:
1524
+ pickle.dump(blaze_profiles, fb)
1525
+ with open(out_dir / 'cross_profiles.pkl', 'wb') as fc:
1526
+ pickle.dump(cross_profiles, fc)
1527
+ write_fits_image(str(out_dir / 'model_flat_2d.fits'), smoothed_model.astype('float32'), dtype='float32')
1528
+ write_fits_image(str(out_dir / 'flat_smoothed_model.fits'), smoothed_model.astype('float32'), dtype='float32')
1529
+ write_fits_image(str(out_dir / 'flat_pixel_2d.fits'), pixel_flat.astype('float32'), dtype='float32')
1530
+ if self.config.get_bool('reduce', 'save_plots', True):
1531
+ fig_format = self.config.get('reduce', 'fig_format', 'png')
1532
+ plot_2d_image_to_file(smoothed_model, str(out_dir / f'flat_smoothed_model.{fig_format}'), 'Reconstructed 2D Flat Model (Step 3)')
1533
+ plot_2d_image_to_file(pixel_flat, str(out_dir / f'flat_pixel_2d.{fig_format}'), 'Pixel-to-Pixel Flat (Step 3)', vmin=0.85, vmax=1.15)
1534
+
1535
+ self.log_text.append(" ✓ Saved flat model products to step3_scatterlight (blaze/cross/model/pixel-flat + diagnostics)")
1536
+
1537
+ self.processing_state['stage_3_completed'] = True
1538
+
1539
+ except Exception as e:
1540
+ self.log_text.append(f" ✗ Master Flat scattered light subtraction failed: {e}")
1541
+ raise
1542
+ else:
1543
+ self.log_text.append("\n ! Step 3 (Master Flat): No flat field or apertures available. Run Step 2 first.")
1544
+
1545
+ total_stages = len(selected_stages)
1546
+ current_stage = 0
1547
+ if "stage_0" in selected_stages: current_stage += 1
1548
+ if "stage_1" in selected_stages: current_stage += 1
1549
+
1550
+ extracted_spectra_dict = {}
1551
+ deblazed_spectra_dict = {}
1552
+ calibrated_spectra_dict = {}
1553
+
1554
+ apertures = getattr(self, '_apertures', getattr(self.pipeline.state, 'apertures', None))
1555
+ flat_field = getattr(self, '_flat_field', getattr(self.pipeline.state, 'flat_field', None))
1556
+
1557
+ # STAGE 2: Scattered Light Subtraction (Science Images)
1558
+ if "stage_2" in selected_stages:
1559
+ current_stage += 1
1560
+ progress = (current_stage / total_stages) * 100
1561
+ self.progress_bar.setValue(int(progress))
1562
+ self.stage_label.setText("Scattered Light Subtraction")
1563
+
1564
+ self.log_text.append("\n" + "=" * 60)
1565
+ self.log_text.append("STEP 3: SCATTERED LIGHT SUBTRACTION (Science Images)")
1566
+ self.log_text.append("=" * 60)
1567
+
1568
+ from specproc.core.scattered_light import process_background_stage
1569
+ from specproc.utils.fits_io import read_fits_image, write_fits_image
1570
+
1571
+ new_science_files = []
1572
+ for sci_file in current_science_files:
1573
+ try:
1574
+ sci_img, sci_header = read_fits_image(sci_file)
1575
+ sci_name = Path(sci_file).stem
1576
+ order_mask = getattr(self, '_step3_mask', None)
1577
+ if order_mask is None:
1578
+ mask_path = Path(self.config.get_output_path()) / 'step3_scatterlight' / 'Scattered_Light_Mask.fits'
1579
+ if mask_path.exists():
1580
+ mask_img, _ = read_fits_image(str(mask_path))
1581
+ order_mask = mask_img
1582
+
1583
+ background = process_background_stage(
1584
+ science_image=sci_img,
1585
+ output_dir_base=self.config.get_output_path(),
1586
+ output_subdir='step3_scatterlight',
1587
+ output_tag=f'{sci_name}_',
1588
+ apertures=apertures,
1589
+ mask_margin_scale=1.0,
1590
+ sci_header=sci_header,
1591
+ order_mask=order_mask,
1592
+ poly_order=self.config.get_int('reduce.background', 'poly_order', 3),
1593
+ bg_method=self.config.get('reduce.background', 'method', 'convolution'),
1594
+ sigma_clip_val=self.config.get_float('reduce.background', 'sigma_clip', 3.0),
1595
+ maxiters=self.config.get_int('reduce.background', 'sigma_clip_maxiters', 4),
1596
+ bspline_smooth=self.config.get_float('reduce.background', 'bspline_smooth', 1.0),
1597
+ mask_margin_pixels=0,
1598
+ n_mask_below=self.config.get_int('reduce.trace', 'n_mask_below', 4),
1599
+ n_mask_above=self.config.get_int('reduce.trace', 'n_mask_above', 4),
1600
+ clip_mode=self.config.get('reduce.background', 'sigma_clip_mode', 'upper'),
1601
+ split_row=self.config.get_int('data', 'detector_split_row', 2068),
1602
+ kernel_sigma_x=self.config.get_float('reduce.background', 'kernel_sigma_x', 13.0),
1603
+ kernel_sigma_y=self.config.get_float('reduce.background', 'kernel_sigma_y', 13.0),
1604
+ spline_smooth_factor=self.config.get_float('reduce.background', 'spline_smooth_factor', 1.0),
1605
+ spline_post_smooth_x=self.config.get_float('reduce.background', 'spline_post_smooth_x', 5.0),
1606
+ save_plots=self.config.get_bool('reduce', 'save_plots', True),
1607
+ fig_format=self.config.get('reduce', 'fig_format', 'png')
1608
+ )
1609
+ corrected = sci_img.astype('float32') - background.astype('float32')
1610
+ out_dir = Path(self.config.get_output_path()) / 'step3_scatterlight'
1611
+ out_dir.mkdir(parents=True, exist_ok=True)
1612
+ corrected_path = out_dir / Path(sci_file).name
1613
+ if sci_header is not None:
1614
+ sci_header['BKGSCAT'] = (True, 'Scattered light background subtracted')
1615
+ write_fits_image(str(corrected_path), corrected, header=sci_header, dtype='float32')
1616
+ self.log_text.append(f" ✓ Step 3 output for {sci_name}: {corrected_path.name}")
1617
+ new_science_files.append(str(corrected_path))
1618
+ except Exception as e:
1619
+ self.log_text.append(f" ✗ Step 3 failed for {Path(sci_file).name}: {e}")
1620
+ new_science_files.append(sci_file)
1621
+ current_science_files = new_science_files
1622
+
1623
+ # STAGE 3: 2D Flat-Field Correction
1624
+ if "stage_3" in selected_stages:
1625
+ current_stage += 1
1626
+ progress = (current_stage / total_stages) * 100
1627
+ self.progress_bar.setValue(int(progress))
1628
+ self.stage_label.setText("2D Flat-Field Correction")
1629
+
1630
+ self.log_text.append("\n" + "=" * 60)
1631
+ self.log_text.append("STEP 4: 2D FLAT-FIELD CORRECTION")
1632
+ self.log_text.append("=" * 60)
1633
+
1634
+ from specproc.utils.fits_io import read_fits_image, write_fits_image
1635
+
1636
+ if flat_field is None:
1637
+ self.log_text.append(" ! Step 4: no MasterFlat available – Step 2 must run first")
1638
+ else:
1639
+ flat_corr_map = flat_field.pixel_flat
1640
+ if flat_corr_map is None:
1641
+ self.log_text.append(" ! Step 4: no prebuilt pixel-flat model found. Run Step 3 first, or run Step 2 when Step 3 is not selected.")
1642
+ else:
1643
+ self.log_text.append(" Using prebuilt 2D flat-field model to correct science frames...")
1644
+
1645
+ safe_flat = flat_corr_map.astype(np.float32)
1646
+ bad = (~np.isfinite(safe_flat)) | (safe_flat <= 0.05)
1647
+ if np.any(bad):
1648
+ safe_flat = safe_flat.copy()
1649
+ safe_flat[bad] = 1.0
1650
+
1651
+ out_dir_f = Path(self.config.get_output_path()) / 'step4_flat_corrected'
1652
+ out_dir_f.mkdir(parents=True, exist_ok=True)
1653
+
1654
+ # Export flat-model artefacts to Step 4 output for compatibility with previous workflow.
1655
+ try:
1656
+ import pickle
1657
+ from specproc.plotting.spectra_plotter import plot_2d_image_to_file
1658
+
1659
+ if flat_field.blaze_profiles:
1660
+ with open(out_dir_f / 'blaze_profiles.pkl', 'wb') as fb:
1661
+ pickle.dump(flat_field.blaze_profiles, fb)
1662
+ if getattr(flat_field, 'cross_profiles', None):
1663
+ with open(out_dir_f / 'cross_profiles.pkl', 'wb') as fc:
1664
+ pickle.dump(flat_field.cross_profiles, fc)
1665
+
1666
+ if getattr(flat_field, 'smoothed_model', None) is not None:
1667
+ write_fits_image(str(out_dir_f / 'model_flat_2d.fits'), flat_field.smoothed_model.astype('float32'), dtype='float32')
1668
+ write_fits_image(str(out_dir_f / 'flat_smoothed_model.fits'), flat_field.smoothed_model.astype('float32'), dtype='float32')
1669
+ write_fits_image(str(out_dir_f / 'flat_pixel_2d.fits'), safe_flat.astype('float32'), dtype='float32')
1670
+
1671
+ # Save corrected MasterFlat for compatibility with earlier Step 4 outputs.
1672
+ mf_candidates = [
1673
+ Path(self.config.get_output_path()) / 'step3_scatterlight' / 'MasterFlat.fits',
1674
+ Path(self.config.get_output_path()) / 'step2_trace' / 'MasterFlat.fits',
1675
+ ]
1676
+ mf_source = next((p for p in mf_candidates if p.exists()), None)
1677
+ if mf_source is not None:
1678
+ mf_img, mf_header = read_fits_image(str(mf_source))
1679
+ mf_corr = mf_img.astype(np.float32) / safe_flat
1680
+ if mf_header is not None:
1681
+ mf_header['FLATCOR'] = (True, '2D pixel flat correction applied')
1682
+ write_fits_image(str(out_dir_f / 'MasterFlat.fits'), mf_corr, header=mf_header, dtype='float32')
1683
+
1684
+ if self.config.get_bool('reduce', 'save_plots', True):
1685
+ fig_format = self.config.get('reduce', 'fig_format', 'png')
1686
+ if getattr(flat_field, 'smoothed_model', None) is not None:
1687
+ plot_2d_image_to_file(flat_field.smoothed_model, str(out_dir_f / f'flat_smoothed_model.{fig_format}'), 'Reconstructed 2D Flat Model')
1688
+ plot_2d_image_to_file(safe_flat, str(out_dir_f / f'flat_pixel_2d.{fig_format}'), 'Pixel-to-Pixel Flat', vmin=0.85, vmax=1.15)
1689
+ if mf_source is not None:
1690
+ plot_2d_image_to_file(mf_corr, str(out_dir_f / f'MasterFlat.{fig_format}'), 'Master Flat After 2D Flat Correction')
1691
+ except Exception as e:
1692
+ self.log_text.append(f" ! Failed to export Step 4 flat model diagnostics: {e}")
1693
+
1694
+ # Apply to science files
1695
+ new_science_files = []
1696
+ self.log_text.append(" Applying 2D flat correction to science frames...")
1697
+ for sci_file in current_science_files:
1698
+ try:
1699
+ sci_img, sci_header = read_fits_image(sci_file)
1700
+ sci_name = Path(sci_file).stem
1701
+ corrected = sci_img.astype(np.float32) / safe_flat
1702
+
1703
+ out_path = out_dir_f / Path(sci_file).name
1704
+ if sci_header is not None:
1705
+ sci_header['FLATCOR'] = (True, '2D pixel flat correction applied')
1706
+ write_fits_image(str(out_path), corrected, header=sci_header, dtype='float32')
1707
+
1708
+ if self.config.get_bool('reduce', 'save_plots', True):
1709
+ from specproc.plotting.spectra_plotter import plot_2d_image_to_file
1710
+ fig_format = self.config.get('reduce', 'fig_format', 'png')
1711
+ 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}")
1712
+
1713
+ new_science_files.append(str(out_path))
1714
+ self.log_text.append(f" ✓ Corrected {sci_name}")
1715
+ except Exception as e:
1716
+ self.log_text.append(f" ✗ Failed to correct {Path(sci_file).name}: {e}")
1717
+ new_science_files.append(sci_file) # Keep original if failed
1718
+ current_science_files = new_science_files
1719
+ else:
1720
+ self.log_text.append("\n============================================================")
1721
+ self.log_text.append("STEP 4: 2D FLAT-FIELD CORRECTION")
1722
+ self.log_text.append(" Skipped (step not selected)")
1723
+ self.log_text.append("============================================================")
1724
+
1725
+ # STAGE 4: 1D Extraction
1726
+ if "stage_4" in selected_stages:
1727
+ current_stage += 1
1728
+ progress = (current_stage / total_stages) * 100
1729
+ self.progress_bar.setValue(int(progress))
1730
+ self.stage_label.setText("1D Extraction")
1731
+
1732
+ if apertures is None:
1733
+ self.log_text.append(" ! Step 5: no apertures available – Step 2 must run first")
1734
+ else:
1735
+ self.log_text.append("\n" + "=" * 60)
1736
+ self.log_text.append("STEP 5: 1D EXTRACTION")
1737
+ self.log_text.append("=" * 60)
1738
+
1739
+ from specproc.core.extraction import process_extraction_stage
1740
+ from specproc.utils.fits_io import read_fits_image
1741
+ extract_kwargs = {
1742
+ 'output_dir_base': self.config.get_output_path(),
1743
+ 'optimal_sigma': self.config.get_float('reduce.extract', 'optimal_sigma', 3.0),
1744
+ 'save_plots': self.config.get_bool('reduce', 'save_plots', True),
1745
+ 'fig_format': self.config.get('reduce', 'fig_format', 'png'),
1746
+ }
1747
+
1748
+ # Extract MasterFlat (fallback to Step 3/2 outputs when Step 4 is skipped)
1749
+ output_base = Path(self.config.get_output_path())
1750
+ mf_candidates = [
1751
+ output_base / 'step4_flat_corrected' / 'MasterFlat.fits',
1752
+ output_base / 'step3_scatterlight' / 'MasterFlat.fits',
1753
+ output_base / 'step2_trace' / 'MasterFlat.fits',
1754
+ ]
1755
+ mf_path = next((p for p in mf_candidates if p.exists()), None)
1756
+ if mf_path is not None:
1757
+ try:
1758
+ mf_img, _ = read_fits_image(str(mf_path))
1759
+ mf_name = "MasterFlat"
1760
+ process_extraction_stage(mf_img, apertures, wavelength_calib=None, flat_field=flat_field, method_override='sum', output_filename=f'{mf_name}_1D_sum.fits', plot_prefix=f'{mf_name}_1D_sum', **extract_kwargs)
1761
+ process_extraction_stage(mf_img, apertures, wavelength_calib=None, flat_field=flat_field, method_override='optimal', output_filename=f'{mf_name}_1D_optimal.fits', plot_prefix=f'{mf_name}_1D_optimal', **extract_kwargs)
1762
+ self.log_text.append(f" ✓ Extracted MasterFlat 1D spectra (sum & optimal) from {mf_path.parent.name}")
1763
+ except Exception as e:
1764
+ self.log_text.append(f" ! Failed to extract MasterFlat: {e}")
1765
+ else:
1766
+ self.log_text.append(" ! MasterFlat not found in step4_flat_corrected/step3_scatterlight/step2_trace")
1767
+
1768
+ # Extract Calib
1769
+ for calib_f in current_calib_files:
1770
+ if not calib_f: continue
1771
+ best_calib = self.pipeline.get_best_calib_file(calib_f)
1772
+ if best_calib and Path(best_calib).exists():
1773
+ try:
1774
+ c_img, _ = read_fits_image(best_calib)
1775
+ c_name = Path(best_calib).stem
1776
+ 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)
1777
+ 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)
1778
+ self.log_text.append(f" ✓ Extracted Calibration 1D spectra for {c_name} (sum & optimal)")
1779
+ except Exception as e:
1780
+ self.log_text.append(f" ! Failed to extract Calibration {c_name}: {e}")
1781
+
1782
+ # Extract Science
1783
+ for sci_file in current_science_files:
1784
+ try:
1785
+ sci_img, _ = read_fits_image(sci_file)
1786
+ sci_name = Path(sci_file).stem
1787
+ 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)
1788
+ spectra_opt = 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)
1789
+ extracted_spectra_dict[sci_name] = spectra_opt
1790
+ self.log_text.append(f" ✓ Extracted Science 1D spectra for {sci_name} (sum & optimal)")
1791
+ except Exception as e:
1792
+ self.log_text.append(f" ! Failed to extract Science {Path(sci_file).name}: {e}")
1793
+
1794
+ # STAGE 5: De-blazing
1795
+ if "stage_5" in selected_stages:
1796
+ current_stage += 1
1797
+ progress = (current_stage / total_stages) * 100
1798
+ self.progress_bar.setValue(int(progress))
1799
+ self.stage_label.setText("De-blazing")
1800
+
1801
+ if flat_field is not None and not flat_field.blaze_profiles:
1802
+ output_base = Path(self.config.get_output_path())
1803
+
1804
+ # Decoupled flow: prefer profiles generated in Step 3, then Step 2,
1805
+ # with legacy Step 4 outputs as the last fallback.
1806
+ model_dirs = [
1807
+ output_base / 'step3_scatterlight',
1808
+ output_base / 'step2_trace',
1809
+ output_base / 'step4_flat_corrected',
1810
+ ]
1811
+ for model_dir in model_dirs:
1812
+ blaze_pkl = model_dir / 'blaze_profiles.pkl'
1813
+ if not blaze_pkl.exists():
1814
+ continue
1815
+ try:
1816
+ import pickle
1817
+ with open(blaze_pkl, 'rb') as fb:
1818
+ flat_field.blaze_profiles = pickle.load(fb)
1819
+ self.log_text.append(f" ✓ Loaded blaze profiles from {model_dir.name}")
1820
+ break
1821
+ except Exception as e:
1822
+ self.log_text.append(f" ! Failed to load {blaze_pkl.name} from {model_dir.name}: {e}")
1823
+
1824
+ if flat_field is None or not flat_field.blaze_profiles:
1825
+ self.log_text.append(" ! Step 6: no blaze function available (run Step 3, or run Step 2 when Step 3 is not selected)")
1826
+ else:
1827
+ self.log_text.append("\n" + "=" * 60)
1828
+ self.log_text.append("STEP 6: DE-BLAZING")
1829
+ self.log_text.append("=" * 60)
1830
+
1831
+ from specproc.core.de_blazing import process_de_blazing_stage
1832
+ from specproc.core.extraction import load_extracted_spectra
1833
+
1834
+ # 为目标光谱生成去闪耀结果。
1835
+ def do_deblaze(target_name, spectra_obj=None):
1836
+ deblazed_opt = None
1837
+ for method in ['sum', 'optimal']:
1838
+ spectra = spectra_obj if (method == 'optimal' and spectra_obj is not None) else None
1839
+ if spectra is None:
1840
+ spectra_path = Path(self.config.get_output_path()) / 'step5_extraction' / f'{target_name}_1D_{method}.fits'
1841
+ if spectra_path.exists():
1842
+ try: spectra = load_extracted_spectra(str(spectra_path))
1843
+ except Exception as e: self.log_text.append(f" ! Error loading {spectra_path.name}: {e}")
1844
+ if spectra is not None:
1845
+ try:
1846
+ db_spec = process_de_blazing_stage(
1847
+ spectra,
1848
+ output_dir_base=self.config.get_output_path(),
1849
+ flat_field=flat_field,
1850
+ save_deblaze=self.config.get_bool('reduce.save_intermediate', 'save_deblaze', True),
1851
+ output_filename=f'{target_name}_1D_{method}_Deblaze.fits',
1852
+ plot_prefix=f'{target_name}_1D_{method}_Deblaze',
1853
+ save_plots=self.config.get_bool('reduce', 'save_plots', True),
1854
+ fig_format=self.config.get('reduce', 'fig_format', 'png')
1855
+ )
1856
+ self.log_text.append(f" ✓ Step 6 De-blazing complete for {target_name} ({method})")
1857
+ if method == 'optimal': deblazed_opt = db_spec
1858
+ except Exception as e:
1859
+ self.log_text.append(f" ! Failed to de-blaze {target_name} ({method}): {e}")
1860
+ return deblazed_opt
1861
+
1862
+ do_deblaze("MasterFlat")
1863
+ for calib_f in current_calib_files:
1864
+ if not calib_f: continue
1865
+ calib_name = Path(self.pipeline.get_best_calib_file(calib_f)).stem
1866
+ do_deblaze(calib_name)
1867
+
1868
+ for sci_file in current_science_files:
1869
+ sci_name = Path(sci_file).stem
1870
+ db_opt = do_deblaze(sci_name, extracted_spectra_dict.get(sci_name))
1871
+ if db_opt is not None:
1872
+ deblazed_spectra_dict[sci_name] = db_opt
1873
+
1874
+ # STAGE 6: Wavelength Calibration
1875
+ if "stage_6" in selected_stages:
1876
+ current_stage += 1
1877
+ progress = (current_stage / total_stages) * 100
1878
+ self.progress_bar.setValue(int(progress))
1879
+ self.stage_label.setText("Wavelength Calibration")
1880
+
1881
+ if not current_calib_files or not current_calib_files[0]:
1882
+ self.log_text.append(" ! Step 7: no calibration file provided")
1883
+ else:
1884
+ self.log_text.append("\n" + "=" * 60)
1885
+ self.log_text.append("STEP 7: WAVELENGTH CALIBRATION")
1886
+ self.log_text.append("=" * 60)
1887
+
1888
+ from specproc.core.wave_calibration import process_wavelength_stage, WavelengthCalibrator
1889
+ from specproc.utils.fits_io import read_fits_image
1890
+ from specproc.core.extraction import process_extraction_stage, load_extracted_spectra
1891
+ import os
1892
+ from astropy.time import Time
1893
+
1894
+ time_key = self.config.get('data', 'statime_key', 'DATE-OBS')
1895
+ # 读取文件观测时间或修改时间。
1896
+ def get_file_time(filepath):
1897
+ if not filepath or not Path(filepath).exists(): return 0
1898
+ try:
1899
+ _, hdr = read_fits_image(filepath)
1900
+ if hdr and time_key in hdr:
1901
+ return Time(hdr[time_key]).unix
1902
+ except Exception: pass
1903
+ return os.path.getmtime(filepath)
1904
+
1905
+ calibrations = []
1906
+ for calib_file in current_calib_files:
1907
+ if not calib_file: continue
1908
+ best_calib = self.pipeline.get_best_calib_file(calib_file)
1909
+ calib_name = Path(best_calib).stem
1910
+ lamp_spectra = None
1911
+
1912
+ lamp_db_path = Path(self.config.get_output_path()) / 'step6_deblazing' / f'{calib_name}_1D_optimal_Deblaze.fits'
1913
+ if lamp_db_path.exists():
1914
+ try: lamp_spectra = load_extracted_spectra(str(lamp_db_path))
1915
+ except Exception as e: self.log_text.append(f" ! Error loading {lamp_db_path.name}: {e}")
1916
+
1917
+ if lamp_spectra is None:
1918
+ lamp_ex_path = Path(self.config.get_output_path()) / 'step5_extraction' / f'{calib_name}_1D_optimal.fits'
1919
+ if lamp_ex_path.exists():
1920
+ try: lamp_spectra = load_extracted_spectra(str(lamp_ex_path))
1921
+ except Exception as e: self.log_text.append(f" ! Error loading {lamp_ex_path.name}: {e}")
1922
+
1923
+ if lamp_spectra is None:
1924
+ self.log_text.append(f" ! Step 7: Calibration lamp spectrum not found. Please run Step 5 or 6 first.")
1925
+ self.log_text.append(f" -> Code searched for: {lamp_db_path.name} and {lamp_ex_path.name}")
1926
+ self.log_text.append(f" -> Absolute path 1: {lamp_db_path.absolute()} (Exists: {lamp_db_path.exists()})")
1927
+ self.log_text.append(f" -> Absolute path 2: {lamp_ex_path.absolute()} (Exists: {lamp_ex_path.exists()})")
1928
+ continue
1929
+
1930
+ wave_calib = process_wavelength_stage(
1931
+ lamp_spectra=lamp_spectra, config=self.config, output_dir_base=self.config.get_output_path(),
1932
+ lamp_type=self.config.get('telescope.linelist', 'linelist_type', 'ThAr'),
1933
+ save_plots=self.config.get_bool('reduce', 'save_plots', True),
1934
+ fig_format=self.config.get('reduce', 'fig_format', 'png'),
1935
+ lamp_name=calib_name
1936
+ )
1937
+ c_time = get_file_time(best_calib)
1938
+ calibrations.append((c_time, wave_calib, calib_name))
1939
+
1940
+ if not calibrations:
1941
+ self.log_text.append(" ! Step 7: No valid wavelength calibrations could be generated.")
1942
+ else:
1943
+ targets = [("MasterFlat", None)]
1944
+ for c_file in current_calib_files:
1945
+ if not c_file: continue
1946
+ best_calib = self.pipeline.get_best_calib_file(c_file)
1947
+ targets.append((Path(best_calib).stem, best_calib))
1948
+ for s_file in current_science_files:
1949
+ targets.append((Path(s_file).stem, s_file))
1950
+
1951
+ for target_name, target_file in targets:
1952
+ target_time = get_file_time(target_file)
1953
+ closest_calib = min(calibrations, key=lambda x: abs(x[0] - target_time))
1954
+ wave_calib = closest_calib[1]
1955
+ self.log_text.append(f" Using calibration {closest_calib[2]} for {target_name} (closest in time)")
1956
+
1957
+ calib_opt = None
1958
+ for method in ['sum', 'optimal']:
1959
+ spectra = None
1960
+ deblaze_path = Path(self.config.get_output_path()) / 'step6_deblazing' / f'{target_name}_1D_{method}_Deblaze.fits'
1961
+ if deblaze_path.exists():
1962
+ try: spectra = load_extracted_spectra(str(deblaze_path))
1963
+ except Exception: pass
1964
+
1965
+ if spectra is None:
1966
+ extract_path = Path(self.config.get_output_path()) / 'step5_extraction' / f'{target_name}_1D_{method}.fits'
1967
+ if extract_path.exists():
1968
+ try: spectra = load_extracted_spectra(str(extract_path))
1969
+ except Exception as e: self.log_text.append(f" ! Error loading {extract_path.name}: {e}")
1970
+
1971
+ if spectra is None:
1972
+ self.log_text.append(f" ! Step 7: No {method} spectra found to calibrate for {target_name}")
1973
+ self.log_text.append(f" -> Code searched for: {deblaze_path.name} and {extract_path.name}")
1974
+ self.log_text.append(f" -> Absolute path 1: {deblaze_path.absolute()} (Exists: {deblaze_path.exists()})")
1975
+ self.log_text.append(f" -> Absolute path 2: {extract_path.absolute()} (Exists: {extract_path.exists()})")
1976
+ continue
1977
+
1978
+ delta_m = getattr(wave_calib, 'delta_m', 0)
1979
+ if delta_m != 0:
1980
+ spectra.shift_orders(delta_m)
1981
+
1982
+ calibrator = WavelengthCalibrator()
1983
+ calibrator.wave_calib = wave_calib
1984
+
1985
+ from specproc.core.data_structures import SpectraSet
1986
+ calibrated_spectra = SpectraSet()
1987
+
1988
+ for spectrum in spectra.spectra.values():
1989
+ n_pixels = len(spectrum.flux)
1990
+ pixel_array = np.arange(n_pixels)
1991
+ wavelengths = calibrator.apply_wavelength_calibration(
1992
+ spectrum.flux, pixel_array, aperture_y=float(spectrum.aperture)
1993
+ )
1994
+ calibrated_spectrum = spectrum.copy()
1995
+ calibrated_spectrum.wavelength = wavelengths
1996
+ calibrated_spectra.add_spectrum(calibrated_spectrum)
1997
+
1998
+ out_dir = Path(self.config.get_output_path()) / 'step7_wavelength'
1999
+ out_dir.mkdir(parents=True, exist_ok=True)
2000
+ from specproc.core.de_blazing import save_deblazed_spectra
2001
+ save_deblazed_spectra(
2002
+ str(out_dir / f'{target_name}_1D_{method}_wavecal.fits'),
2003
+ calibrated_spectra,
2004
+ content_desc='Wavelength calibrated spectra',
2005
+ log_desc='wavelength-calibrated'
2006
+ )
2007
+
2008
+ if self.config.get_bool('reduce', 'save_plots', True):
2009
+ from specproc.plotting.spectra_plotter import plot_spectra_to_pdf
2010
+ pdf_path = out_dir / f"{target_name}_1D_{method}_wavecal.pdf"
2011
+ plot_spectra_to_pdf(calibrated_spectra, str(pdf_path),
2012
+ title_prefix="Wavelength Calibrated Spectrum", xlabel=r"Wavelength ($\AA$)",
2013
+ id_label="Order")
2014
+
2015
+ self.log_text.append(f" ✓ Step 7 Wavelength calibration applied for {target_name} ({method})")
2016
+ if method == 'optimal':
2017
+ calib_opt = calibrated_spectra
2018
+
2019
+ if calib_opt is not None and target_name in [Path(s).stem for s in current_science_files]:
2020
+ calibrated_spectra_dict[target_name] = calib_opt
2021
+
2022
+ # STAGE 7: Order Stitching
2023
+ if "stage_7" in selected_stages:
2024
+ current_stage += 1
2025
+ progress = (current_stage / total_stages) * 100
2026
+ self.progress_bar.setValue(int(progress))
2027
+ self.stage_label.setText("Order Stitching")
2028
+
2029
+ self.log_text.append("\n" + "=" * 60)
2030
+ self.log_text.append("STEP 8: ORDER STITCHING")
2031
+ self.log_text.append("=" * 60)
2032
+
2033
+ from specproc.core.de_blazing import process_de_blazing_stage
2034
+ from specproc.core.extraction import load_extracted_spectra
2035
+
2036
+ for sci_file in current_science_files:
2037
+ sci_name = Path(sci_file).stem
2038
+ spectra = calibrated_spectra_dict.get(sci_name)
2039
+ if spectra is None:
2040
+ calibrated_path = Path(self.config.get_output_path()) / 'step7_wavelength' / f'{sci_name}_1D_optimal_wavecal.fits'
2041
+ if calibrated_path.exists():
2042
+ try: spectra = load_extracted_spectra(str(calibrated_path))
2043
+ except Exception as e: self.log_text.append(f" ! Error loading {calibrated_path.name}: {e}")
2044
+
2045
+ if spectra is None:
2046
+ self.log_text.append(f" ! Step 8: no calibrated spectra available for {sci_name}")
2047
+ else:
2048
+ try:
2049
+ stitched = process_order_stitching_stage(
2050
+ spectra,
2051
+ output_dir_base=self.config.get_output_path(),
2052
+ output_subdir=f'step8_stitching/{sci_name}',
2053
+ save_plots=self.config.get_bool('reduce', 'save_plots', True),
2054
+ fig_format=self.config.get('reduce', 'fig_format', 'png'),
2055
+ )
2056
+ self.log_text.append(f" ✓ Step 8 Order Stitching complete for {sci_name}")
2057
+ except Exception as e:
2058
+ self.log_text.append(f" ✗ Step 8 Order Stitching failed for {sci_name}: {e}")
2059
+
2060
+ self.log_text.append("\n============================================================")
2061
+ self.log_text.append(f"✓ {len(current_science_files)} science images processed through selected steps.")
2062
+ self.log_text.append("============================================================")
2063
+
2064
+ # Processing summary statistics
2065
+ if hasattr(self, 'processing_start_time') and hasattr(self, 'processing_total_size_bytes'):
2066
+ total_time = time.time() - self.processing_start_time
2067
+ total_size_mb = self.processing_total_size_bytes / (1024 * 1024)
2068
+ processing_speed_mb_per_min = (total_size_mb / total_time) * 60 if total_time > 0 else 0
2069
+ summary_message = (
2070
+ f"\nProcessing Summary\n"
2071
+ f"------------------------------------------------------------\n"
2072
+ f" Total data processed: {total_size_mb:.2f} MB\n"
2073
+ f" Total processing time: {total_time:.2f} seconds\n"
2074
+ f" Average processing speed: {processing_speed_mb_per_min:.2f} MB/min\n"
2075
+ f"------------------------------------------------------------"
2076
+ )
2077
+ self.log_text.append(summary_message)
2078
+ logger.info(summary_message)
2079
+
2080
+ self.progress_bar.setValue(100)
2081
+ self.stage_label.setText("Completed")
2082
+ self.run_all_btn.setEnabled(True)
2083
+ self.run_selected_btn.setEnabled(True)
2084
+ self.stop_btn.setEnabled(False)
2085
+
2086
+ # 选中全部处理阶段。
2087
+ def _select_all_stages(self):
2088
+ """Select all processing stages."""
2089
+ for checkbox in self.stage_checkboxes.values():
2090
+ checkbox.setChecked(True)
2091
+ self.statusBar.showMessage("All processing steps selected")
2092
+
2093
+ # 取消选中全部处理阶段。
2094
+ def _clear_all_stages(self):
2095
+ """Clear all processing stage selections."""
2096
+ for checkbox in self.stage_checkboxes.values():
2097
+ checkbox.setChecked(False)
2098
+ self.statusBar.showMessage("All processing steps cleared")
2099
+
2100
+ # 停止当前处理任务。
2101
+ def _stop_processing(self):
2102
+ """Stop processing."""
2103
+ if self.worker_thread and self.worker_thread.isRunning():
2104
+ self.worker_thread.terminate()
2105
+ self.worker_thread.wait()
2106
+ self.log_text.append("\n[STOPPED] Pipeline execution halted by user")
2107
+
2108
+ self.run_all_btn.setEnabled(True)
2109
+ self.stop_btn.setEnabled(False)
2110
+
2111
+ # 更新处理进度显示。
2112
+ def _on_progress(self, progress: float, stage: str):
2113
+ """Handle progress update."""
2114
+ self.progress_bar.setValue(int(progress * 100))
2115
+ self.stage_label.setText(f"Current: {stage} ({progress*100:.1f}%)")
2116
+ self.log_text.append(f"[{progress*100:5.1f}%] {stage}")
2117
+
2118
+ # 处理完成后更新界面状态。
2119
+ def _on_execution_complete(self, success: bool, message: str,
2120
+ img_idx: int = 0, total_science: int = 1, total_imgs: int = 1):
2121
+ """Handle pipeline completion.
2122
+
2123
+ Args:
2124
+ success: Whether processing was successful
2125
+ message: Status message
2126
+ img_idx: Index of the current science image
2127
+ total_science: Total number of science images
2128
+ total_imgs: Total number of all images processed (bias + flat + calib + science)
2129
+ """
2130
+ self.log_text.append("\n" + "=" * 60)
2131
+ self.log_text.append(message)
2132
+ self.log_text.append("=" * 60)
2133
+
2134
+ if success:
2135
+ if img_idx == total_science - 1:
2136
+ # Last science image processed, so log summary
2137
+ end_time = time.time()
2138
+ if hasattr(self, 'processing_start_time') and hasattr(self, 'processing_total_size_bytes'):
2139
+ total_time = end_time - self.processing_start_time
2140
+ total_size_mb = self.processing_total_size_bytes / (1024 * 1024)
2141
+ # User requested MB/min
2142
+ processing_speed_mb_per_min = (total_size_mb / total_time) * 60 if total_time > 0 else 0
2143
+
2144
+ summary_message = (
2145
+ f"\nProcessing Summary\n"
2146
+ f"------------------------------------------------------------\n"
2147
+ f" Total data processed: {total_size_mb:.2f} MB\n"
2148
+ f" Total processing time: {total_time:.2f} seconds\n"
2149
+ f" Average processing speed: {processing_speed_mb_per_min:.2f} MB/min\n"
2150
+ f"------------------------------------------------------------"
2151
+ )
2152
+ self.log_text.append(summary_message)
2153
+ logger.info(summary_message)
2154
+
2155
+ self.progress_bar.setValue(100)
2156
+ self.run_all_btn.setEnabled(True)
2157
+ self.stop_btn.setEnabled(False)
2158
+ else:
2159
+ QMessageBox.critical(self, "Error", message)
2160
+ self.progress_bar.setValue(0)
2161
+ self.run_all_btn.setEnabled(True)
2162
+ self.stop_btn.setEnabled(False)
2163
+
2164
+ # 打开当前配置文件。
2165
+ def _open_config(self):
2166
+ """Open configuration file."""
2167
+ file, _ = QFileDialog.getOpenFileName(
2168
+ self, "Open Configuration", "", "Config Files (*.cfg);;All Files (*)"
2169
+ )
2170
+ if file:
2171
+ try:
2172
+ self.config.load(file)
2173
+ self.statusBar.showMessage(f"Loaded config: {Path(file).name}")
2174
+ except Exception as e:
2175
+ QMessageBox.critical(self, "Error", f"Failed to load config: {e}")
2176
+
2177
+ # 打开设置对话框。
2178
+ def _show_settings(self):
2179
+ """Show settings dialog."""
2180
+ from specproc.gui.settings_dialog import SettingsDialog
2181
+ dialog = SettingsDialog(self.config, self)
2182
+ if dialog.exec_() == QDialog.Accepted:
2183
+ # Update overscan checkbox state after settings are saved
2184
+ self._update_overscan_checkbox_from_config()
2185
+ self.statusBar.showMessage("Settings saved")
2186
+
2187
+ # 显示应用说明对话框。
2188
+ def _show_about(self):
2189
+ """Show about dialog."""
2190
+ QMessageBox.information(
2191
+ self,
2192
+ "About SpecProc",
2193
+ "SpecProc v0.1.0\n\nPyQt-based spectral reduction for echelle spectrographs\n\n"
2194
+ "Inspired by the gamse package"
2195
+ )
2196
+
2197
+ # 配置日志输出到界面。
2198
+ def setup_logging(self):
2199
+ """Setup logging to GUI."""
2200
+ # 由于日志实时显示在GUI中难以实现,我们只保留终端和文件日志
2201
+ # 不再添加GUI日志处理器,避免性能问题
2202
+ pass
2203
+
2204
+ # 关闭窗口前停止并清理任务。
2205
+ def closeEvent(self, event):
2206
+ """Handle window close."""
2207
+ if self.worker_thread and self.worker_thread.isRunning():
2208
+ self.worker_thread.terminate()
2209
+ self.worker_thread.wait()
2210
+ super().closeEvent(event)
2211
+
2212
+ # 更新第一步选项的显示文本。
2213
+ def _refresh_step1_checkbox_text(self):
2214
+ """Update Step1 display text so options are shown in parentheses."""
2215
+ stage_0_checkbox = self.stage_checkboxes.get('stage_0')
2216
+ if not stage_0_checkbox:
2217
+ return
2218
+
2219
+ if not hasattr(self, 'step1_substep_checkboxes') or not self.step1_substep_checkboxes:
2220
+ stage_0_checkbox.setText("Step 1: Basic Pre-processing")
2221
+ return
2222
+
2223
+ selected = []
2224
+ for key, label in [('overscan', 'Overscan'), ('bias', 'Bias'), ('cosmic', 'Cosmic')]:
2225
+ cb = self.step1_substep_checkboxes.get(key)
2226
+ if cb and cb.isChecked():
2227
+ selected.append(label)
2228
+
2229
+ options_text = '/'.join(selected) if selected else 'None'
2230
+ stage_0_checkbox.setText(f"Step 1: Basic Pre-processing ({options_text})")
2231
+
2232
+ # 根据配置更新过扫描选项。
2233
+ def _update_overscan_checkbox_from_config(self):
2234
+ """Update overscan checkbox based on configuration."""
2235
+ overscan_start_col = self.config.get_int('data', 'overscan_start_column', -1)
2236
+ stage_0_checkbox = self.stage_checkboxes.get('stage_0')
2237
+ overscan_cb = self.step1_substep_checkboxes.get('overscan')
2238
+
2239
+ if stage_0_checkbox and overscan_cb:
2240
+ # DO NOT set stage_0_checkbox.setChecked() here. This is the user's explicit choice.
2241
+ # Only update the 'overscan' sub-checkbox based on config.
2242
+
2243
+ # Temporarily block signals to prevent triggering the change handler
2244
+ overscan_cb.blockSignals(True)
2245
+
2246
+ # If overscan_start_column is -1 (disabled), uncheck the checkbox
2247
+ overscan_cb.setChecked(overscan_start_col != -1)
2248
+
2249
+ # Restore signals for the sub-checkbox
2250
+ overscan_cb.blockSignals(False)
2251
+
2252
+ # Keep Step1 substep toggles disabled when Step1 itself is unchecked.
2253
+ enabled = stage_0_checkbox.isChecked()
2254
+ for cb in self.step1_substep_checkboxes.values():
2255
+ cb.setEnabled(enabled)
2256
+
2257
+ self._refresh_step1_checkbox_text()
2258
+
2259
+ # 响应过扫描选项状态变化。
2260
+ def _on_overscan_checkbox_changed(self, state):
2261
+ """Handle overscan checkbox state change."""
2262
+ stage_0_checkbox = self.stage_checkboxes.get('stage_0')
2263
+ if not stage_0_checkbox:
2264
+ return
2265
+
2266
+ enabled = (state == Qt.Checked)
2267
+ for cb in self.step1_substep_checkboxes.values():
2268
+ cb.setEnabled(enabled)
2269
+ self._refresh_step1_checkbox_text()
2270
+
2271
+ overscan_start_col = self.config.get_int('data', 'overscan_start_column', -1)
2272
+ overscan_cb = self.step1_substep_checkboxes.get('overscan')
2273
+
2274
+ if state == Qt.Checked and overscan_cb and overscan_cb.isChecked():
2275
+ # Check if overscan_start_column is set (not -1)
2276
+ if overscan_start_col == -1:
2277
+ # Ask user to set the overscan configuration
2278
+ QMessageBox.information(
2279
+ self, "Overscan Configuration Required",
2280
+ "Overscan start column is not configured. Please set the overscan "
2281
+ "configuration in the Settings dialog."
2282
+ )
2283
+
2284
+ # Uncheck only the overscan substep, keep Step 1 checked
2285
+ overscan_cb.blockSignals(True)
2286
+ overscan_cb.setChecked(False)
2287
+ overscan_cb.blockSignals(False)
2288
+ self._refresh_step1_checkbox_text()
2289
+
2290
+ # Open settings dialog to the Data Reduction tab (index 1) which contains overscan settings
2291
+ from specproc.gui.settings_dialog import SettingsDialog
2292
+ dialog = SettingsDialog(self.config, self, initial_tab=1)
2293
+ result = dialog.exec_()
2294
+
2295
+ # After dialog closes, update checkbox state based on configuration
2296
+ self._update_overscan_checkbox_from_config()
2297
+
2298
+ if result == QDialog.Accepted:
2299
+ overscan_start_col = self.config.get_int('data', 'overscan_start_column', -1)
2300
+ if overscan_start_col != -1:
2301
+ overscan_cb.blockSignals(True)
2302
+ overscan_cb.setChecked(True)
2303
+ overscan_cb.blockSignals(False)
2304
+ self._refresh_step1_checkbox_text()
2305
+ self.statusBar.showMessage(f"Overscan configuration updated: start column = {overscan_start_col}")
2306
+ else:
2307
+ self.statusBar.showMessage("Overscan configuration saved (disabled)")
2308
+ else:
2309
+ self.statusBar.showMessage("Settings changes cancelled")
2310
+ else:
2311
+ # Configuration is already set, just update status message
2312
+ self.statusBar.showMessage(f"Overscan correction enabled (start column = {overscan_start_col})")
2313
+ else:
2314
+ # If unchecked, just update status message - don't modify configuration
2315
+ self.statusBar.showMessage("Step 1 toggled (configuration preserved)")