SpecProc 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- specproc/__init__.py +9 -0
- specproc/calib_data/linelists/FeAr.dat +375 -0
- specproc/calib_data/linelists/README +79 -0
- specproc/calib_data/linelists/airvac.cl +61 -0
- specproc/calib_data/linelists/argon.dat +161 -0
- specproc/calib_data/linelists/ctiohear.dat +106 -0
- specproc/calib_data/linelists/ctiohenear.dat +146 -0
- specproc/calib_data/linelists/cuar.dat +459 -0
- specproc/calib_data/linelists/henear.dat +93 -0
- specproc/calib_data/linelists/henearhres.dat +300 -0
- specproc/calib_data/linelists/idhenear.dat +127 -0
- specproc/calib_data/linelists/krypton.dat +109 -0
- specproc/calib_data/linelists/lowskylines.dat +577 -0
- specproc/calib_data/linelists/ohlines.dat +268 -0
- specproc/calib_data/linelists/skylines.dat +1385 -0
- specproc/calib_data/linelists/thar-noao.dat +4169 -0
- specproc/calib_data/linelists/thar.dat +3057 -0
- specproc/calib_data/linelists/thorium.dat +3057 -0
- specproc/calib_data/linelists/vacidhenear.dat +127 -0
- specproc/calib_data/linelists/vacthar.dat +3057 -0
- specproc/calib_data/linelists/vacthorium.dat +3057 -0
- specproc/calib_data/linelists/xenon.dat +89 -0
- specproc/calib_data/telescopes/lijiang24hrs/lijiang24_hrs_thar_lines.csv +550 -0
- specproc/calib_data/telescopes/xinglong216bfosc/BFOSC/345/256/232/346/240/207/347/201/257/350/260/261/350/256/244/350/257/201/346/211/213/345/206/214v171118.pdf +0 -0
- specproc/calib_data/telescopes/xinglong216bfosc//346/212/245/345/221/212/345/233/276.docx +0 -0
- specproc/calib_data/telescopes/xinglong216hrs/216HRS /347/201/257/350/260/261/350/257/201/350/256/244/346/226/207/344/273/266.pdf +0 -0
- specproc/calib_data/telescopes/xinglong216hrs/wlcalib_20211123011_A.png +0 -0
- specproc/calib_data/telescopes/xinglong216hrs/xinglong216_thar_lines.csv +517 -0
- specproc/config/__init__.py +1 -0
- specproc/config/config_manager.py +431 -0
- specproc/core/__init__.py +1 -0
- specproc/core/basic_reduction.py +615 -0
- specproc/core/data_structures.py +525 -0
- specproc/core/de_blazing.py +210 -0
- specproc/core/extraction.py +375 -0
- specproc/core/flat_correction.py +1255 -0
- specproc/core/line_identification.py +302 -0
- specproc/core/order_stitching.py +187 -0
- specproc/core/order_tracing.py +1587 -0
- specproc/core/processing_pipeline.py +1183 -0
- specproc/core/scattered_light.py +654 -0
- specproc/core/slit_pipeline.py +762 -0
- specproc/core/wave_calibration.py +961 -0
- specproc/gui/__init__.py +1 -0
- specproc/gui/line_identifier.py +731 -0
- specproc/gui/main_window.py +2315 -0
- specproc/gui/settings_dialog.py +946 -0
- specproc/main.py +388 -0
- specproc/plotting/__init__.py +1 -0
- specproc/plotting/spectra_plotter.py +483 -0
- specproc/utils/__init__.py +1 -0
- specproc/utils/airvac.py +123 -0
- specproc/utils/fits_io.py +269 -0
- specproc/utils/image_processing.py +426 -0
- specproc/utils/overscan.py +600 -0
- specproc-1.0.0.dist-info/METADATA +668 -0
- specproc-1.0.0.dist-info/RECORD +61 -0
- specproc-1.0.0.dist-info/WHEEL +5 -0
- specproc-1.0.0.dist-info/entry_points.txt +5 -0
- specproc-1.0.0.dist-info/licenses/LICENSE +674 -0
- specproc-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)")
|