solarviewer 1.0.2__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.
- solar_radio_image_viewer/__init__.py +12 -0
- solar_radio_image_viewer/assets/add_tab_default.png +0 -0
- solar_radio_image_viewer/assets/add_tab_default_light.png +0 -0
- solar_radio_image_viewer/assets/add_tab_hover.png +0 -0
- solar_radio_image_viewer/assets/add_tab_hover_light.png +0 -0
- solar_radio_image_viewer/assets/browse.png +0 -0
- solar_radio_image_viewer/assets/browse_light.png +0 -0
- solar_radio_image_viewer/assets/close_tab_default.png +0 -0
- solar_radio_image_viewer/assets/close_tab_default_light.png +0 -0
- solar_radio_image_viewer/assets/close_tab_hover.png +0 -0
- solar_radio_image_viewer/assets/close_tab_hover_light.png +0 -0
- solar_radio_image_viewer/assets/ellipse_selection.png +0 -0
- solar_radio_image_viewer/assets/ellipse_selection_light.png +0 -0
- solar_radio_image_viewer/assets/icons8-ellipse-90.png +0 -0
- solar_radio_image_viewer/assets/icons8-ellipse-90_light.png +0 -0
- solar_radio_image_viewer/assets/icons8-info-90.png +0 -0
- solar_radio_image_viewer/assets/icons8-info-90_light.png +0 -0
- solar_radio_image_viewer/assets/profile.png +0 -0
- solar_radio_image_viewer/assets/profile_light.png +0 -0
- solar_radio_image_viewer/assets/rectangle_selection.png +0 -0
- solar_radio_image_viewer/assets/rectangle_selection_light.png +0 -0
- solar_radio_image_viewer/assets/reset.png +0 -0
- solar_radio_image_viewer/assets/reset_light.png +0 -0
- solar_radio_image_viewer/assets/ruler.png +0 -0
- solar_radio_image_viewer/assets/ruler_light.png +0 -0
- solar_radio_image_viewer/assets/search.png +0 -0
- solar_radio_image_viewer/assets/search_light.png +0 -0
- solar_radio_image_viewer/assets/settings.png +0 -0
- solar_radio_image_viewer/assets/settings_light.png +0 -0
- solar_radio_image_viewer/assets/splash.fits +0 -0
- solar_radio_image_viewer/assets/zoom_60arcmin.png +0 -0
- solar_radio_image_viewer/assets/zoom_60arcmin_light.png +0 -0
- solar_radio_image_viewer/assets/zoom_in.png +0 -0
- solar_radio_image_viewer/assets/zoom_in_light.png +0 -0
- solar_radio_image_viewer/assets/zoom_out.png +0 -0
- solar_radio_image_viewer/assets/zoom_out_light.png +0 -0
- solar_radio_image_viewer/create_video.py +1345 -0
- solar_radio_image_viewer/dialogs.py +2665 -0
- solar_radio_image_viewer/from_simpl/__init__.py +184 -0
- solar_radio_image_viewer/from_simpl/caltable_visualizer.py +1001 -0
- solar_radio_image_viewer/from_simpl/dynamic_spectra_dialog.py +332 -0
- solar_radio_image_viewer/from_simpl/make_dynamic_spectra.py +351 -0
- solar_radio_image_viewer/from_simpl/pipeline_logger_gui.py +1232 -0
- solar_radio_image_viewer/from_simpl/simpl_theme.py +352 -0
- solar_radio_image_viewer/from_simpl/utils.py +984 -0
- solar_radio_image_viewer/from_simpl/view_dynamic_spectra_GUI.py +1975 -0
- solar_radio_image_viewer/helioprojective.py +1916 -0
- solar_radio_image_viewer/helioprojective_viewer.py +817 -0
- solar_radio_image_viewer/helioviewer_browser.py +1514 -0
- solar_radio_image_viewer/main.py +148 -0
- solar_radio_image_viewer/move_phasecenter.py +1269 -0
- solar_radio_image_viewer/napari_viewer.py +368 -0
- solar_radio_image_viewer/noaa_events/__init__.py +32 -0
- solar_radio_image_viewer/noaa_events/noaa_events.py +430 -0
- solar_radio_image_viewer/noaa_events/noaa_events_gui.py +1922 -0
- solar_radio_image_viewer/norms.py +293 -0
- solar_radio_image_viewer/radio_data_downloader/__init__.py +25 -0
- solar_radio_image_viewer/radio_data_downloader/radio_data_downloader.py +756 -0
- solar_radio_image_viewer/radio_data_downloader/radio_data_downloader_gui.py +528 -0
- solar_radio_image_viewer/searchable_combobox.py +220 -0
- solar_radio_image_viewer/solar_context/__init__.py +41 -0
- solar_radio_image_viewer/solar_context/active_regions.py +371 -0
- solar_radio_image_viewer/solar_context/cme_alerts.py +234 -0
- solar_radio_image_viewer/solar_context/context_images.py +297 -0
- solar_radio_image_viewer/solar_context/realtime_data.py +528 -0
- solar_radio_image_viewer/solar_data_downloader/__init__.py +35 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader.py +1667 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_cli.py +901 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_gui.py +1210 -0
- solar_radio_image_viewer/styles.py +643 -0
- solar_radio_image_viewer/utils/__init__.py +32 -0
- solar_radio_image_viewer/utils/rate_limiter.py +255 -0
- solar_radio_image_viewer/utils.py +952 -0
- solar_radio_image_viewer/video_dialog.py +2629 -0
- solar_radio_image_viewer/video_utils.py +656 -0
- solar_radio_image_viewer/viewer.py +11174 -0
- solarviewer-1.0.2.dist-info/METADATA +343 -0
- solarviewer-1.0.2.dist-info/RECORD +82 -0
- solarviewer-1.0.2.dist-info/WHEEL +5 -0
- solarviewer-1.0.2.dist-info/entry_points.txt +8 -0
- solarviewer-1.0.2.dist-info/licenses/LICENSE +21 -0
- solarviewer-1.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2629 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from PyQt5.QtWidgets import (
|
|
7
|
+
QDialog,
|
|
8
|
+
QMainWindow,
|
|
9
|
+
QVBoxLayout,
|
|
10
|
+
QHBoxLayout,
|
|
11
|
+
QGridLayout,
|
|
12
|
+
QLabel,
|
|
13
|
+
QLineEdit,
|
|
14
|
+
QPushButton,
|
|
15
|
+
QFileDialog,
|
|
16
|
+
QGroupBox,
|
|
17
|
+
QComboBox,
|
|
18
|
+
QCheckBox,
|
|
19
|
+
QSpinBox,
|
|
20
|
+
QDoubleSpinBox,
|
|
21
|
+
QProgressBar,
|
|
22
|
+
QMessageBox,
|
|
23
|
+
QRadioButton,
|
|
24
|
+
QButtonGroup,
|
|
25
|
+
QApplication,
|
|
26
|
+
QFrame,
|
|
27
|
+
QTabWidget,
|
|
28
|
+
QScrollArea,
|
|
29
|
+
QWidget,
|
|
30
|
+
QProgressDialog,
|
|
31
|
+
QFormLayout,
|
|
32
|
+
)
|
|
33
|
+
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSize
|
|
34
|
+
from PyQt5.QtGui import QIcon
|
|
35
|
+
import matplotlib.pyplot as plt
|
|
36
|
+
from matplotlib.figure import Figure
|
|
37
|
+
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
|
38
|
+
import numpy as np
|
|
39
|
+
import glob
|
|
40
|
+
import time
|
|
41
|
+
import threading
|
|
42
|
+
import multiprocessing
|
|
43
|
+
import psutil
|
|
44
|
+
import shutil
|
|
45
|
+
from contextlib import contextmanager
|
|
46
|
+
|
|
47
|
+
import matplotlib.style as mplstyle
|
|
48
|
+
mplstyle.use('fast')
|
|
49
|
+
|
|
50
|
+
@contextmanager
|
|
51
|
+
def wait_cursor():
|
|
52
|
+
"""Context manager to show a wait cursor"""
|
|
53
|
+
QApplication.setOverrideCursor(Qt.WaitCursor)
|
|
54
|
+
try:
|
|
55
|
+
yield
|
|
56
|
+
finally:
|
|
57
|
+
QApplication.restoreOverrideCursor()
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
from .create_video import (
|
|
61
|
+
create_video,
|
|
62
|
+
VideoProgress,
|
|
63
|
+
load_fits_data,
|
|
64
|
+
apply_visualization,
|
|
65
|
+
format_timestamp,
|
|
66
|
+
get_norm,
|
|
67
|
+
)
|
|
68
|
+
from .norms import (
|
|
69
|
+
SqrtNorm,
|
|
70
|
+
AsinhNorm,
|
|
71
|
+
PowerNorm,
|
|
72
|
+
ZScaleNorm,
|
|
73
|
+
HistEqNorm,
|
|
74
|
+
)
|
|
75
|
+
except ImportError:
|
|
76
|
+
from create_video import (
|
|
77
|
+
create_video,
|
|
78
|
+
VideoProgress,
|
|
79
|
+
load_fits_data,
|
|
80
|
+
apply_visualization,
|
|
81
|
+
format_timestamp,
|
|
82
|
+
get_norm,
|
|
83
|
+
)
|
|
84
|
+
from norms import (
|
|
85
|
+
SqrtNorm,
|
|
86
|
+
AsinhNorm,
|
|
87
|
+
PowerNorm,
|
|
88
|
+
ZScaleNorm,
|
|
89
|
+
HistEqNorm,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT
|
|
94
|
+
|
|
95
|
+
class PreviewWindow(QMainWindow):
|
|
96
|
+
"""
|
|
97
|
+
Separate window for previewing images
|
|
98
|
+
"""
|
|
99
|
+
def __init__(self, parent=None):
|
|
100
|
+
super().__init__(parent)
|
|
101
|
+
self.setWindowTitle("Preview")
|
|
102
|
+
self.resize(600, 500)
|
|
103
|
+
|
|
104
|
+
# Central widget and layout
|
|
105
|
+
central_widget = QWidget()
|
|
106
|
+
self.setCentralWidget(central_widget)
|
|
107
|
+
layout = QVBoxLayout(central_widget)
|
|
108
|
+
|
|
109
|
+
# Matplotlib figure
|
|
110
|
+
self.figure = Figure(figsize=(5, 4), dpi=100)
|
|
111
|
+
self.canvas = FigureCanvas(self.figure)
|
|
112
|
+
|
|
113
|
+
# Toolbar
|
|
114
|
+
self.toolbar = NavigationToolbar2QT(self.canvas, self)
|
|
115
|
+
|
|
116
|
+
layout.addWidget(self.toolbar)
|
|
117
|
+
layout.addWidget(self.canvas)
|
|
118
|
+
|
|
119
|
+
# Ensure cleanup on close?
|
|
120
|
+
# Actually we want to keep it alive via the parent reference but just hide/show
|
|
121
|
+
# self.setAttribute(Qt.WA_DeleteOnClose) # Don't delete, just hide
|
|
122
|
+
|
|
123
|
+
def closeEvent(self, event):
|
|
124
|
+
# Just hide instead of closing/destroying to keep state
|
|
125
|
+
# user can re-open via button
|
|
126
|
+
self.hide()
|
|
127
|
+
event.ignore()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class VideoCreationDialog(QDialog):
|
|
131
|
+
"""
|
|
132
|
+
Dialog for creating videos from FITS files
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self, parent=None, current_file=None):
|
|
136
|
+
super().__init__(parent)
|
|
137
|
+
# Force window behavior to ensure maximize button works
|
|
138
|
+
self.setWindowFlags(Qt.Window | Qt.WindowMinMaxButtonsHint | Qt.WindowCloseButtonHint)
|
|
139
|
+
self.parent = parent
|
|
140
|
+
self.current_file = current_file
|
|
141
|
+
self.progress_tracker = None
|
|
142
|
+
self.preview_image = None
|
|
143
|
+
self.reference_image = None
|
|
144
|
+
|
|
145
|
+
self.setWindowTitle("Create Video")
|
|
146
|
+
self.resize(650, 700)
|
|
147
|
+
|
|
148
|
+
# Set up the UI
|
|
149
|
+
self.setup_ui()
|
|
150
|
+
|
|
151
|
+
# Initialize the separate preview window FIRST (before any methods that access figure)
|
|
152
|
+
self._preview_window = PreviewWindow(self)
|
|
153
|
+
self._preview_window.show()
|
|
154
|
+
|
|
155
|
+
# Initialize stretch controls
|
|
156
|
+
self.update_gamma_controls()
|
|
157
|
+
|
|
158
|
+
# Initialize range mode controls (for default "Auto per Frame")
|
|
159
|
+
self.toggle_range_mode(self.range_mode_combo.currentIndex())
|
|
160
|
+
|
|
161
|
+
# Initialize with current file if provided
|
|
162
|
+
if current_file:
|
|
163
|
+
dir_path = os.path.dirname(current_file)
|
|
164
|
+
# If file has no directory (just filename), use CWD
|
|
165
|
+
if not dir_path:
|
|
166
|
+
dir_path = os.getcwd()
|
|
167
|
+
self.input_directory_edit.setText(dir_path)
|
|
168
|
+
file_ext = os.path.splitext(current_file)[1]
|
|
169
|
+
self.input_pattern_edit.setText(f"*{file_ext}")
|
|
170
|
+
|
|
171
|
+
# Set reference image to current file (use absolute path)
|
|
172
|
+
abs_file = os.path.join(dir_path, os.path.basename(current_file)) if not os.path.isabs(current_file) else current_file
|
|
173
|
+
self.reference_image = abs_file
|
|
174
|
+
self.reference_image_edit.setText(abs_file)
|
|
175
|
+
|
|
176
|
+
# Set default output file
|
|
177
|
+
self.output_file_edit.setText(os.path.join(dir_path, "output_video.mp4"))
|
|
178
|
+
|
|
179
|
+
# Load visualization settings from parent if available
|
|
180
|
+
if hasattr(parent, "colormap") and parent.colormap:
|
|
181
|
+
idx = self.colormap_combo.findText(parent.colormap)
|
|
182
|
+
if idx >= 0:
|
|
183
|
+
self.colormap_combo.setCurrentIndex(idx)
|
|
184
|
+
|
|
185
|
+
if hasattr(parent, "stretch_type") and parent.stretch_type:
|
|
186
|
+
idx = self.stretch_combo.findText(parent.stretch_type.capitalize())
|
|
187
|
+
if idx >= 0:
|
|
188
|
+
self.stretch_combo.setCurrentIndex(idx)
|
|
189
|
+
|
|
190
|
+
if hasattr(parent, "gamma") and parent.gamma:
|
|
191
|
+
self.gamma_spinbox.setValue(parent.gamma)
|
|
192
|
+
|
|
193
|
+
if hasattr(parent, "vmin") and parent.vmin:
|
|
194
|
+
self.vmin_spinbox.setValue(parent.vmin)
|
|
195
|
+
|
|
196
|
+
if hasattr(parent, "vmax") and parent.vmax:
|
|
197
|
+
self.vmax_spinbox.setValue(parent.vmax)
|
|
198
|
+
|
|
199
|
+
# Auto-scan for files
|
|
200
|
+
self.preview_input_files()
|
|
201
|
+
else:
|
|
202
|
+
# No file provided - use CWD as default
|
|
203
|
+
cwd = os.getcwd()
|
|
204
|
+
if os.path.isdir(cwd):
|
|
205
|
+
self.input_directory_edit.setText(cwd)
|
|
206
|
+
self.output_file_edit.setText(os.path.join(cwd, "output_video.mp4"))
|
|
207
|
+
# Check if CWD has any FITS files to set pattern
|
|
208
|
+
fits_in_cwd = glob.glob(os.path.join(cwd, "*.fits")) + glob.glob(os.path.join(cwd, "*.fts"))
|
|
209
|
+
if fits_in_cwd:
|
|
210
|
+
self.input_pattern_edit.setText("*.fits")
|
|
211
|
+
# Auto-scan for files
|
|
212
|
+
self.preview_input_files()
|
|
213
|
+
|
|
214
|
+
# Update preview if we have a valid reference image
|
|
215
|
+
if self.reference_image:
|
|
216
|
+
self.update_preview(self.reference_image)
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def figure(self):
|
|
220
|
+
return self._preview_window.figure
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def canvas(self):
|
|
224
|
+
return self._preview_window.canvas
|
|
225
|
+
|
|
226
|
+
def show_preview_window(self):
|
|
227
|
+
"""Show the preview window if it's hidden or raise it"""
|
|
228
|
+
self._preview_window.show()
|
|
229
|
+
self._preview_window.raise_()
|
|
230
|
+
self._preview_window.activateWindow()
|
|
231
|
+
|
|
232
|
+
def setup_ui(self):
|
|
233
|
+
"""Set up the UI elements"""
|
|
234
|
+
self.setSizeGripEnabled(True)
|
|
235
|
+
main_layout = QVBoxLayout(self)
|
|
236
|
+
main_layout.setContentsMargins(16, 16, 16, 16)
|
|
237
|
+
main_layout.setSpacing(12)
|
|
238
|
+
|
|
239
|
+
# Create tab widget
|
|
240
|
+
self.tab_widget = QTabWidget()
|
|
241
|
+
|
|
242
|
+
# Create tabs
|
|
243
|
+
input_tab = QWidget()
|
|
244
|
+
display_tab = QWidget()
|
|
245
|
+
overlay_tab = QWidget()
|
|
246
|
+
region_tab = QWidget() # New tab for region selection
|
|
247
|
+
output_tab = QWidget()
|
|
248
|
+
|
|
249
|
+
# Set up tab layouts
|
|
250
|
+
input_layout = QVBoxLayout(input_tab)
|
|
251
|
+
display_layout = QVBoxLayout(display_tab)
|
|
252
|
+
overlay_layout = QVBoxLayout(overlay_tab)
|
|
253
|
+
region_layout = QVBoxLayout(region_tab) # Layout for the new region tab
|
|
254
|
+
output_layout = QVBoxLayout(output_tab)
|
|
255
|
+
|
|
256
|
+
# Create the preview control section
|
|
257
|
+
preview_controls_group = QGroupBox("Preview Controls")
|
|
258
|
+
preview_controls_layout = QHBoxLayout(preview_controls_group)
|
|
259
|
+
preview_controls_layout.setContentsMargins(10, 8, 10, 8)
|
|
260
|
+
preview_controls_layout.setSpacing(12)
|
|
261
|
+
|
|
262
|
+
# Add "Show Preview Window" button
|
|
263
|
+
show_preview_btn = QPushButton("Show Preview")
|
|
264
|
+
show_preview_btn.setMinimumWidth(140)
|
|
265
|
+
show_preview_btn.clicked.connect(self.show_preview_window)
|
|
266
|
+
preview_controls_layout.addWidget(show_preview_btn)
|
|
267
|
+
|
|
268
|
+
# Add "Update Preview" button
|
|
269
|
+
update_preview_btn = QPushButton("Update")
|
|
270
|
+
update_preview_btn.setMinimumWidth(140)
|
|
271
|
+
update_preview_btn.clicked.connect(self.update_preview_from_reference)
|
|
272
|
+
preview_controls_layout.addWidget(update_preview_btn)
|
|
273
|
+
|
|
274
|
+
preview_controls_layout.addStretch()
|
|
275
|
+
|
|
276
|
+
# Add "Contour Mode" checkbox
|
|
277
|
+
self.contour_video_enabled = QCheckBox("Contour Mode")
|
|
278
|
+
self.contour_video_enabled.setChecked(False)
|
|
279
|
+
self.contour_video_enabled.stateChanged.connect(self.toggle_contour_mode)
|
|
280
|
+
preview_controls_layout.addWidget(self.contour_video_enabled)
|
|
281
|
+
|
|
282
|
+
# Add preview controls to the main layout first
|
|
283
|
+
main_layout.addWidget(preview_controls_group)
|
|
284
|
+
|
|
285
|
+
# ------ Input Tab ------
|
|
286
|
+
# Create a scroll area for the input tab
|
|
287
|
+
input_scroll = QScrollArea()
|
|
288
|
+
input_scroll.setWidgetResizable(True)
|
|
289
|
+
input_scroll_content = QWidget()
|
|
290
|
+
input_layout = QVBoxLayout(input_scroll_content)
|
|
291
|
+
input_layout.setContentsMargins(12, 12, 12, 12)
|
|
292
|
+
input_layout.setSpacing(12)
|
|
293
|
+
input_scroll.setWidget(input_scroll_content)
|
|
294
|
+
|
|
295
|
+
# Input pattern section
|
|
296
|
+
input_group = QGroupBox("Input Files")
|
|
297
|
+
input_group_layout = QGridLayout(input_group)
|
|
298
|
+
input_group_layout.setContentsMargins(12, 16, 12, 12)
|
|
299
|
+
input_group_layout.setHorizontalSpacing(10)
|
|
300
|
+
input_group_layout.setVerticalSpacing(8)
|
|
301
|
+
|
|
302
|
+
# 1. Directory field
|
|
303
|
+
input_group_layout.addWidget(QLabel("Input Directory:"), 0, 0)
|
|
304
|
+
self.input_directory_edit = QLineEdit()
|
|
305
|
+
input_group_layout.addWidget(self.input_directory_edit, 0, 1)
|
|
306
|
+
|
|
307
|
+
browse_dir_btn = QPushButton("Browse")
|
|
308
|
+
browse_dir_btn.clicked.connect(
|
|
309
|
+
lambda: (
|
|
310
|
+
(
|
|
311
|
+
self.input_directory_edit.setText(os.getcwd())
|
|
312
|
+
if not self.input_directory_edit.text()
|
|
313
|
+
else None
|
|
314
|
+
),
|
|
315
|
+
self.browse_input_directory(),
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
input_group_layout.addWidget(browse_dir_btn, 0, 2)
|
|
319
|
+
|
|
320
|
+
# 2. Pattern field
|
|
321
|
+
input_group_layout.addWidget(QLabel("File Pattern:"), 1, 0)
|
|
322
|
+
self.input_pattern_edit = QLineEdit()
|
|
323
|
+
self.input_pattern_edit.setPlaceholderText("e.g., *.fits or *_171*.fits")
|
|
324
|
+
input_group_layout.addWidget(self.input_pattern_edit, 1, 1)
|
|
325
|
+
|
|
326
|
+
# Scan button
|
|
327
|
+
scan_btn = QPushButton("Scan")
|
|
328
|
+
scan_btn.clicked.connect(self.preview_input_files)
|
|
329
|
+
input_group_layout.addWidget(scan_btn, 1, 2)
|
|
330
|
+
|
|
331
|
+
# File sorting
|
|
332
|
+
input_group_layout.addWidget(QLabel("Sort Files By:"), 2, 0)
|
|
333
|
+
self.sort_combo = QComboBox()
|
|
334
|
+
self.sort_combo.addItems(["Filename", "Date/Time", "Extension"])
|
|
335
|
+
input_group_layout.addWidget(self.sort_combo, 2, 1)
|
|
336
|
+
|
|
337
|
+
# Stokes parameter
|
|
338
|
+
input_group_layout.addWidget(QLabel("Stokes Parameter:"), 3, 0)
|
|
339
|
+
self.stokes_combo = QComboBox()
|
|
340
|
+
self.stokes_combo.addItems(["I", "Q", "U", "V"])
|
|
341
|
+
self.stokes_combo.currentIndexChanged.connect(self.update_preview_settings)
|
|
342
|
+
input_group_layout.addWidget(self.stokes_combo, 3, 1)
|
|
343
|
+
|
|
344
|
+
input_layout.addWidget(input_group)
|
|
345
|
+
|
|
346
|
+
# Files found status (separate from the group) - use theme-compatible styling
|
|
347
|
+
self.files_found_label = QLabel("No files found yet")
|
|
348
|
+
self.files_found_label.setObjectName("StatusLabel")
|
|
349
|
+
input_layout.addWidget(self.files_found_label)
|
|
350
|
+
input_layout.addStretch()
|
|
351
|
+
|
|
352
|
+
# ------ Display Tab ------
|
|
353
|
+
# Create a scroll area for the display tab
|
|
354
|
+
display_scroll = QScrollArea()
|
|
355
|
+
display_scroll.setWidgetResizable(True)
|
|
356
|
+
display_scroll_content = QWidget()
|
|
357
|
+
display_layout = QVBoxLayout(display_scroll_content)
|
|
358
|
+
display_layout.setContentsMargins(12, 12, 12, 12)
|
|
359
|
+
display_layout.setSpacing(12)
|
|
360
|
+
display_scroll.setWidget(display_scroll_content)
|
|
361
|
+
|
|
362
|
+
# Reference image for display settings
|
|
363
|
+
reference_group = QGroupBox("Reference Image")
|
|
364
|
+
reference_layout = QGridLayout(reference_group)
|
|
365
|
+
reference_layout.setContentsMargins(12, 16, 12, 12)
|
|
366
|
+
reference_layout.setHorizontalSpacing(10)
|
|
367
|
+
reference_layout.setVerticalSpacing(8)
|
|
368
|
+
|
|
369
|
+
reference_layout.addWidget(QLabel("Reference Image:"), 0, 0)
|
|
370
|
+
self.reference_image_edit = QLineEdit()
|
|
371
|
+
self.reference_image_edit.setReadOnly(True) # Make it read-only
|
|
372
|
+
reference_layout.addWidget(self.reference_image_edit, 0, 1)
|
|
373
|
+
|
|
374
|
+
browse_reference_btn = QPushButton("Browse")
|
|
375
|
+
browse_reference_btn.clicked.connect(self.browse_reference_image)
|
|
376
|
+
reference_layout.addWidget(browse_reference_btn, 0, 2)
|
|
377
|
+
|
|
378
|
+
display_layout.addWidget(reference_group)
|
|
379
|
+
|
|
380
|
+
# Visualization settings section
|
|
381
|
+
viz_group = QGroupBox("Visualization")
|
|
382
|
+
viz_form = QFormLayout(viz_group)
|
|
383
|
+
viz_form.setContentsMargins(12, 16, 12, 12)
|
|
384
|
+
viz_form.setVerticalSpacing(8)
|
|
385
|
+
viz_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
386
|
+
|
|
387
|
+
# Colormap
|
|
388
|
+
self.colormap_combo = QComboBox()
|
|
389
|
+
colormaps = sorted(
|
|
390
|
+
[cmap for cmap in plt.colormaps() if not cmap.endswith("_r")]
|
|
391
|
+
)
|
|
392
|
+
self.colormap_combo.addItems(colormaps)
|
|
393
|
+
idx = self.colormap_combo.findText("viridis")
|
|
394
|
+
if idx >= 0:
|
|
395
|
+
self.colormap_combo.setCurrentIndex(idx)
|
|
396
|
+
self.colormap_combo.currentIndexChanged.connect(self.update_preview_settings)
|
|
397
|
+
viz_form.addRow("Colormap:", self.colormap_combo)
|
|
398
|
+
|
|
399
|
+
# Stretch
|
|
400
|
+
self.stretch_combo = QComboBox()
|
|
401
|
+
self.stretch_combo.addItems([
|
|
402
|
+
"Linear", "Log", "Sqrt", "Power", "Arcsinh", "ZScale", "Histogram Equalization"
|
|
403
|
+
])
|
|
404
|
+
self.stretch_combo.setItemData(0, "Linear stretch - no transformation", Qt.ToolTipRole)
|
|
405
|
+
self.stretch_combo.setItemData(1, "Logarithmic stretch - enhances very faint features", Qt.ToolTipRole)
|
|
406
|
+
self.stretch_combo.setItemData(2, "Square root stretch - enhances faint features", Qt.ToolTipRole)
|
|
407
|
+
self.stretch_combo.setItemData(3, "Power law stretch - adjustable using gamma", Qt.ToolTipRole)
|
|
408
|
+
self.stretch_combo.setItemData(4, "Arcsinh stretch - similar to log but handles negative values", Qt.ToolTipRole)
|
|
409
|
+
self.stretch_combo.setItemData(5, "ZScale stretch - automatic contrast based on image statistics", Qt.ToolTipRole)
|
|
410
|
+
self.stretch_combo.setItemData(6, "Histogram equalization - enhances contrast by redistributing intensities", Qt.ToolTipRole)
|
|
411
|
+
self.stretch_combo.currentIndexChanged.connect(self.update_preview_settings)
|
|
412
|
+
self.stretch_combo.currentIndexChanged.connect(self.update_gamma_controls)
|
|
413
|
+
viz_form.addRow("Stretch:", self.stretch_combo)
|
|
414
|
+
|
|
415
|
+
# Gamma (for power stretch)
|
|
416
|
+
self.gamma_spinbox = QDoubleSpinBox()
|
|
417
|
+
self.gamma_spinbox.setRange(0.1, 10.0)
|
|
418
|
+
self.gamma_spinbox.setSingleStep(0.1)
|
|
419
|
+
self.gamma_spinbox.setValue(1.0)
|
|
420
|
+
self.gamma_spinbox.valueChanged.connect(self.update_preview_settings)
|
|
421
|
+
viz_form.addRow("Gamma:", self.gamma_spinbox)
|
|
422
|
+
|
|
423
|
+
# Colorbar option
|
|
424
|
+
self.colorbar_check = QCheckBox("Show Colorbar")
|
|
425
|
+
self.colorbar_check.setChecked(True)
|
|
426
|
+
self.colorbar_check.stateChanged.connect(self.update_preview_settings)
|
|
427
|
+
viz_form.addRow("", self.colorbar_check)
|
|
428
|
+
|
|
429
|
+
display_layout.addWidget(viz_group)
|
|
430
|
+
|
|
431
|
+
# Range settings section
|
|
432
|
+
range_group = QGroupBox("Range Scaling")
|
|
433
|
+
range_form = QFormLayout(range_group)
|
|
434
|
+
range_form.setContentsMargins(12, 16, 12, 12)
|
|
435
|
+
range_form.setVerticalSpacing(8)
|
|
436
|
+
range_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
437
|
+
|
|
438
|
+
# Range mode selection
|
|
439
|
+
self.range_mode_combo = QComboBox()
|
|
440
|
+
self.range_mode_combo.addItems(["Fixed Range", "Auto per Frame", "Global Auto"])
|
|
441
|
+
self.range_mode_combo.setCurrentIndex(1) # Default to "Auto per Frame"
|
|
442
|
+
self.range_mode_combo.setToolTip(
|
|
443
|
+
"Fixed Range: Use the min/max values specified below for all frames\n"
|
|
444
|
+
"Auto per Frame: Calculate min/max independently for each frame based on percentiles\n"
|
|
445
|
+
"Global Auto: Calculate min/max once from all frames based on percentiles"
|
|
446
|
+
)
|
|
447
|
+
self.range_mode_combo.currentIndexChanged.connect(self.toggle_range_mode)
|
|
448
|
+
range_form.addRow("Mode:", self.range_mode_combo)
|
|
449
|
+
|
|
450
|
+
# Add explanatory label
|
|
451
|
+
'''self.range_explanation_label = QLabel(
|
|
452
|
+
"Auto Per Frame: Min/max calculated independently for each frame"
|
|
453
|
+
)
|
|
454
|
+
self.range_explanation_label.setObjectName("SecondaryText")
|
|
455
|
+
range_form.addRow("", self.range_explanation_label)'''
|
|
456
|
+
|
|
457
|
+
# Min/Max values in a horizontal layout
|
|
458
|
+
minmax_widget = QWidget()
|
|
459
|
+
minmax_layout = QHBoxLayout(minmax_widget)
|
|
460
|
+
minmax_layout.setContentsMargins(0, 0, 0, 0)
|
|
461
|
+
minmax_layout.setSpacing(10)
|
|
462
|
+
|
|
463
|
+
minmax_layout.addWidget(QLabel("Min:"))
|
|
464
|
+
self.vmin_spinbox = QDoubleSpinBox()
|
|
465
|
+
self.vmin_spinbox.setRange(-1e10, 1e10)
|
|
466
|
+
self.vmin_spinbox.setDecimals(2)
|
|
467
|
+
self.vmin_spinbox.setValue(0)
|
|
468
|
+
self.vmin_spinbox.valueChanged.connect(self.update_preview_settings)
|
|
469
|
+
minmax_layout.addWidget(self.vmin_spinbox)
|
|
470
|
+
|
|
471
|
+
minmax_layout.addWidget(QLabel("Max:"))
|
|
472
|
+
self.vmax_spinbox = QDoubleSpinBox()
|
|
473
|
+
self.vmax_spinbox.setRange(-1e10, 1e10)
|
|
474
|
+
self.vmax_spinbox.setDecimals(2)
|
|
475
|
+
self.vmax_spinbox.setValue(3000)
|
|
476
|
+
self.vmax_spinbox.valueChanged.connect(self.update_preview_settings)
|
|
477
|
+
minmax_layout.addWidget(self.vmax_spinbox)
|
|
478
|
+
|
|
479
|
+
range_form.addRow("Values:", minmax_widget)
|
|
480
|
+
|
|
481
|
+
display_layout.addWidget(range_group)
|
|
482
|
+
|
|
483
|
+
# Frame settings section
|
|
484
|
+
frame_group = QGroupBox("Frame Settings")
|
|
485
|
+
frame_form = QFormLayout(frame_group)
|
|
486
|
+
frame_form.setContentsMargins(12, 16, 12, 12)
|
|
487
|
+
frame_form.setVerticalSpacing(8)
|
|
488
|
+
frame_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
489
|
+
|
|
490
|
+
# Frame resize in a horizontal layout
|
|
491
|
+
size_widget = QWidget()
|
|
492
|
+
size_layout = QHBoxLayout(size_widget)
|
|
493
|
+
size_layout.setContentsMargins(0, 0, 0, 0)
|
|
494
|
+
size_layout.setSpacing(10)
|
|
495
|
+
|
|
496
|
+
size_layout.addWidget(QLabel("Width:"))
|
|
497
|
+
self.width_spinbox = QSpinBox()
|
|
498
|
+
self.width_spinbox.setRange(0, 7680)
|
|
499
|
+
self.width_spinbox.setValue(0)
|
|
500
|
+
self.width_spinbox.setSpecialValueText("Original")
|
|
501
|
+
size_layout.addWidget(self.width_spinbox)
|
|
502
|
+
|
|
503
|
+
size_layout.addWidget(QLabel("Height:"))
|
|
504
|
+
self.height_spinbox = QSpinBox()
|
|
505
|
+
self.height_spinbox.setRange(0, 4320)
|
|
506
|
+
self.height_spinbox.setValue(0)
|
|
507
|
+
self.height_spinbox.setSpecialValueText("Original")
|
|
508
|
+
size_layout.addWidget(self.height_spinbox)
|
|
509
|
+
|
|
510
|
+
frame_form.addRow("Size:", size_widget)
|
|
511
|
+
|
|
512
|
+
display_layout.addWidget(frame_group)
|
|
513
|
+
|
|
514
|
+
# WCS Coordinates section
|
|
515
|
+
wcs_group = QGroupBox("Coordinate System")
|
|
516
|
+
wcs_form = QFormLayout(wcs_group)
|
|
517
|
+
wcs_form.setContentsMargins(12, 16, 12, 12)
|
|
518
|
+
wcs_form.setVerticalSpacing(8)
|
|
519
|
+
|
|
520
|
+
self.wcs_coords_check = QCheckBox("Show WCS Coordinates in Video")
|
|
521
|
+
self.wcs_coords_check.setChecked(True)
|
|
522
|
+
self.wcs_coords_check.setToolTip(
|
|
523
|
+
"Display RA/Dec or Solar-X/Solar-Y coordinates instead of pixels"
|
|
524
|
+
)
|
|
525
|
+
wcs_form.addRow("", self.wcs_coords_check)
|
|
526
|
+
|
|
527
|
+
self.wcs_info_label = QLabel("Coordinates will be detected from FITS header")
|
|
528
|
+
self.wcs_info_label.setObjectName("SecondaryText")
|
|
529
|
+
wcs_form.addRow("", self.wcs_info_label)
|
|
530
|
+
|
|
531
|
+
display_layout.addWidget(wcs_group)
|
|
532
|
+
|
|
533
|
+
# Add preset buttons similar to main application
|
|
534
|
+
presets_group = QGroupBox("Display Presets")
|
|
535
|
+
presets_layout = QGridLayout(presets_group)
|
|
536
|
+
|
|
537
|
+
# Auto range presets
|
|
538
|
+
auto_minmax_btn = QPushButton("Auto Min/Max")
|
|
539
|
+
auto_minmax_btn.clicked.connect(self.apply_auto_minmax)
|
|
540
|
+
presets_layout.addWidget(auto_minmax_btn, 0, 0)
|
|
541
|
+
|
|
542
|
+
auto_percentile_btn = QPushButton("Auto Percentile (1-99%)")
|
|
543
|
+
auto_percentile_btn.clicked.connect(self.apply_auto_percentile)
|
|
544
|
+
presets_layout.addWidget(auto_percentile_btn, 0, 1)
|
|
545
|
+
|
|
546
|
+
'''auto_median_btn = QPushButton("Auto Median±3×RMS")
|
|
547
|
+
auto_median_btn.clicked.connect(self.apply_auto_median_rms)
|
|
548
|
+
presets_layout.addWidget(auto_median_btn, 1, 0)'''
|
|
549
|
+
|
|
550
|
+
# AIA/HMI Presets
|
|
551
|
+
aia_preset_btn = QPushButton("AIA 171Å Preset")
|
|
552
|
+
aia_preset_btn.clicked.connect(self.apply_aia_preset)
|
|
553
|
+
presets_layout.addWidget(aia_preset_btn, 1, 0)
|
|
554
|
+
|
|
555
|
+
hmi_preset_btn = QPushButton("HMI Preset")
|
|
556
|
+
hmi_preset_btn.clicked.connect(self.apply_hmi_preset)
|
|
557
|
+
presets_layout.addWidget(hmi_preset_btn, 1, 1)
|
|
558
|
+
|
|
559
|
+
display_layout.addWidget(presets_group)
|
|
560
|
+
display_layout.addStretch()
|
|
561
|
+
|
|
562
|
+
# ------ Region Tab ------
|
|
563
|
+
# Create a scroll area for the region tab
|
|
564
|
+
region_scroll = QScrollArea()
|
|
565
|
+
region_scroll.setWidgetResizable(True)
|
|
566
|
+
region_scroll_content = QWidget()
|
|
567
|
+
region_layout = QVBoxLayout(region_scroll_content)
|
|
568
|
+
region_layout.setContentsMargins(12, 12, 12, 12)
|
|
569
|
+
region_layout.setSpacing(12)
|
|
570
|
+
region_scroll.setWidget(region_scroll_content)
|
|
571
|
+
|
|
572
|
+
region_group = QGroupBox("Region Selection")
|
|
573
|
+
region_main_layout = QVBoxLayout(region_group)
|
|
574
|
+
region_main_layout.setContentsMargins(12, 16, 12, 12)
|
|
575
|
+
region_main_layout.setSpacing(12)
|
|
576
|
+
|
|
577
|
+
# Enable region selection
|
|
578
|
+
self.region_enabled = QCheckBox("Enable Region Selection (Zoomed Video)")
|
|
579
|
+
self.region_enabled.setChecked(False)
|
|
580
|
+
self.region_enabled.stateChanged.connect(self.toggle_region_controls)
|
|
581
|
+
region_main_layout.addWidget(self.region_enabled)
|
|
582
|
+
|
|
583
|
+
# Coordinate inputs using form layout
|
|
584
|
+
coord_form = QFormLayout()
|
|
585
|
+
coord_form.setVerticalSpacing(8)
|
|
586
|
+
coord_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
587
|
+
|
|
588
|
+
# X range
|
|
589
|
+
x_range_widget = QWidget()
|
|
590
|
+
x_range_layout = QHBoxLayout(x_range_widget)
|
|
591
|
+
x_range_layout.setContentsMargins(0, 0, 0, 0)
|
|
592
|
+
x_range_layout.setSpacing(8)
|
|
593
|
+
|
|
594
|
+
self.x_min_spinbox = QSpinBox()
|
|
595
|
+
self.x_min_spinbox.setRange(0, 10000)
|
|
596
|
+
self.x_min_spinbox.setValue(0)
|
|
597
|
+
self.x_min_spinbox.valueChanged.connect(self.update_region_preview)
|
|
598
|
+
x_range_layout.addWidget(self.x_min_spinbox)
|
|
599
|
+
x_range_layout.addWidget(QLabel("to"))
|
|
600
|
+
self.x_max_spinbox = QSpinBox()
|
|
601
|
+
self.x_max_spinbox.setRange(0, 10000)
|
|
602
|
+
self.x_max_spinbox.setValue(1000)
|
|
603
|
+
self.x_max_spinbox.valueChanged.connect(self.update_region_preview)
|
|
604
|
+
x_range_layout.addWidget(self.x_max_spinbox)
|
|
605
|
+
x_range_layout.addWidget(QLabel("px"))
|
|
606
|
+
x_range_layout.addStretch()
|
|
607
|
+
coord_form.addRow("X Range:", x_range_widget)
|
|
608
|
+
|
|
609
|
+
# Y range
|
|
610
|
+
y_range_widget = QWidget()
|
|
611
|
+
y_range_layout = QHBoxLayout(y_range_widget)
|
|
612
|
+
y_range_layout.setContentsMargins(0, 0, 0, 0)
|
|
613
|
+
y_range_layout.setSpacing(8)
|
|
614
|
+
|
|
615
|
+
self.y_min_spinbox = QSpinBox()
|
|
616
|
+
self.y_min_spinbox.setRange(0, 10000)
|
|
617
|
+
self.y_min_spinbox.setValue(0)
|
|
618
|
+
self.y_min_spinbox.valueChanged.connect(self.update_region_preview)
|
|
619
|
+
y_range_layout.addWidget(self.y_min_spinbox)
|
|
620
|
+
y_range_layout.addWidget(QLabel("to"))
|
|
621
|
+
self.y_max_spinbox = QSpinBox()
|
|
622
|
+
self.y_max_spinbox.setRange(0, 10000)
|
|
623
|
+
self.y_max_spinbox.setValue(1000)
|
|
624
|
+
self.y_max_spinbox.valueChanged.connect(self.update_region_preview)
|
|
625
|
+
y_range_layout.addWidget(self.y_max_spinbox)
|
|
626
|
+
y_range_layout.addWidget(QLabel("px"))
|
|
627
|
+
y_range_layout.addStretch()
|
|
628
|
+
coord_form.addRow("Y Range:", y_range_widget)
|
|
629
|
+
|
|
630
|
+
region_main_layout.addLayout(coord_form)
|
|
631
|
+
|
|
632
|
+
# Interactive selection button
|
|
633
|
+
select_from_preview_btn = QPushButton("Select Region from Preview...")
|
|
634
|
+
select_from_preview_btn.clicked.connect(self.select_region_from_preview)
|
|
635
|
+
region_main_layout.addWidget(select_from_preview_btn)
|
|
636
|
+
|
|
637
|
+
region_layout.addWidget(region_group)
|
|
638
|
+
|
|
639
|
+
# Presets group
|
|
640
|
+
presets_group = QGroupBox("Quick Presets")
|
|
641
|
+
presets_layout = QHBoxLayout(presets_group)
|
|
642
|
+
presets_layout.setContentsMargins(12, 16, 12, 12)
|
|
643
|
+
presets_layout.setSpacing(10)
|
|
644
|
+
|
|
645
|
+
center_25_btn = QPushButton("Center 25%")
|
|
646
|
+
center_25_btn.clicked.connect(lambda: self.set_region_preset(0.25))
|
|
647
|
+
presets_layout.addWidget(center_25_btn)
|
|
648
|
+
|
|
649
|
+
center_50_btn = QPushButton("Center 50%")
|
|
650
|
+
center_50_btn.clicked.connect(lambda: self.set_region_preset(0.5))
|
|
651
|
+
presets_layout.addWidget(center_50_btn)
|
|
652
|
+
|
|
653
|
+
center_75_btn = QPushButton("Center 75%")
|
|
654
|
+
center_75_btn.clicked.connect(lambda: self.set_region_preset(0.75))
|
|
655
|
+
presets_layout.addWidget(center_75_btn)
|
|
656
|
+
|
|
657
|
+
region_layout.addWidget(presets_group)
|
|
658
|
+
|
|
659
|
+
# Help text at the bottom
|
|
660
|
+
help_label = QLabel(
|
|
661
|
+
"Create a video focused on a specific region of interest. "
|
|
662
|
+
"The selected region will be shown with a red rectangle in the preview."
|
|
663
|
+
)
|
|
664
|
+
help_label.setWordWrap(True)
|
|
665
|
+
help_label.setObjectName("SecondaryText")
|
|
666
|
+
region_layout.addWidget(help_label)
|
|
667
|
+
|
|
668
|
+
# Initially disable the region controls
|
|
669
|
+
self.toggle_region_controls(False)
|
|
670
|
+
|
|
671
|
+
region_layout.addStretch()
|
|
672
|
+
|
|
673
|
+
# ------ Overlay Tab ------
|
|
674
|
+
# Create a scroll area for the overlay tab
|
|
675
|
+
overlay_scroll = QScrollArea()
|
|
676
|
+
overlay_scroll.setWidgetResizable(True)
|
|
677
|
+
overlay_scroll_content = QWidget()
|
|
678
|
+
overlay_layout = QVBoxLayout(overlay_scroll_content)
|
|
679
|
+
overlay_layout.setContentsMargins(12, 12, 12, 12)
|
|
680
|
+
overlay_layout.setSpacing(12)
|
|
681
|
+
overlay_scroll.setWidget(overlay_scroll_content)
|
|
682
|
+
|
|
683
|
+
# Overlay settings section
|
|
684
|
+
overlay_group = QGroupBox("Overlay Settings")
|
|
685
|
+
overlay_main_layout = QHBoxLayout(overlay_group)
|
|
686
|
+
overlay_main_layout.setContentsMargins(12, 16, 12, 12)
|
|
687
|
+
|
|
688
|
+
# Use a form layout for cleaner organization
|
|
689
|
+
overlay_form = QFormLayout()
|
|
690
|
+
overlay_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
691
|
+
|
|
692
|
+
# Timestamp, Frame number, and Filename as checkboxes with labels
|
|
693
|
+
self.timestamp_check = QCheckBox("Add Timestamp")
|
|
694
|
+
self.timestamp_check.setChecked(True)
|
|
695
|
+
self.timestamp_check.setToolTip("Show date/time information from FITS header")
|
|
696
|
+
overlay_form.addRow("", self.timestamp_check)
|
|
697
|
+
|
|
698
|
+
self.frame_number_check = QCheckBox("Add Frame Number")
|
|
699
|
+
self.frame_number_check.setChecked(False)
|
|
700
|
+
self.frame_number_check.setToolTip("Show frame counter (e.g., Frame: 1/100)")
|
|
701
|
+
overlay_form.addRow("", self.frame_number_check)
|
|
702
|
+
|
|
703
|
+
self.filename_check = QCheckBox("Add Filename")
|
|
704
|
+
self.filename_check.setChecked(False)
|
|
705
|
+
self.filename_check.setToolTip("Show source filename in the video frame")
|
|
706
|
+
overlay_form.addRow("", self.filename_check)
|
|
707
|
+
|
|
708
|
+
overlay_main_layout.addLayout(overlay_form)
|
|
709
|
+
overlay_layout.addWidget(overlay_group)
|
|
710
|
+
|
|
711
|
+
# Min/Max Timeline section
|
|
712
|
+
timeline_group = QGroupBox("Min/Max Timeline")
|
|
713
|
+
timeline_layout = QVBoxLayout(timeline_group)
|
|
714
|
+
timeline_layout.setContentsMargins(12, 16, 12, 12)
|
|
715
|
+
timeline_layout.setSpacing(8)
|
|
716
|
+
|
|
717
|
+
self.minmax_timeline_check = QCheckBox("Show Min/Max Timeline Plot")
|
|
718
|
+
self.minmax_timeline_check.setChecked(False)
|
|
719
|
+
self.minmax_timeline_check.setToolTip(
|
|
720
|
+
"Display a continuous line plot showing min/max values for all frames"
|
|
721
|
+
)
|
|
722
|
+
timeline_layout.addWidget(self.minmax_timeline_check)
|
|
723
|
+
|
|
724
|
+
# Position selector
|
|
725
|
+
'''position_widget = QWidget()
|
|
726
|
+
position_layout = QHBoxLayout(position_widget)
|
|
727
|
+
position_layout.setContentsMargins(0, 0, 0, 0)
|
|
728
|
+
position_layout.setSpacing(10)
|
|
729
|
+
|
|
730
|
+
position_layout.addWidget(QLabel("Position:"))
|
|
731
|
+
self.timeline_position_combo = QComboBox()
|
|
732
|
+
self.timeline_position_combo.addItems([
|
|
733
|
+
"Bottom Left", "Bottom Right", "Top Left", "Top Right"
|
|
734
|
+
])
|
|
735
|
+
self.timeline_position_combo.setCurrentIndex(0)
|
|
736
|
+
position_layout.addWidget(self.timeline_position_combo)
|
|
737
|
+
position_layout.addStretch()
|
|
738
|
+
timeline_layout.addWidget(position_widget)'''
|
|
739
|
+
|
|
740
|
+
# Data source selector (for contour mode)
|
|
741
|
+
source_widget = QWidget()
|
|
742
|
+
source_layout = QHBoxLayout(source_widget)
|
|
743
|
+
source_layout.setContentsMargins(0, 0, 0, 0)
|
|
744
|
+
source_layout.setSpacing(10)
|
|
745
|
+
|
|
746
|
+
source_layout.addWidget(QLabel("Data Source:"))
|
|
747
|
+
self.timeline_source_combo = QComboBox()
|
|
748
|
+
self.timeline_source_combo.addItems(["Colormap", "Contours"])
|
|
749
|
+
self.timeline_source_combo.setCurrentIndex(0)
|
|
750
|
+
self.timeline_source_combo.setToolTip("Select which data to plot (applies in contour mode)")
|
|
751
|
+
source_layout.addWidget(self.timeline_source_combo)
|
|
752
|
+
source_layout.addStretch()
|
|
753
|
+
timeline_layout.addWidget(source_widget)
|
|
754
|
+
|
|
755
|
+
# Log scale option
|
|
756
|
+
self.timeline_log_scale_check = QCheckBox("Use Log Scale")
|
|
757
|
+
self.timeline_log_scale_check.setChecked(False)
|
|
758
|
+
self.timeline_log_scale_check.setToolTip("Plot values on logarithmic scale")
|
|
759
|
+
timeline_layout.addWidget(self.timeline_log_scale_check)
|
|
760
|
+
|
|
761
|
+
timeline_info = QLabel("Plots min (blue) and max (orange) pixel values over time")
|
|
762
|
+
timeline_info.setObjectName("SecondaryText")
|
|
763
|
+
timeline_layout.addWidget(timeline_info)
|
|
764
|
+
|
|
765
|
+
overlay_layout.addWidget(timeline_group)
|
|
766
|
+
|
|
767
|
+
# Add spacer to push controls to the top
|
|
768
|
+
overlay_layout.addStretch()
|
|
769
|
+
|
|
770
|
+
# ------ Output Tab ------
|
|
771
|
+
# Create a scroll area for the output tab
|
|
772
|
+
output_scroll = QScrollArea()
|
|
773
|
+
output_scroll.setWidgetResizable(True)
|
|
774
|
+
output_scroll_content = QWidget()
|
|
775
|
+
output_layout = QVBoxLayout(output_scroll_content)
|
|
776
|
+
output_layout.setContentsMargins(12, 12, 12, 12)
|
|
777
|
+
output_layout.setSpacing(12)
|
|
778
|
+
output_scroll.setWidget(output_scroll_content)
|
|
779
|
+
|
|
780
|
+
# File output section
|
|
781
|
+
file_group = QGroupBox("Output File")
|
|
782
|
+
file_form = QFormLayout(file_group)
|
|
783
|
+
file_form.setContentsMargins(12, 16, 12, 12)
|
|
784
|
+
file_form.setVerticalSpacing(8)
|
|
785
|
+
file_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
786
|
+
|
|
787
|
+
file_widget = QWidget()
|
|
788
|
+
file_layout = QHBoxLayout(file_widget)
|
|
789
|
+
file_layout.setContentsMargins(0, 0, 0, 0)
|
|
790
|
+
file_layout.setSpacing(8)
|
|
791
|
+
|
|
792
|
+
self.output_file_edit = QLineEdit()
|
|
793
|
+
file_layout.addWidget(self.output_file_edit)
|
|
794
|
+
|
|
795
|
+
browse_output_btn = QPushButton("Browse")
|
|
796
|
+
browse_output_btn.clicked.connect(self.browse_output_file)
|
|
797
|
+
file_layout.addWidget(browse_output_btn)
|
|
798
|
+
|
|
799
|
+
file_form.addRow("Path:", file_widget)
|
|
800
|
+
output_layout.addWidget(file_group)
|
|
801
|
+
|
|
802
|
+
# Video settings section
|
|
803
|
+
video_group = QGroupBox("Video Settings")
|
|
804
|
+
video_form = QFormLayout(video_group)
|
|
805
|
+
video_form.setContentsMargins(12, 16, 12, 12)
|
|
806
|
+
video_form.setVerticalSpacing(8)
|
|
807
|
+
video_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
808
|
+
|
|
809
|
+
self.fps_spinbox = QSpinBox()
|
|
810
|
+
self.fps_spinbox.setRange(1, 60)
|
|
811
|
+
self.fps_spinbox.setValue(10)
|
|
812
|
+
video_form.addRow("Frames Per Second:", self.fps_spinbox)
|
|
813
|
+
|
|
814
|
+
quality_widget = QWidget()
|
|
815
|
+
quality_layout = QHBoxLayout(quality_widget)
|
|
816
|
+
quality_layout.setContentsMargins(0, 0, 0, 0)
|
|
817
|
+
quality_layout.setSpacing(10)
|
|
818
|
+
|
|
819
|
+
self.quality_spinbox = QSpinBox()
|
|
820
|
+
self.quality_spinbox.setRange(1, 10)
|
|
821
|
+
self.quality_spinbox.setValue(10)
|
|
822
|
+
self.quality_spinbox.setToolTip("Higher values mean better quality but larger file size")
|
|
823
|
+
quality_layout.addWidget(self.quality_spinbox)
|
|
824
|
+
|
|
825
|
+
quality_label = QLabel("(1=low, 10=high)")
|
|
826
|
+
quality_label.setObjectName("SecondaryText")
|
|
827
|
+
quality_layout.addWidget(quality_label)
|
|
828
|
+
quality_layout.addStretch()
|
|
829
|
+
|
|
830
|
+
video_form.addRow("Quality:", quality_widget)
|
|
831
|
+
output_layout.addWidget(video_group)
|
|
832
|
+
|
|
833
|
+
# Performance section
|
|
834
|
+
perf_group = QGroupBox("Performance")
|
|
835
|
+
perf_form = QFormLayout(perf_group)
|
|
836
|
+
perf_form.setContentsMargins(12, 16, 12, 12)
|
|
837
|
+
perf_form.setVerticalSpacing(8)
|
|
838
|
+
perf_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
839
|
+
|
|
840
|
+
self.multiprocessing_check = QCheckBox("Use Multiprocessing")
|
|
841
|
+
self.multiprocessing_check.setChecked(True)
|
|
842
|
+
perf_form.addRow("", self.multiprocessing_check)
|
|
843
|
+
|
|
844
|
+
max_cores = multiprocessing.cpu_count()
|
|
845
|
+
self.cores_spinbox = QSpinBox()
|
|
846
|
+
self.cores_spinbox.setRange(1, max_cores)
|
|
847
|
+
self.cores_spinbox.setValue(max(1, max_cores - 1))
|
|
848
|
+
self.cores_spinbox.setEnabled(self.multiprocessing_check.isChecked())
|
|
849
|
+
perf_form.addRow("CPU Cores:", self.cores_spinbox)
|
|
850
|
+
|
|
851
|
+
self.multiprocessing_check.stateChanged.connect(
|
|
852
|
+
lambda state: self.cores_spinbox.setEnabled(state == Qt.Checked)
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
output_layout.addWidget(perf_group)
|
|
856
|
+
output_layout.addStretch()
|
|
857
|
+
|
|
858
|
+
# ------ Contours Tab ------
|
|
859
|
+
# Create a scroll area for the contours tab
|
|
860
|
+
contours_scroll = QScrollArea()
|
|
861
|
+
contours_scroll.setWidgetResizable(True)
|
|
862
|
+
contours_scroll_content = QWidget()
|
|
863
|
+
contours_layout = QVBoxLayout(contours_scroll_content)
|
|
864
|
+
contours_layout.setContentsMargins(12, 12, 12, 12)
|
|
865
|
+
contours_layout.setSpacing(12)
|
|
866
|
+
contours_scroll.setWidget(contours_scroll_content)
|
|
867
|
+
|
|
868
|
+
# Enable contours
|
|
869
|
+
contours_enable_group = QGroupBox("Contour Video")
|
|
870
|
+
contours_enable_layout = QVBoxLayout(contours_enable_group)
|
|
871
|
+
contours_enable_layout.setContentsMargins(12, 16, 12, 12)
|
|
872
|
+
contours_enable_layout.setSpacing(8)
|
|
873
|
+
|
|
874
|
+
# Mode selector
|
|
875
|
+
mode_widget = QWidget()
|
|
876
|
+
mode_layout = QHBoxLayout(mode_widget)
|
|
877
|
+
mode_layout.setContentsMargins(0, 0, 0, 0)
|
|
878
|
+
mode_layout.setSpacing(10)
|
|
879
|
+
|
|
880
|
+
mode_layout.addWidget(QLabel("Mode:"))
|
|
881
|
+
self.contour_mode_combo = QComboBox()
|
|
882
|
+
self.contour_mode_combo.addItems([
|
|
883
|
+
"A: Fixed base, evolving contours",
|
|
884
|
+
"B: Fixed contours, evolving colormap",
|
|
885
|
+
"C: Both evolve"
|
|
886
|
+
])
|
|
887
|
+
self.contour_mode_combo.currentIndexChanged.connect(self.update_contour_mode_ui)
|
|
888
|
+
self.contour_mode_combo.currentIndexChanged.connect(self.update_create_button_state)
|
|
889
|
+
mode_layout.addWidget(self.contour_mode_combo)
|
|
890
|
+
mode_layout.addStretch()
|
|
891
|
+
|
|
892
|
+
contours_enable_layout.addWidget(mode_widget)
|
|
893
|
+
contours_layout.addWidget(contours_enable_group)
|
|
894
|
+
|
|
895
|
+
# File inputs group (changes based on mode)
|
|
896
|
+
self.contour_files_group = QGroupBox("File Selection")
|
|
897
|
+
contour_files_layout = QFormLayout(self.contour_files_group)
|
|
898
|
+
contour_files_layout.setContentsMargins(12, 16, 12, 12)
|
|
899
|
+
contour_files_layout.setVerticalSpacing(8)
|
|
900
|
+
contour_files_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
901
|
+
|
|
902
|
+
# Base image file (for mode A)
|
|
903
|
+
base_file_widget = QWidget()
|
|
904
|
+
base_file_layout = QHBoxLayout(base_file_widget)
|
|
905
|
+
base_file_layout.setContentsMargins(0, 0, 0, 0)
|
|
906
|
+
base_file_layout.setSpacing(8)
|
|
907
|
+
self.contour_base_file_edit = QLineEdit()
|
|
908
|
+
self.contour_base_file_edit.setPlaceholderText("Select base image file...")
|
|
909
|
+
self.contour_base_file_edit.textChanged.connect(self.update_create_button_state)
|
|
910
|
+
base_file_layout.addWidget(self.contour_base_file_edit)
|
|
911
|
+
self.contour_base_file_btn = QPushButton("Browse")
|
|
912
|
+
self.contour_base_file_btn.clicked.connect(self.browse_contour_base_file)
|
|
913
|
+
base_file_layout.addWidget(self.contour_base_file_btn)
|
|
914
|
+
contour_files_layout.addRow("Base Image:", base_file_widget)
|
|
915
|
+
|
|
916
|
+
# Contour directory (for modes A and C)
|
|
917
|
+
contour_dir_widget = QWidget()
|
|
918
|
+
contour_dir_layout = QHBoxLayout(contour_dir_widget)
|
|
919
|
+
contour_dir_layout.setContentsMargins(0, 0, 0, 0)
|
|
920
|
+
contour_dir_layout.setSpacing(8)
|
|
921
|
+
self.contour_dir_edit = QLineEdit()
|
|
922
|
+
self.contour_dir_edit.setPlaceholderText("Select contour files directory...")
|
|
923
|
+
self.contour_dir_edit.textChanged.connect(self.scan_contour_files)
|
|
924
|
+
self.contour_dir_edit.textChanged.connect(self.update_create_button_state)
|
|
925
|
+
contour_dir_layout.addWidget(self.contour_dir_edit)
|
|
926
|
+
self.contour_dir_btn = QPushButton("Browse")
|
|
927
|
+
self.contour_dir_btn.clicked.connect(self.browse_contour_directory)
|
|
928
|
+
contour_dir_layout.addWidget(self.contour_dir_btn)
|
|
929
|
+
contour_files_layout.addRow("Contour Directory:", contour_dir_widget)
|
|
930
|
+
|
|
931
|
+
# Contour file pattern
|
|
932
|
+
self.contour_pattern_widget = QWidget()
|
|
933
|
+
contour_pattern_layout = QHBoxLayout(self.contour_pattern_widget)
|
|
934
|
+
contour_pattern_layout.setContentsMargins(0, 0, 0, 0)
|
|
935
|
+
contour_pattern_layout.setSpacing(8)
|
|
936
|
+
self.contour_dir_pattern_edit = QLineEdit("*.fits")
|
|
937
|
+
self.contour_dir_pattern_edit.textChanged.connect(self.scan_contour_files)
|
|
938
|
+
contour_pattern_layout.addWidget(self.contour_dir_pattern_edit)
|
|
939
|
+
self.contour_files_count_label = QLabel("")
|
|
940
|
+
self.contour_files_count_label.setObjectName("StatusLabel")
|
|
941
|
+
contour_pattern_layout.addWidget(self.contour_files_count_label)
|
|
942
|
+
contour_files_layout.addRow("Contour Pattern:", self.contour_pattern_widget)
|
|
943
|
+
|
|
944
|
+
# Fixed contour file (for mode B)
|
|
945
|
+
fixed_contour_widget = QWidget()
|
|
946
|
+
fixed_contour_layout = QHBoxLayout(fixed_contour_widget)
|
|
947
|
+
fixed_contour_layout.setContentsMargins(0, 0, 0, 0)
|
|
948
|
+
fixed_contour_layout.setSpacing(8)
|
|
949
|
+
self.contour_fixed_file_edit = QLineEdit()
|
|
950
|
+
self.contour_fixed_file_edit.setPlaceholderText("Select fixed contour file...")
|
|
951
|
+
self.contour_fixed_file_edit.textChanged.connect(self.update_create_button_state)
|
|
952
|
+
fixed_contour_layout.addWidget(self.contour_fixed_file_edit)
|
|
953
|
+
self.contour_fixed_file_btn = QPushButton("Browse")
|
|
954
|
+
self.contour_fixed_file_btn.clicked.connect(self.browse_contour_fixed_file)
|
|
955
|
+
fixed_contour_layout.addWidget(self.contour_fixed_file_btn)
|
|
956
|
+
contour_files_layout.addRow("Fixed Contour:", fixed_contour_widget)
|
|
957
|
+
|
|
958
|
+
# Colormap directory (for modes B and C)
|
|
959
|
+
colormap_dir_widget = QWidget()
|
|
960
|
+
colormap_dir_layout = QHBoxLayout(colormap_dir_widget)
|
|
961
|
+
colormap_dir_layout.setContentsMargins(0, 0, 0, 0)
|
|
962
|
+
colormap_dir_layout.setSpacing(8)
|
|
963
|
+
self.contour_colormap_dir_edit = QLineEdit()
|
|
964
|
+
self.contour_colormap_dir_edit.setPlaceholderText("Select colormap files directory...")
|
|
965
|
+
self.contour_colormap_dir_edit.textChanged.connect(self.scan_colormap_files)
|
|
966
|
+
self.contour_colormap_dir_edit.textChanged.connect(self.update_create_button_state)
|
|
967
|
+
colormap_dir_layout.addWidget(self.contour_colormap_dir_edit)
|
|
968
|
+
self.contour_colormap_dir_btn = QPushButton("Browse")
|
|
969
|
+
self.contour_colormap_dir_btn.clicked.connect(self.browse_contour_colormap_directory)
|
|
970
|
+
colormap_dir_layout.addWidget(self.contour_colormap_dir_btn)
|
|
971
|
+
contour_files_layout.addRow("Colormap Directory:", colormap_dir_widget)
|
|
972
|
+
|
|
973
|
+
# Colormap file pattern
|
|
974
|
+
self.colormap_pattern_widget = QWidget()
|
|
975
|
+
colormap_pattern_layout = QHBoxLayout(self.colormap_pattern_widget)
|
|
976
|
+
colormap_pattern_layout.setContentsMargins(0, 0, 0, 0)
|
|
977
|
+
colormap_pattern_layout.setSpacing(8)
|
|
978
|
+
self.contour_colormap_pattern_edit = QLineEdit("*.fits")
|
|
979
|
+
self.contour_colormap_pattern_edit.textChanged.connect(self.scan_colormap_files)
|
|
980
|
+
colormap_pattern_layout.addWidget(self.contour_colormap_pattern_edit)
|
|
981
|
+
self.colormap_files_count_label = QLabel("")
|
|
982
|
+
self.colormap_files_count_label.setObjectName("StatusLabel")
|
|
983
|
+
colormap_pattern_layout.addWidget(self.colormap_files_count_label)
|
|
984
|
+
contour_files_layout.addRow("Colormap Pattern:", self.colormap_pattern_widget)
|
|
985
|
+
|
|
986
|
+
contours_layout.addWidget(self.contour_files_group)
|
|
987
|
+
|
|
988
|
+
# Contour settings group
|
|
989
|
+
contour_settings_group = QGroupBox("Contour Settings")
|
|
990
|
+
contour_settings_layout = QFormLayout(contour_settings_group)
|
|
991
|
+
contour_settings_layout.setContentsMargins(12, 16, 12, 12)
|
|
992
|
+
contour_settings_layout.setVerticalSpacing(8)
|
|
993
|
+
contour_settings_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
994
|
+
|
|
995
|
+
# Level type
|
|
996
|
+
self.contour_level_type_combo = QComboBox()
|
|
997
|
+
self.contour_level_type_combo.addItems(["Fraction of Max", "Sigma (RMS)", "Absolute"])
|
|
998
|
+
contour_settings_layout.addRow("Level Type:", self.contour_level_type_combo)
|
|
999
|
+
|
|
1000
|
+
# Positive levels
|
|
1001
|
+
self.contour_pos_levels_edit = QLineEdit("0.1, 0.3, 0.5, 0.7, 0.9")
|
|
1002
|
+
self.contour_pos_levels_edit.setToolTip("Comma-separated list of positive contour levels")
|
|
1003
|
+
contour_settings_layout.addRow("Positive Levels:", self.contour_pos_levels_edit)
|
|
1004
|
+
|
|
1005
|
+
# Negative levels
|
|
1006
|
+
self.contour_neg_levels_edit = QLineEdit("0.1, 0.3, 0.5, 0.7, 0.9")
|
|
1007
|
+
self.contour_neg_levels_edit.setToolTip("Comma-separated list of negative contour levels")
|
|
1008
|
+
contour_settings_layout.addRow("Negative Levels:", self.contour_neg_levels_edit)
|
|
1009
|
+
|
|
1010
|
+
# Colors
|
|
1011
|
+
color_widget = QWidget()
|
|
1012
|
+
color_layout = QHBoxLayout(color_widget)
|
|
1013
|
+
color_layout.setContentsMargins(0, 0, 0, 0)
|
|
1014
|
+
color_layout.setSpacing(10)
|
|
1015
|
+
|
|
1016
|
+
color_layout.addWidget(QLabel("Pos:"))
|
|
1017
|
+
self.contour_pos_color_combo = QComboBox()
|
|
1018
|
+
self.contour_pos_color_combo.addItems(["white", "red", "yellow", "green", "cyan", "blue", "magenta"])
|
|
1019
|
+
color_layout.addWidget(self.contour_pos_color_combo)
|
|
1020
|
+
|
|
1021
|
+
color_layout.addWidget(QLabel("Neg:"))
|
|
1022
|
+
self.contour_neg_color_combo = QComboBox()
|
|
1023
|
+
self.contour_neg_color_combo.addItems(["cyan", "blue", "red", "yellow", "green", "white", "magenta"])
|
|
1024
|
+
color_layout.addWidget(self.contour_neg_color_combo)
|
|
1025
|
+
color_layout.addStretch()
|
|
1026
|
+
|
|
1027
|
+
contour_settings_layout.addRow("Colors:", color_widget)
|
|
1028
|
+
|
|
1029
|
+
# Line width
|
|
1030
|
+
self.contour_linewidth_spin = QDoubleSpinBox()
|
|
1031
|
+
self.contour_linewidth_spin.setRange(0.1, 5.0)
|
|
1032
|
+
self.contour_linewidth_spin.setValue(1.0)
|
|
1033
|
+
self.contour_linewidth_spin.setSingleStep(0.1)
|
|
1034
|
+
contour_settings_layout.addRow("Line Width:", self.contour_linewidth_spin)
|
|
1035
|
+
|
|
1036
|
+
contours_layout.addWidget(contour_settings_group)
|
|
1037
|
+
contours_layout.addStretch()
|
|
1038
|
+
|
|
1039
|
+
# Initially disable contour controls
|
|
1040
|
+
self.toggle_contour_video_controls(False)
|
|
1041
|
+
self.update_contour_mode_ui(0)
|
|
1042
|
+
|
|
1043
|
+
# Add tabs to tab widget (Contours is second when in Contour Mode)
|
|
1044
|
+
self.tab_widget.addTab(input_scroll, "Input")
|
|
1045
|
+
self.tab_widget.addTab(contours_scroll, "Input")
|
|
1046
|
+
self.tab_widget.addTab(display_scroll, "Display")
|
|
1047
|
+
self.tab_widget.addTab(region_scroll, "Region")
|
|
1048
|
+
self.tab_widget.addTab(overlay_scroll, "Overlays")
|
|
1049
|
+
self.tab_widget.addTab(output_scroll, "Output")
|
|
1050
|
+
|
|
1051
|
+
# Add the tab widget to the main layout (after the preview)
|
|
1052
|
+
main_layout.addWidget(self.tab_widget)
|
|
1053
|
+
|
|
1054
|
+
# Hide Contours tab by default (shown when Contour Mode checkbox is checked)
|
|
1055
|
+
self.tab_widget.setTabVisible(1, False)
|
|
1056
|
+
|
|
1057
|
+
# Buttons at the bottom
|
|
1058
|
+
button_layout = QHBoxLayout()
|
|
1059
|
+
button_layout.setSpacing(12)
|
|
1060
|
+
button_layout.addStretch()
|
|
1061
|
+
|
|
1062
|
+
self.cancel_btn = QPushButton("Cancel")
|
|
1063
|
+
self.cancel_btn.setMinimumWidth(100)
|
|
1064
|
+
self.cancel_btn.clicked.connect(self.reject)
|
|
1065
|
+
button_layout.addWidget(self.cancel_btn)
|
|
1066
|
+
|
|
1067
|
+
self.create_btn = QPushButton("Create Video")
|
|
1068
|
+
self.create_btn.setMinimumWidth(120)
|
|
1069
|
+
self.create_btn.setObjectName("PrimaryButton")
|
|
1070
|
+
self.create_btn.clicked.connect(self.create_video)
|
|
1071
|
+
self.create_btn.setEnabled(False)
|
|
1072
|
+
button_layout.addWidget(self.create_btn)
|
|
1073
|
+
|
|
1074
|
+
main_layout.addLayout(button_layout)
|
|
1075
|
+
|
|
1076
|
+
def toggle_range_mode(self, index):
|
|
1077
|
+
"""Enable/disable controls based on the range mode selection"""
|
|
1078
|
+
# Update the explanation label
|
|
1079
|
+
if index == 0: # Fixed Range
|
|
1080
|
+
'''self.range_explanation_label.setText(
|
|
1081
|
+
"Fixed Range: Same min/max values used for all frames"
|
|
1082
|
+
)'''
|
|
1083
|
+
# Enable min/max spinboxes
|
|
1084
|
+
self.vmin_spinbox.setEnabled(True)
|
|
1085
|
+
self.vmax_spinbox.setEnabled(True)
|
|
1086
|
+
|
|
1087
|
+
elif index == 1: # Auto Per Frame
|
|
1088
|
+
'''self.range_explanation_label.setText(
|
|
1089
|
+
"Auto Per Frame: Min/max calculated independently for each frame"
|
|
1090
|
+
)'''
|
|
1091
|
+
# Disable min/max spinboxes (they'll be updated for reference only)
|
|
1092
|
+
self.vmin_spinbox.setEnabled(False)
|
|
1093
|
+
self.vmax_spinbox.setEnabled(False)
|
|
1094
|
+
|
|
1095
|
+
else: # Global Auto
|
|
1096
|
+
'''self.range_explanation_label.setText(
|
|
1097
|
+
"Global Auto: Min/max calculated once from all frames"
|
|
1098
|
+
)'''
|
|
1099
|
+
# Disable min/max spinboxes
|
|
1100
|
+
self.vmin_spinbox.setEnabled(False)
|
|
1101
|
+
self.vmax_spinbox.setEnabled(False)
|
|
1102
|
+
|
|
1103
|
+
# Update preview with new settings
|
|
1104
|
+
self.update_preview()
|
|
1105
|
+
|
|
1106
|
+
def get_smart_start_directory(self, *extra_paths):
|
|
1107
|
+
"""
|
|
1108
|
+
Get a smart starting directory for file/directory dialogs.
|
|
1109
|
+
|
|
1110
|
+
Checks multiple sources in priority order:
|
|
1111
|
+
1. Any extra paths passed as arguments (e.g., current field value)
|
|
1112
|
+
2. Contour base file directory (if set)
|
|
1113
|
+
3. Reference image directory
|
|
1114
|
+
4. Input directory from Input tab
|
|
1115
|
+
5. Current file from main viewer
|
|
1116
|
+
6. Current working directory (CWD)
|
|
1117
|
+
7. Fall back to home directory
|
|
1118
|
+
|
|
1119
|
+
Returns the first valid directory found.
|
|
1120
|
+
"""
|
|
1121
|
+
candidates = list(extra_paths) + [
|
|
1122
|
+
self.contour_base_file_edit.text().strip() if hasattr(self, 'contour_base_file_edit') else None,
|
|
1123
|
+
self.reference_image,
|
|
1124
|
+
self.input_directory_edit.text().strip() if hasattr(self, 'input_directory_edit') else None,
|
|
1125
|
+
self.current_file,
|
|
1126
|
+
os.getcwd(), # CWD as fallback before home
|
|
1127
|
+
]
|
|
1128
|
+
|
|
1129
|
+
for path in candidates:
|
|
1130
|
+
if path:
|
|
1131
|
+
# If it's a file, get its directory
|
|
1132
|
+
if os.path.isfile(path):
|
|
1133
|
+
dir_path = os.path.dirname(path)
|
|
1134
|
+
else:
|
|
1135
|
+
dir_path = path
|
|
1136
|
+
|
|
1137
|
+
if dir_path and os.path.isdir(dir_path):
|
|
1138
|
+
return dir_path
|
|
1139
|
+
|
|
1140
|
+
return os.path.expanduser("~")
|
|
1141
|
+
|
|
1142
|
+
def browse_input_directory(self):
|
|
1143
|
+
"""Browse for input directory"""
|
|
1144
|
+
start_dir = self.get_smart_start_directory(self.input_directory_edit.text())
|
|
1145
|
+
|
|
1146
|
+
directory = QFileDialog.getExistingDirectory(
|
|
1147
|
+
self, "Select Input Directory", start_dir
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
if directory:
|
|
1151
|
+
self.input_directory_edit.setText(directory)
|
|
1152
|
+
|
|
1153
|
+
# Set default output file if not already set
|
|
1154
|
+
if not self.output_file_edit.text():
|
|
1155
|
+
self.output_file_edit.setText(
|
|
1156
|
+
os.path.join(directory, "output_video.mp4")
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
# Preview files if pattern is already set
|
|
1160
|
+
if self.input_pattern_edit.text():
|
|
1161
|
+
self.preview_input_files()
|
|
1162
|
+
|
|
1163
|
+
def preview_input_files(self):
|
|
1164
|
+
"""Preview the files matching the input pattern"""
|
|
1165
|
+
with wait_cursor():
|
|
1166
|
+
directory = self.input_directory_edit.text()
|
|
1167
|
+
pattern = self.input_pattern_edit.text()
|
|
1168
|
+
|
|
1169
|
+
if not directory or not pattern:
|
|
1170
|
+
QMessageBox.warning(
|
|
1171
|
+
self, "Incomplete Input", "Please specify both directory and pattern."
|
|
1172
|
+
)
|
|
1173
|
+
return
|
|
1174
|
+
|
|
1175
|
+
full_pattern = os.path.join(directory, pattern)
|
|
1176
|
+
|
|
1177
|
+
# Find matching files
|
|
1178
|
+
files = glob.glob(full_pattern)
|
|
1179
|
+
|
|
1180
|
+
if not files:
|
|
1181
|
+
self.files_found_label.setText("No files found matching the pattern")
|
|
1182
|
+
self.create_btn.setEnabled(False)
|
|
1183
|
+
self.create_btn.setToolTip("No files found matching the pattern")
|
|
1184
|
+
QMessageBox.warning(
|
|
1185
|
+
self, "No Files Found", f"No files match the pattern: {full_pattern}"
|
|
1186
|
+
)
|
|
1187
|
+
return
|
|
1188
|
+
|
|
1189
|
+
# Validate extensions
|
|
1190
|
+
invalid_extensions = []
|
|
1191
|
+
for f in files:
|
|
1192
|
+
ext = os.path.splitext(f)[1].lower()
|
|
1193
|
+
if ext not in [".fits", ".fts"]:
|
|
1194
|
+
invalid_extensions.append(os.path.basename(f))
|
|
1195
|
+
|
|
1196
|
+
if invalid_extensions:
|
|
1197
|
+
self.files_found_label.setText(f"Found {len(files)} files, but {len(invalid_extensions)} have invalid extensions")
|
|
1198
|
+
self.create_btn.setEnabled(False)
|
|
1199
|
+
self.create_btn.setToolTip("Invalid file extensions found")
|
|
1200
|
+
|
|
1201
|
+
# Show first few invalid files
|
|
1202
|
+
msg = f"Found {len(invalid_extensions)} files with invalid extensions.\nOnly .fits and .fts files are supported."
|
|
1203
|
+
if len(invalid_extensions) > 5:
|
|
1204
|
+
msg += f"\n\nExamples:\n" + "\n".join(invalid_extensions[:5]) + "\n..."
|
|
1205
|
+
else:
|
|
1206
|
+
msg += f"\n\nFiles:\n" + "\n".join(invalid_extensions)
|
|
1207
|
+
|
|
1208
|
+
QMessageBox.warning(self, "Invalid File Extensions", msg)
|
|
1209
|
+
return
|
|
1210
|
+
|
|
1211
|
+
# Update label with file count
|
|
1212
|
+
self.files_found_label.setText(f"Found {len(files)} files matching the pattern")
|
|
1213
|
+
self.create_btn.setEnabled(True)
|
|
1214
|
+
self.create_btn.setToolTip("")
|
|
1215
|
+
|
|
1216
|
+
# Always update the reference list and force a preview refresh
|
|
1217
|
+
# Use first file as reference if no reference is set, or if we want to refresh
|
|
1218
|
+
if not self.reference_image or self.reference_image not in files:
|
|
1219
|
+
self.reference_image = files[0]
|
|
1220
|
+
self.reference_image_edit.setText(self.reference_image)
|
|
1221
|
+
|
|
1222
|
+
# Explicitly update preview to satisfy user request "Refresh the figure, each time user presses 'preview files'"
|
|
1223
|
+
self.update_preview(self.reference_image)
|
|
1224
|
+
|
|
1225
|
+
def browse_reference_image(self):
|
|
1226
|
+
"""Browse for a reference image to use for preview and settings"""
|
|
1227
|
+
if self.reference_image:
|
|
1228
|
+
start_dir = os.path.dirname(self.reference_image)
|
|
1229
|
+
elif self.input_directory_edit.text():
|
|
1230
|
+
start_dir = self.input_directory_edit.text()
|
|
1231
|
+
elif self.current_file:
|
|
1232
|
+
start_dir = os.path.dirname(self.current_file)
|
|
1233
|
+
else:
|
|
1234
|
+
start_dir = os.path.expanduser("~")
|
|
1235
|
+
|
|
1236
|
+
filepath, _ = QFileDialog.getOpenFileName(
|
|
1237
|
+
self,
|
|
1238
|
+
"Select Reference Image",
|
|
1239
|
+
start_dir,
|
|
1240
|
+
"FITS Files (*.fits *.fit);;All Files (*.*)",
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
if filepath:
|
|
1244
|
+
self.reference_image = filepath
|
|
1245
|
+
self.reference_image_edit.setText(filepath)
|
|
1246
|
+
self.update_preview(filepath)
|
|
1247
|
+
|
|
1248
|
+
def browse_output_file(self):
|
|
1249
|
+
"""Browse for output file"""
|
|
1250
|
+
if self.output_file_edit.text():
|
|
1251
|
+
start_dir = os.path.dirname(self.output_file_edit.text())
|
|
1252
|
+
elif self.input_directory_edit.text():
|
|
1253
|
+
start_dir = self.input_directory_edit.text()
|
|
1254
|
+
elif self.current_file:
|
|
1255
|
+
start_dir = os.path.dirname(self.current_file)
|
|
1256
|
+
else:
|
|
1257
|
+
start_dir = os.path.expanduser("~")
|
|
1258
|
+
|
|
1259
|
+
filepath, _ = QFileDialog.getSaveFileName(
|
|
1260
|
+
self,
|
|
1261
|
+
"Save Video As",
|
|
1262
|
+
start_dir,
|
|
1263
|
+
"MP4 Files (*.mp4);;AVI Files (*.avi);;GIF Files (*.gif);;All Files (*.*)",
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
if filepath:
|
|
1267
|
+
self.output_file_edit.setText(filepath)
|
|
1268
|
+
|
|
1269
|
+
def update_preview_from_reference(self):
|
|
1270
|
+
"""Load and update the preview from the reference image"""
|
|
1271
|
+
if self.reference_image:
|
|
1272
|
+
self.update_preview(self.reference_image)
|
|
1273
|
+
else:
|
|
1274
|
+
QMessageBox.warning(
|
|
1275
|
+
self, "No Reference Image", "Please select a reference image first."
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
def update_preview_settings(self):
|
|
1279
|
+
"""Update the preview with new settings"""
|
|
1280
|
+
self.update_preview()
|
|
1281
|
+
|
|
1282
|
+
@wait_cursor()
|
|
1283
|
+
def update_preview(self, preview_file=None):
|
|
1284
|
+
"""Update the preview image"""
|
|
1285
|
+
try:
|
|
1286
|
+
# Clear the figure
|
|
1287
|
+
self.figure.clear()
|
|
1288
|
+
ax = self.figure.add_subplot(111)
|
|
1289
|
+
|
|
1290
|
+
if not preview_file and self.reference_image:
|
|
1291
|
+
preview_file = self.reference_image
|
|
1292
|
+
|
|
1293
|
+
if preview_file:
|
|
1294
|
+
# Load the data
|
|
1295
|
+
data, header = load_fits_data(
|
|
1296
|
+
preview_file, stokes=self.stokes_combo.currentText()
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
if data is not None:
|
|
1300
|
+
original_data = data.copy() # Save original data for region overlay
|
|
1301
|
+
|
|
1302
|
+
# Apply region selection if enabled
|
|
1303
|
+
if self.region_enabled.isChecked():
|
|
1304
|
+
x_min = self.x_min_spinbox.value()
|
|
1305
|
+
x_max = self.x_max_spinbox.value()
|
|
1306
|
+
y_min = self.y_min_spinbox.value()
|
|
1307
|
+
y_max = self.y_max_spinbox.value()
|
|
1308
|
+
|
|
1309
|
+
# Ensure proper order of min/max
|
|
1310
|
+
x_min, x_max = min(x_min, x_max), max(x_min, x_max)
|
|
1311
|
+
y_min, y_max = min(y_min, y_max), max(y_min, y_max)
|
|
1312
|
+
|
|
1313
|
+
# Check boundaries
|
|
1314
|
+
x_min = max(0, min(x_min, data.shape[1] - 1))
|
|
1315
|
+
x_max = max(0, min(x_max, data.shape[1] - 1))
|
|
1316
|
+
y_min = max(0, min(y_min, data.shape[0] - 1))
|
|
1317
|
+
y_max = max(0, min(y_max, data.shape[0] - 1))
|
|
1318
|
+
|
|
1319
|
+
# Extract the region
|
|
1320
|
+
data = data[y_min : y_max + 1, x_min : x_max + 1]
|
|
1321
|
+
|
|
1322
|
+
# Determine vmin/vmax based on range mode
|
|
1323
|
+
range_mode = self.range_mode_combo.currentIndex()
|
|
1324
|
+
|
|
1325
|
+
if range_mode == 0: # Fixed Range
|
|
1326
|
+
vmin = self.vmin_spinbox.value()
|
|
1327
|
+
vmax = self.vmax_spinbox.value()
|
|
1328
|
+
else: # Auto
|
|
1329
|
+
vmin = np.nanpercentile(data, 0)
|
|
1330
|
+
vmax = np.nanpercentile(data, 100)
|
|
1331
|
+
|
|
1332
|
+
# Update spinboxes for reference (without triggering events)
|
|
1333
|
+
self.vmin_spinbox.blockSignals(True)
|
|
1334
|
+
self.vmax_spinbox.blockSignals(True)
|
|
1335
|
+
self.vmin_spinbox.setValue(vmin)
|
|
1336
|
+
self.vmax_spinbox.setValue(vmax)
|
|
1337
|
+
self.vmin_spinbox.blockSignals(False)
|
|
1338
|
+
self.vmax_spinbox.blockSignals(False)
|
|
1339
|
+
|
|
1340
|
+
# Ensure min/max are proper
|
|
1341
|
+
if vmin >= vmax:
|
|
1342
|
+
vmax = vmin + 1.0
|
|
1343
|
+
|
|
1344
|
+
# Apply visualization settings
|
|
1345
|
+
stretch = self.stretch_combo.currentText().lower()
|
|
1346
|
+
gamma = self.gamma_spinbox.value()
|
|
1347
|
+
cmap = self.colormap_combo.currentText()
|
|
1348
|
+
|
|
1349
|
+
# Create the appropriate normalization
|
|
1350
|
+
norm = get_norm(stretch, vmin, vmax, gamma)
|
|
1351
|
+
|
|
1352
|
+
# Decide whether to show the full image or the region
|
|
1353
|
+
display_data = data
|
|
1354
|
+
|
|
1355
|
+
# Show title with filename and region info if applicable
|
|
1356
|
+
title = os.path.basename(preview_file)
|
|
1357
|
+
if self.region_enabled.isChecked():
|
|
1358
|
+
region_dims = f"{data.shape[1]}×{data.shape[0]}"
|
|
1359
|
+
title += f" - Region: {region_dims} pixels"
|
|
1360
|
+
|
|
1361
|
+
title += f"\nRange: [{vmin:.1f}, {vmax:.1f}]"
|
|
1362
|
+
ax.set_title(title, fontsize=10)
|
|
1363
|
+
|
|
1364
|
+
# Display the image
|
|
1365
|
+
im = ax.imshow(
|
|
1366
|
+
display_data,
|
|
1367
|
+
cmap=cmap,
|
|
1368
|
+
norm=norm,
|
|
1369
|
+
origin="lower",
|
|
1370
|
+
interpolation="none",
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
# If region is enabled and showing the preview,
|
|
1374
|
+
# draw a red rectangle to indicate the region
|
|
1375
|
+
if self.region_enabled.isChecked():
|
|
1376
|
+
# Show the full image with a rectangle for the region
|
|
1377
|
+
# Store current axes for restoring after showing full image
|
|
1378
|
+
ax.set_xticks([])
|
|
1379
|
+
ax.set_yticks([])
|
|
1380
|
+
|
|
1381
|
+
# Add a second axes to show full image with region overlay
|
|
1382
|
+
#overlay_ax = self.figure.add_axes([0.65, 0.65, 0.3, 0.3])
|
|
1383
|
+
overlay_ax = self.figure.add_axes([0.05, 0.05, 0.3, 0.3])
|
|
1384
|
+
overlay_ax.imshow(
|
|
1385
|
+
original_data,
|
|
1386
|
+
cmap=cmap,
|
|
1387
|
+
norm=norm,
|
|
1388
|
+
origin="lower",
|
|
1389
|
+
interpolation="none",
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
# Draw region rectangle on the overlay
|
|
1393
|
+
x_min = self.x_min_spinbox.value()
|
|
1394
|
+
x_max = self.x_max_spinbox.value()
|
|
1395
|
+
y_min = self.y_min_spinbox.value()
|
|
1396
|
+
y_max = self.y_max_spinbox.value()
|
|
1397
|
+
|
|
1398
|
+
# Ensure proper order
|
|
1399
|
+
x_min, x_max = min(x_min, x_max), max(x_min, x_max)
|
|
1400
|
+
y_min, y_max = min(y_min, y_max), max(y_min, y_max)
|
|
1401
|
+
|
|
1402
|
+
from matplotlib.patches import Rectangle
|
|
1403
|
+
|
|
1404
|
+
overlay_ax.add_patch(
|
|
1405
|
+
Rectangle(
|
|
1406
|
+
(x_min, y_min),
|
|
1407
|
+
x_max - x_min,
|
|
1408
|
+
y_max - y_min,
|
|
1409
|
+
fill=False,
|
|
1410
|
+
edgecolor="red",
|
|
1411
|
+
linewidth=2,
|
|
1412
|
+
)
|
|
1413
|
+
)
|
|
1414
|
+
|
|
1415
|
+
# Turn off overlay axis labels and ticks
|
|
1416
|
+
overlay_ax.set_xticks([])
|
|
1417
|
+
overlay_ax.set_yticks([])
|
|
1418
|
+
overlay_ax.set_title("Region Location", fontsize=8)
|
|
1419
|
+
|
|
1420
|
+
# Add colorbar if checked
|
|
1421
|
+
if self.colorbar_check.isChecked():
|
|
1422
|
+
cbar = self.figure.colorbar(im, ax=ax)
|
|
1423
|
+
|
|
1424
|
+
self.preview_image = preview_file
|
|
1425
|
+
|
|
1426
|
+
# Turn off axis labels
|
|
1427
|
+
ax.set_xticks([])
|
|
1428
|
+
ax.set_yticks([])
|
|
1429
|
+
else:
|
|
1430
|
+
ax.text(
|
|
1431
|
+
0.5,
|
|
1432
|
+
0.5,
|
|
1433
|
+
"Could not load preview image",
|
|
1434
|
+
ha="center",
|
|
1435
|
+
va="center",
|
|
1436
|
+
transform=ax.transAxes,
|
|
1437
|
+
)
|
|
1438
|
+
else:
|
|
1439
|
+
ax.text(
|
|
1440
|
+
0.5,
|
|
1441
|
+
0.5,
|
|
1442
|
+
"No preview image available\nSelect a reference image first",
|
|
1443
|
+
ha="center",
|
|
1444
|
+
va="center",
|
|
1445
|
+
transform=ax.transAxes,
|
|
1446
|
+
)
|
|
1447
|
+
|
|
1448
|
+
# Refresh the canvas
|
|
1449
|
+
self.canvas.draw()
|
|
1450
|
+
|
|
1451
|
+
except RuntimeError as re:
|
|
1452
|
+
# Handle specific Qt/Matplotlib runtime errors (deleted objects)
|
|
1453
|
+
print(f"RuntimeError updating preview: {re}")
|
|
1454
|
+
try:
|
|
1455
|
+
# If the figure/canvas is corrupted, try to recreate the figure content cleanly
|
|
1456
|
+
# Don't try self.figure.clear() if it caused the error
|
|
1457
|
+
|
|
1458
|
+
# We interpret "wrapped C/C++ object of type QAction has been deleted"
|
|
1459
|
+
# as a sign that the toolbar or canvas state is invalid.
|
|
1460
|
+
# Simplest recovery is to just show an error text on a fresh axes if possible,
|
|
1461
|
+
# or just log it and return to avoid crashing.
|
|
1462
|
+
|
|
1463
|
+
# Check if we can access the figure at all
|
|
1464
|
+
if hasattr(self, 'figure'):
|
|
1465
|
+
# Try to reset the figure completely
|
|
1466
|
+
self.figure = Figure(figsize=(5, 4), dpi=100)
|
|
1467
|
+
self.canvas.figure = self.figure
|
|
1468
|
+
self.canvas.draw()
|
|
1469
|
+
|
|
1470
|
+
# Try to show error on new figure
|
|
1471
|
+
ax = self.figure.add_subplot(111)
|
|
1472
|
+
ax.text(0.5, 0.5, "Preview Error (Recovered)", ha="center", va="center")
|
|
1473
|
+
self.canvas.draw()
|
|
1474
|
+
except Exception as e2:
|
|
1475
|
+
print(f"Could not recover from preview error: {e2}")
|
|
1476
|
+
|
|
1477
|
+
except Exception as e:
|
|
1478
|
+
print(f"Error updating preview: {e}")
|
|
1479
|
+
try:
|
|
1480
|
+
# Clear the figure
|
|
1481
|
+
self.figure.clear()
|
|
1482
|
+
ax = self.figure.add_subplot(111)
|
|
1483
|
+
ax.text(
|
|
1484
|
+
0.5,
|
|
1485
|
+
0.5,
|
|
1486
|
+
f"Error loading preview: {str(e)}",
|
|
1487
|
+
ha="center",
|
|
1488
|
+
va="center",
|
|
1489
|
+
transform=ax.transAxes,
|
|
1490
|
+
)
|
|
1491
|
+
self.canvas.draw()
|
|
1492
|
+
except Exception as e2:
|
|
1493
|
+
print(f"Error in exception handler: {e2}")
|
|
1494
|
+
|
|
1495
|
+
def create_video(self):
|
|
1496
|
+
"""Create a video from the selected files"""
|
|
1497
|
+
try:
|
|
1498
|
+
# Initialize matching_files - will be set differently for contour mode
|
|
1499
|
+
matching_files = []
|
|
1500
|
+
|
|
1501
|
+
# Check if we're in contour mode - skip Input tab validation
|
|
1502
|
+
if not self.contour_video_enabled.isChecked():
|
|
1503
|
+
# Get input files from Input tab
|
|
1504
|
+
input_dir = self.input_directory_edit.text()
|
|
1505
|
+
if not input_dir or not os.path.isdir(input_dir):
|
|
1506
|
+
QMessageBox.warning(
|
|
1507
|
+
self,
|
|
1508
|
+
"Invalid Directory",
|
|
1509
|
+
"The specified input directory does not exist.",
|
|
1510
|
+
)
|
|
1511
|
+
return
|
|
1512
|
+
|
|
1513
|
+
input_pattern = self.input_pattern_edit.text()
|
|
1514
|
+
input_path = os.path.join(input_dir, input_pattern)
|
|
1515
|
+
|
|
1516
|
+
# Verify files exist
|
|
1517
|
+
matching_files = glob.glob(input_path)
|
|
1518
|
+
if not matching_files:
|
|
1519
|
+
QMessageBox.warning(
|
|
1520
|
+
self,
|
|
1521
|
+
"No Files Found",
|
|
1522
|
+
f"No files match the pattern: {input_path}",
|
|
1523
|
+
)
|
|
1524
|
+
return
|
|
1525
|
+
|
|
1526
|
+
# Validate extensions
|
|
1527
|
+
invalid_extensions = []
|
|
1528
|
+
for f in matching_files:
|
|
1529
|
+
ext = os.path.splitext(f)[1].lower()
|
|
1530
|
+
if ext not in [".fits", ".fts"]:
|
|
1531
|
+
invalid_extensions.append(os.path.basename(f))
|
|
1532
|
+
|
|
1533
|
+
if invalid_extensions:
|
|
1534
|
+
# Show first few invalid files
|
|
1535
|
+
msg = f"Found {len(invalid_extensions)} files with invalid extensions.\nOnly .fits and .fts files are supported."
|
|
1536
|
+
if len(invalid_extensions) > 5:
|
|
1537
|
+
msg += f"\n\nExamples:\n" + "\n".join(invalid_extensions[:5]) + "\n..."
|
|
1538
|
+
else:
|
|
1539
|
+
msg += f"\n\nFiles:\n" + "\n".join(invalid_extensions)
|
|
1540
|
+
|
|
1541
|
+
QMessageBox.warning(self, "Invalid File Extensions", msg)
|
|
1542
|
+
return
|
|
1543
|
+
|
|
1544
|
+
# Sort the files based on selected method
|
|
1545
|
+
sort_method = self.sort_combo.currentText().lower()
|
|
1546
|
+
if sort_method == "filename":
|
|
1547
|
+
matching_files.sort()
|
|
1548
|
+
elif sort_method == "date/time":
|
|
1549
|
+
matching_files.sort(key=os.path.getmtime)
|
|
1550
|
+
elif sort_method == "extension":
|
|
1551
|
+
matching_files.sort(key=lambda x: os.path.splitext(x)[1])
|
|
1552
|
+
|
|
1553
|
+
# Get output file
|
|
1554
|
+
output_file = self.output_file_edit.text().strip()
|
|
1555
|
+
if not output_file:
|
|
1556
|
+
QMessageBox.warning(
|
|
1557
|
+
self,
|
|
1558
|
+
"No Output File",
|
|
1559
|
+
"Please specify an output file for the video.",
|
|
1560
|
+
)
|
|
1561
|
+
return
|
|
1562
|
+
|
|
1563
|
+
# Normalize path (handle ~ and make absolute)
|
|
1564
|
+
output_file = os.path.abspath(os.path.expanduser(output_file))
|
|
1565
|
+
self.output_file_edit.setText(output_file)
|
|
1566
|
+
|
|
1567
|
+
# Check if output directory is writable
|
|
1568
|
+
output_dir = os.path.dirname(output_file)
|
|
1569
|
+
if not output_dir:
|
|
1570
|
+
output_dir = os.getcwd()
|
|
1571
|
+
|
|
1572
|
+
if not os.path.isdir(output_dir):
|
|
1573
|
+
try:
|
|
1574
|
+
os.makedirs(output_dir)
|
|
1575
|
+
except OSError:
|
|
1576
|
+
QMessageBox.critical(
|
|
1577
|
+
self,
|
|
1578
|
+
"Error",
|
|
1579
|
+
f"Could not create output directory: {output_dir}",
|
|
1580
|
+
)
|
|
1581
|
+
return
|
|
1582
|
+
|
|
1583
|
+
if not os.access(output_dir, os.W_OK):
|
|
1584
|
+
QMessageBox.critical(
|
|
1585
|
+
self,
|
|
1586
|
+
"Permission Denied",
|
|
1587
|
+
f"Output directory is not writable: {output_dir}",
|
|
1588
|
+
)
|
|
1589
|
+
return
|
|
1590
|
+
|
|
1591
|
+
# Confirm overwrite if file exists
|
|
1592
|
+
if os.path.exists(output_file):
|
|
1593
|
+
reply = QMessageBox.question(
|
|
1594
|
+
self,
|
|
1595
|
+
"Confirm Overwrite",
|
|
1596
|
+
f"File already exists:\n{output_file}\n\nDo you want to overwrite it?",
|
|
1597
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
1598
|
+
QMessageBox.No,
|
|
1599
|
+
)
|
|
1600
|
+
if reply == QMessageBox.No:
|
|
1601
|
+
return
|
|
1602
|
+
|
|
1603
|
+
# Get display options
|
|
1604
|
+
display_options = {
|
|
1605
|
+
"stokes": self.stokes_combo.currentText(),
|
|
1606
|
+
"colormap": self.colormap_combo.currentText(),
|
|
1607
|
+
"stretch": self.stretch_combo.currentText().lower(),
|
|
1608
|
+
"gamma": self.gamma_spinbox.value(),
|
|
1609
|
+
"range_mode": self.range_mode_combo.currentIndex(), # 0: Fixed Range, 1: Auto Per Frame, 2: Global Auto
|
|
1610
|
+
"vmin": self.vmin_spinbox.value(),
|
|
1611
|
+
"vmax": self.vmax_spinbox.value(),
|
|
1612
|
+
"colorbar": self.colorbar_check.isChecked(),
|
|
1613
|
+
"width": self.width_spinbox.value(),
|
|
1614
|
+
"height": self.height_spinbox.value(),
|
|
1615
|
+
"wcs_enabled": self.wcs_coords_check.isChecked(),
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
# Get overlay options
|
|
1620
|
+
overlay_options = {
|
|
1621
|
+
"timestamp": self.timestamp_check.isChecked(),
|
|
1622
|
+
"frame_number": self.frame_number_check.isChecked(),
|
|
1623
|
+
"filename": self.filename_check.isChecked(),
|
|
1624
|
+
"minmax_timeline_enabled": self.minmax_timeline_check.isChecked(),
|
|
1625
|
+
"timeline_position": 0, # Position selector removed - using default bottom dock
|
|
1626
|
+
"timeline_source": self.timeline_source_combo.currentIndex(), # 0=Colormap, 1=Contours
|
|
1627
|
+
"timeline_log_scale": self.timeline_log_scale_check.isChecked(),
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
# Get region selection options
|
|
1631
|
+
region_options = {
|
|
1632
|
+
"region_enabled": self.region_enabled.isChecked(),
|
|
1633
|
+
"x_min": self.x_min_spinbox.value(),
|
|
1634
|
+
"x_max": self.x_max_spinbox.value(),
|
|
1635
|
+
"y_min": self.y_min_spinbox.value(),
|
|
1636
|
+
"y_max": self.y_max_spinbox.value(),
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
# Ensure proper order of min/max values
|
|
1640
|
+
if region_options["region_enabled"]:
|
|
1641
|
+
region_options["x_min"], region_options["x_max"] = min(
|
|
1642
|
+
region_options["x_min"], region_options["x_max"]
|
|
1643
|
+
), max(region_options["x_min"], region_options["x_max"])
|
|
1644
|
+
region_options["y_min"], region_options["y_max"] = min(
|
|
1645
|
+
region_options["y_min"], region_options["y_max"]
|
|
1646
|
+
), max(region_options["y_min"], region_options["y_max"])
|
|
1647
|
+
|
|
1648
|
+
# Get contour video options
|
|
1649
|
+
contour_options = {
|
|
1650
|
+
"contour_video_enabled": self.contour_video_enabled.isChecked(),
|
|
1651
|
+
"contour_mode": self.contour_mode_combo.currentIndex(), # 0=A, 1=B, 2=C
|
|
1652
|
+
"base_file": self.contour_base_file_edit.text().strip(),
|
|
1653
|
+
"contour_files": getattr(self, "_cached_contour_files", []),
|
|
1654
|
+
"fixed_contour_file": self.contour_fixed_file_edit.text().strip(),
|
|
1655
|
+
"colormap_files": getattr(self, "_cached_colormap_files", []),
|
|
1656
|
+
"level_type": ["fraction", "sigma", "absolute"][self.contour_level_type_combo.currentIndex()],
|
|
1657
|
+
"pos_levels": self._parse_levels(self.contour_pos_levels_edit.text()),
|
|
1658
|
+
"neg_levels": self._parse_levels(self.contour_neg_levels_edit.text()),
|
|
1659
|
+
"pos_color": self.contour_pos_color_combo.currentText(),
|
|
1660
|
+
"neg_color": self.contour_neg_color_combo.currentText(),
|
|
1661
|
+
"linewidth": self.contour_linewidth_spin.value(),
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
# Check system resources
|
|
1666
|
+
# 1. Disk Space
|
|
1667
|
+
try:
|
|
1668
|
+
total, used, free = shutil.disk_usage(output_dir)
|
|
1669
|
+
if free < 500 * 1024 * 1024: # Less than 500MB
|
|
1670
|
+
reply = QMessageBox.warning(
|
|
1671
|
+
self,
|
|
1672
|
+
"Low Disk Space",
|
|
1673
|
+
f"Free disk space on {output_dir} is low ({free / (1024*1024):.1f} MB).\nVideo creation might fail or produce incomplete files.\n\nDo you want to continue?",
|
|
1674
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
1675
|
+
QMessageBox.No,
|
|
1676
|
+
)
|
|
1677
|
+
if reply == QMessageBox.No:
|
|
1678
|
+
return
|
|
1679
|
+
except Exception as e:
|
|
1680
|
+
print(f"Warning: Could not check disk space: {e}")
|
|
1681
|
+
|
|
1682
|
+
# 2. Memory
|
|
1683
|
+
try:
|
|
1684
|
+
vm = psutil.virtual_memory()
|
|
1685
|
+
# Estimate memory needed: ~100MB per core overhead + frame size
|
|
1686
|
+
# Very rough estimate, but good for catching extreme cases
|
|
1687
|
+
estimated_needed = 1024 * 1024 * 1024 # 1GB base
|
|
1688
|
+
if self.multiprocessing_check.isChecked():
|
|
1689
|
+
estimated_needed += self.cores_spinbox.value() * 200 * 1024 * 1024 # 200MB per core
|
|
1690
|
+
|
|
1691
|
+
if vm.available < estimated_needed:
|
|
1692
|
+
reply = QMessageBox.warning(
|
|
1693
|
+
self,
|
|
1694
|
+
"Low Memory",
|
|
1695
|
+
f"System memory is low ({vm.available / (1024*1024*1024):.1f} GB available).\nUsing {self.cores_spinbox.value()} cores might cause system instability.\n\nDo you want to continue?",
|
|
1696
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
1697
|
+
QMessageBox.No,
|
|
1698
|
+
)
|
|
1699
|
+
if reply == QMessageBox.No:
|
|
1700
|
+
return
|
|
1701
|
+
except Exception as e:
|
|
1702
|
+
print(f"Warning: Could not check memory: {e}")
|
|
1703
|
+
|
|
1704
|
+
# Get video options
|
|
1705
|
+
video_options = {
|
|
1706
|
+
"fps": self.fps_spinbox.value(),
|
|
1707
|
+
"quality": self.quality_spinbox.value(),
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
# Additional validation for dimensions
|
|
1711
|
+
width = self.width_spinbox.value()
|
|
1712
|
+
height = self.height_spinbox.value()
|
|
1713
|
+
|
|
1714
|
+
if width > 0 and width % 2 != 0:
|
|
1715
|
+
QMessageBox.warning(
|
|
1716
|
+
self,
|
|
1717
|
+
"Invalid Width",
|
|
1718
|
+
"Video width must be an even number.",
|
|
1719
|
+
)
|
|
1720
|
+
return
|
|
1721
|
+
|
|
1722
|
+
if height > 0 and height % 2 != 0:
|
|
1723
|
+
QMessageBox.warning(
|
|
1724
|
+
self,
|
|
1725
|
+
"Invalid Height",
|
|
1726
|
+
"Video height must be an even number.",
|
|
1727
|
+
)
|
|
1728
|
+
return
|
|
1729
|
+
|
|
1730
|
+
# Create progress dialog
|
|
1731
|
+
progress_dialog = QProgressDialog(
|
|
1732
|
+
"Creating video...",
|
|
1733
|
+
"Cancel",
|
|
1734
|
+
0,
|
|
1735
|
+
1000, # Use 1000 as maximum (100 * scale factor of 10)
|
|
1736
|
+
self,
|
|
1737
|
+
)
|
|
1738
|
+
progress_dialog.setWindowTitle("Creating Video")
|
|
1739
|
+
progress_dialog.setWindowModality(Qt.WindowModal)
|
|
1740
|
+
print(f"Created progress dialog with range: 0-1000")
|
|
1741
|
+
progress_dialog.show()
|
|
1742
|
+
self.progress_dialog = progress_dialog # Store as class member
|
|
1743
|
+
|
|
1744
|
+
# Merge all options
|
|
1745
|
+
options = {
|
|
1746
|
+
**display_options,
|
|
1747
|
+
**overlay_options,
|
|
1748
|
+
**region_options,
|
|
1749
|
+
**contour_options,
|
|
1750
|
+
**video_options,
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
# Create the video
|
|
1754
|
+
from solar_radio_image_viewer.create_video import (
|
|
1755
|
+
create_video as create_video_function,
|
|
1756
|
+
)
|
|
1757
|
+
|
|
1758
|
+
# Determine which files to use based on contour mode
|
|
1759
|
+
video_files = matching_files # Default: use Input tab files
|
|
1760
|
+
|
|
1761
|
+
if options.get("contour_video_enabled", False):
|
|
1762
|
+
contour_mode = options.get("contour_mode", 0)
|
|
1763
|
+
base_file = options.get("base_file", "")
|
|
1764
|
+
contour_files = options.get("contour_files", [])
|
|
1765
|
+
colormap_files = options.get("colormap_files", [])
|
|
1766
|
+
fixed_contour = options.get("fixed_contour_file", "")
|
|
1767
|
+
|
|
1768
|
+
if contour_mode == 0: # Mode A: Fixed base + evolving contours
|
|
1769
|
+
# The base image is displayed repeatedly, contours evolve
|
|
1770
|
+
if not base_file or not os.path.exists(base_file):
|
|
1771
|
+
QMessageBox.warning(self, "Error", "Contour Mode A requires a base image file")
|
|
1772
|
+
return
|
|
1773
|
+
if not contour_files:
|
|
1774
|
+
QMessageBox.warning(self, "Error", "Contour Mode A requires a contour directory")
|
|
1775
|
+
return
|
|
1776
|
+
# Create list of base_file repeated for each contour frame
|
|
1777
|
+
video_files = [base_file] * len(contour_files)
|
|
1778
|
+
print(f"Mode A: {len(contour_files)} contour frames, base image: {os.path.basename(base_file)}")
|
|
1779
|
+
|
|
1780
|
+
elif contour_mode == 1: # Mode B: Fixed contours + evolving colormap
|
|
1781
|
+
# Colormap images evolve, fixed contour overlaid on each
|
|
1782
|
+
if not fixed_contour or not os.path.exists(fixed_contour):
|
|
1783
|
+
QMessageBox.warning(self, "Error", "Contour Mode B requires a fixed contour file")
|
|
1784
|
+
return
|
|
1785
|
+
if not colormap_files:
|
|
1786
|
+
QMessageBox.warning(self, "Error", "Contour Mode B requires a colormap directory")
|
|
1787
|
+
return
|
|
1788
|
+
video_files = colormap_files
|
|
1789
|
+
print(f"Mode B: {len(colormap_files)} colormap frames, fixed contour: {os.path.basename(fixed_contour)}")
|
|
1790
|
+
|
|
1791
|
+
elif contour_mode == 2: # Mode C: Both evolve
|
|
1792
|
+
# Both colormap and contours evolve frame by frame
|
|
1793
|
+
if not colormap_files:
|
|
1794
|
+
QMessageBox.warning(self, "Error", "Contour Mode C requires a colormap directory")
|
|
1795
|
+
return
|
|
1796
|
+
if not contour_files:
|
|
1797
|
+
QMessageBox.warning(self, "Error", "Contour Mode C requires a contour directory")
|
|
1798
|
+
return
|
|
1799
|
+
# Match file counts
|
|
1800
|
+
if len(colormap_files) != len(contour_files):
|
|
1801
|
+
QMessageBox.warning(
|
|
1802
|
+
self, "Warning",
|
|
1803
|
+
f"Colormap ({len(colormap_files)}) and contour ({len(contour_files)}) file counts don't match. Using minimum."
|
|
1804
|
+
)
|
|
1805
|
+
min_count = min(len(colormap_files), len(contour_files))
|
|
1806
|
+
colormap_files = colormap_files[:min_count]
|
|
1807
|
+
options["contour_files"] = contour_files[:min_count]
|
|
1808
|
+
video_files = colormap_files
|
|
1809
|
+
print(f"Mode C: {len(colormap_files)} colormap frames, {len(contour_files)} contour frames")
|
|
1810
|
+
|
|
1811
|
+
print(f"Contour Mode {contour_mode}: Using {len(video_files)} files for video")
|
|
1812
|
+
|
|
1813
|
+
# Use a worker thread for video creation
|
|
1814
|
+
self.worker = VideoWorker(
|
|
1815
|
+
video_files,
|
|
1816
|
+
output_file,
|
|
1817
|
+
options,
|
|
1818
|
+
progress_dialog,
|
|
1819
|
+
self.cores_spinbox.value(),
|
|
1820
|
+
)
|
|
1821
|
+
self.worker.finished.connect(self.on_video_creation_finished)
|
|
1822
|
+
self.worker.error.connect(self.on_video_creation_error)
|
|
1823
|
+
|
|
1824
|
+
# Disable the create button while processing
|
|
1825
|
+
self.create_btn.setEnabled(False)
|
|
1826
|
+
self.create_btn.setText("Creating Video...")
|
|
1827
|
+
|
|
1828
|
+
# Start the worker thread
|
|
1829
|
+
self.worker.start()
|
|
1830
|
+
|
|
1831
|
+
except Exception as e:
|
|
1832
|
+
# Close the progress dialog
|
|
1833
|
+
if hasattr(self, "progress_dialog"):
|
|
1834
|
+
self.progress_dialog.setValue(
|
|
1835
|
+
1000
|
|
1836
|
+
) # Use 1000 instead of 100 to match our scale factor of 10
|
|
1837
|
+
self.progress_dialog.close()
|
|
1838
|
+
|
|
1839
|
+
QMessageBox.critical(
|
|
1840
|
+
self,
|
|
1841
|
+
"Error",
|
|
1842
|
+
f"Error creating video: {str(e)}",
|
|
1843
|
+
)
|
|
1844
|
+
|
|
1845
|
+
def on_video_creation_finished(self, output_file):
|
|
1846
|
+
"""Handle successful video creation"""
|
|
1847
|
+
# Re-enable the create button
|
|
1848
|
+
self.create_btn.setEnabled(True)
|
|
1849
|
+
self.create_btn.setText("Create Video")
|
|
1850
|
+
|
|
1851
|
+
# Close the progress dialog
|
|
1852
|
+
if hasattr(self, "progress_dialog"):
|
|
1853
|
+
self.progress_dialog.setValue(
|
|
1854
|
+
1000
|
|
1855
|
+
) # Use 1000 instead of 100 to match our scale factor of 10
|
|
1856
|
+
self.progress_dialog.close()
|
|
1857
|
+
|
|
1858
|
+
QMessageBox.information(
|
|
1859
|
+
self,
|
|
1860
|
+
"Video Created",
|
|
1861
|
+
f"Video successfully created: {output_file}",
|
|
1862
|
+
)
|
|
1863
|
+
# self.accept() # Keep dialog open after creation
|
|
1864
|
+
|
|
1865
|
+
def on_video_creation_error(self, error_message):
|
|
1866
|
+
"""Handle error in video creation"""
|
|
1867
|
+
# Re-enable the create button
|
|
1868
|
+
self.create_btn.setEnabled(True)
|
|
1869
|
+
self.create_btn.setText("Create Video")
|
|
1870
|
+
|
|
1871
|
+
QMessageBox.critical(
|
|
1872
|
+
self,
|
|
1873
|
+
"Error Creating Video",
|
|
1874
|
+
f"Error creating video: {error_message}",
|
|
1875
|
+
)
|
|
1876
|
+
|
|
1877
|
+
@wait_cursor()
|
|
1878
|
+
def select_region_from_preview(self, *args):
|
|
1879
|
+
"""Let the user select a region from the preview image"""
|
|
1880
|
+
if not self.reference_image:
|
|
1881
|
+
QMessageBox.warning(
|
|
1882
|
+
self, "No Preview", "Please load a reference image first."
|
|
1883
|
+
)
|
|
1884
|
+
return
|
|
1885
|
+
|
|
1886
|
+
try:
|
|
1887
|
+
# Enable region selection
|
|
1888
|
+
self.region_enabled.setChecked(True)
|
|
1889
|
+
|
|
1890
|
+
# Create a separate figure/canvas for selection to avoid parenting issues
|
|
1891
|
+
from matplotlib.figure import Figure as MplFigure
|
|
1892
|
+
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as MplCanvas
|
|
1893
|
+
|
|
1894
|
+
selection_figure = MplFigure(figsize=(5, 4), dpi=100)
|
|
1895
|
+
selection_canvas = MplCanvas(selection_figure)
|
|
1896
|
+
ax = selection_figure.add_subplot(111)
|
|
1897
|
+
|
|
1898
|
+
# Load data
|
|
1899
|
+
data, _ = load_fits_data(
|
|
1900
|
+
self.reference_image, stokes=self.stokes_combo.currentText()
|
|
1901
|
+
)
|
|
1902
|
+
|
|
1903
|
+
if data is None:
|
|
1904
|
+
return
|
|
1905
|
+
|
|
1906
|
+
# Display image for selection
|
|
1907
|
+
stretch = self.stretch_combo.currentText().lower()
|
|
1908
|
+
gamma = self.gamma_spinbox.value()
|
|
1909
|
+
cmap = self.colormap_combo.currentText()
|
|
1910
|
+
|
|
1911
|
+
# Determine vmin/vmax
|
|
1912
|
+
if self.range_mode_combo.currentIndex() == 0: # Fixed
|
|
1913
|
+
vmin = self.vmin_spinbox.value()
|
|
1914
|
+
vmax = self.vmax_spinbox.value()
|
|
1915
|
+
else: # Auto
|
|
1916
|
+
vmin = np.nanpercentile(data, 0)
|
|
1917
|
+
vmax = np.nanpercentile(data, 100)
|
|
1918
|
+
|
|
1919
|
+
# Create normalization
|
|
1920
|
+
norm = get_norm(stretch, vmin, vmax, gamma)
|
|
1921
|
+
|
|
1922
|
+
# Display the image
|
|
1923
|
+
ax.imshow(
|
|
1924
|
+
data, cmap=cmap, norm=norm, origin="lower", interpolation="nearest"
|
|
1925
|
+
)
|
|
1926
|
+
|
|
1927
|
+
ax.set_title("Click and drag to select region", fontsize=10)
|
|
1928
|
+
|
|
1929
|
+
# Add interactive rectangle selector
|
|
1930
|
+
from matplotlib.widgets import RectangleSelector
|
|
1931
|
+
|
|
1932
|
+
def onselect(eclick, erelease):
|
|
1933
|
+
"""Handle region selection event"""
|
|
1934
|
+
# Get coordinates in data space
|
|
1935
|
+
x1, y1 = eclick.xdata, eclick.ydata
|
|
1936
|
+
x2, y2 = erelease.xdata, erelease.ydata
|
|
1937
|
+
|
|
1938
|
+
# Check for None (outside axes)
|
|
1939
|
+
if None in (x1, y1, x2, y2):
|
|
1940
|
+
return
|
|
1941
|
+
|
|
1942
|
+
# Ensure proper min/max
|
|
1943
|
+
x_min, x_max = int(min(x1, x2)), int(max(x1, x2))
|
|
1944
|
+
y_min, y_max = int(min(y1, y2)), int(max(y1, y2))
|
|
1945
|
+
|
|
1946
|
+
# Update spinboxes with selected region
|
|
1947
|
+
self.x_min_spinbox.setValue(max(0, x_min))
|
|
1948
|
+
self.x_max_spinbox.setValue(min(data.shape[1] - 1, x_max))
|
|
1949
|
+
self.y_min_spinbox.setValue(max(0, y_min))
|
|
1950
|
+
self.y_max_spinbox.setValue(min(data.shape[0] - 1, y_max))
|
|
1951
|
+
|
|
1952
|
+
# We don't update the main preview here to keep things fast/safe
|
|
1953
|
+
# The main preview will update when the dialog closes via the changed spinboxes if needed
|
|
1954
|
+
# or we can update it explicitly at the end
|
|
1955
|
+
|
|
1956
|
+
# Draw rectangle selector
|
|
1957
|
+
rect_selector = RectangleSelector(
|
|
1958
|
+
ax,
|
|
1959
|
+
onselect,
|
|
1960
|
+
useblit=True,
|
|
1961
|
+
button=[1], # Left mouse button only
|
|
1962
|
+
minspanx=5,
|
|
1963
|
+
minspany=5,
|
|
1964
|
+
spancoords="pixels",
|
|
1965
|
+
interactive=True,
|
|
1966
|
+
props=dict(facecolor="none", edgecolor="red", linewidth=2),
|
|
1967
|
+
)
|
|
1968
|
+
|
|
1969
|
+
# Need to keep a reference to prevent garbage collection
|
|
1970
|
+
self._rect_selector = rect_selector
|
|
1971
|
+
|
|
1972
|
+
# Show message
|
|
1973
|
+
'''status_text = ax.text(
|
|
1974
|
+
0.5,
|
|
1975
|
+
0.02,
|
|
1976
|
+
"Click and drag to select region, then close this window",
|
|
1977
|
+
transform=ax.transAxes,
|
|
1978
|
+
ha="center",
|
|
1979
|
+
va="bottom",
|
|
1980
|
+
bbox=dict(boxstyle="round", fc="white", alpha=0.8),
|
|
1981
|
+
)'''
|
|
1982
|
+
|
|
1983
|
+
# Refresh canvas
|
|
1984
|
+
selection_canvas.draw()
|
|
1985
|
+
|
|
1986
|
+
# Create a modal dialog to use for selection
|
|
1987
|
+
selector_dialog = QDialog(self)
|
|
1988
|
+
selector_dialog.setWindowTitle("Select Region")
|
|
1989
|
+
selector_layout = QVBoxLayout(selector_dialog)
|
|
1990
|
+
|
|
1991
|
+
# Add the canvas to the dialog
|
|
1992
|
+
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT
|
|
1993
|
+
|
|
1994
|
+
toolbar = NavigationToolbar2QT(selection_canvas, selector_dialog)
|
|
1995
|
+
selector_layout.addWidget(toolbar)
|
|
1996
|
+
selector_layout.addWidget(selection_canvas)
|
|
1997
|
+
|
|
1998
|
+
# Add instructions
|
|
1999
|
+
instructions = QLabel(
|
|
2000
|
+
"Click and drag to select a region. Use toolbar to pan/zoom if needed. "
|
|
2001
|
+
"Close this dialog when finished."
|
|
2002
|
+
)
|
|
2003
|
+
instructions.setWordWrap(True)
|
|
2004
|
+
selector_layout.addWidget(instructions)
|
|
2005
|
+
|
|
2006
|
+
# Add done button
|
|
2007
|
+
done_btn = QPushButton("Done")
|
|
2008
|
+
done_btn.clicked.connect(selector_dialog.accept)
|
|
2009
|
+
selector_layout.addWidget(done_btn)
|
|
2010
|
+
|
|
2011
|
+
# Set a reasonable size
|
|
2012
|
+
selector_dialog.resize(800, 600)
|
|
2013
|
+
|
|
2014
|
+
# Execute dialog
|
|
2015
|
+
selector_dialog.exec_()
|
|
2016
|
+
|
|
2017
|
+
# Clean up selector to break circular references
|
|
2018
|
+
self._rect_selector = None
|
|
2019
|
+
|
|
2020
|
+
# Update the preview after dialog closes
|
|
2021
|
+
self.update_preview()
|
|
2022
|
+
|
|
2023
|
+
except Exception as e:
|
|
2024
|
+
QMessageBox.warning(self, "Error", f"Could not select region: {str(e)}")
|
|
2025
|
+
|
|
2026
|
+
def apply_auto_minmax(self):
|
|
2027
|
+
"""Apply Auto Min/Max preset to the display range"""
|
|
2028
|
+
if not self.reference_image:
|
|
2029
|
+
return
|
|
2030
|
+
|
|
2031
|
+
data, _ = load_fits_data(
|
|
2032
|
+
self.reference_image, stokes=self.stokes_combo.currentText()
|
|
2033
|
+
)
|
|
2034
|
+
if data is None:
|
|
2035
|
+
return
|
|
2036
|
+
|
|
2037
|
+
# Calculate min/max, excluding NaN values
|
|
2038
|
+
vmin = np.nanmin(data)
|
|
2039
|
+
vmax = np.nanmax(data)
|
|
2040
|
+
|
|
2041
|
+
# Update spinboxes
|
|
2042
|
+
self.range_mode_combo.setCurrentIndex(0) # Switch to fixed range
|
|
2043
|
+
self.vmin_spinbox.setValue(vmin)
|
|
2044
|
+
self.vmax_spinbox.setValue(vmax)
|
|
2045
|
+
|
|
2046
|
+
# Update preview
|
|
2047
|
+
self.update_preview()
|
|
2048
|
+
|
|
2049
|
+
def apply_auto_percentile(self):
|
|
2050
|
+
"""Apply Auto Percentile preset to the display range"""
|
|
2051
|
+
if not self.reference_image:
|
|
2052
|
+
return
|
|
2053
|
+
|
|
2054
|
+
data, _ = load_fits_data(
|
|
2055
|
+
self.reference_image, stokes=self.stokes_combo.currentText()
|
|
2056
|
+
)
|
|
2057
|
+
if data is None:
|
|
2058
|
+
return
|
|
2059
|
+
|
|
2060
|
+
# Calculate 1st and 99th percentiles
|
|
2061
|
+
vmin = np.nanpercentile(data, 1)
|
|
2062
|
+
vmax = np.nanpercentile(data, 99)
|
|
2063
|
+
|
|
2064
|
+
# Update spinboxes
|
|
2065
|
+
self.range_mode_combo.setCurrentIndex(0) # Switch to fixed range
|
|
2066
|
+
self.vmin_spinbox.setValue(np.nanpercentile(data, 1))
|
|
2067
|
+
self.vmax_spinbox.setValue(np.nanpercentile(data, 99))
|
|
2068
|
+
|
|
2069
|
+
# Update preview
|
|
2070
|
+
self.update_preview()
|
|
2071
|
+
|
|
2072
|
+
def apply_auto_median_rms(self):
|
|
2073
|
+
"""Apply Auto Median ± 3×RMS preset to the display range"""
|
|
2074
|
+
if not self.reference_image:
|
|
2075
|
+
return
|
|
2076
|
+
|
|
2077
|
+
data, _ = load_fits_data(
|
|
2078
|
+
self.reference_image, stokes=self.stokes_combo.currentText()
|
|
2079
|
+
)
|
|
2080
|
+
if data is None:
|
|
2081
|
+
return
|
|
2082
|
+
|
|
2083
|
+
# Calculate median and RMS
|
|
2084
|
+
median = np.nanmedian(data)
|
|
2085
|
+
rms = np.sqrt(np.nanmean(np.square(data - median)))
|
|
2086
|
+
|
|
2087
|
+
# Set range to median ± 3×RMS
|
|
2088
|
+
vmin = median - 3 * rms
|
|
2089
|
+
vmax = median + 3 * rms
|
|
2090
|
+
|
|
2091
|
+
# Update spinboxes
|
|
2092
|
+
self.range_mode_combo.setCurrentIndex(0) # Switch to fixed range
|
|
2093
|
+
self.vmin_spinbox.setValue(vmin)
|
|
2094
|
+
self.vmax_spinbox.setValue(vmax)
|
|
2095
|
+
|
|
2096
|
+
# Update preview
|
|
2097
|
+
self.update_preview()
|
|
2098
|
+
|
|
2099
|
+
def apply_aia_preset(self):
|
|
2100
|
+
"""Apply AIA 171Å preset to the display"""
|
|
2101
|
+
# Set colormap to SDO-AIA 171
|
|
2102
|
+
idx = self.colormap_combo.findText("sdoaia171")
|
|
2103
|
+
if idx >= 0:
|
|
2104
|
+
self.colormap_combo.setCurrentIndex(idx)
|
|
2105
|
+
else:
|
|
2106
|
+
# Fallback to similar colormap
|
|
2107
|
+
idx = self.colormap_combo.findText("hot")
|
|
2108
|
+
if idx >= 0:
|
|
2109
|
+
self.colormap_combo.setCurrentIndex(idx)
|
|
2110
|
+
|
|
2111
|
+
# Set stretch to log
|
|
2112
|
+
idx = self.stretch_combo.findText("Log")
|
|
2113
|
+
if idx >= 0:
|
|
2114
|
+
self.stretch_combo.setCurrentIndex(idx)
|
|
2115
|
+
|
|
2116
|
+
# Update preview
|
|
2117
|
+
self.update_preview()
|
|
2118
|
+
|
|
2119
|
+
def apply_hmi_preset(self):
|
|
2120
|
+
"""Apply HMI preset to the display"""
|
|
2121
|
+
# Set colormap to gray for HMI
|
|
2122
|
+
idx = self.colormap_combo.findText("gray")
|
|
2123
|
+
if idx >= 0:
|
|
2124
|
+
self.colormap_combo.setCurrentIndex(idx)
|
|
2125
|
+
|
|
2126
|
+
# Set stretch to linear
|
|
2127
|
+
idx = self.stretch_combo.findText("Linear")
|
|
2128
|
+
if idx >= 0:
|
|
2129
|
+
self.stretch_combo.setCurrentIndex(idx)
|
|
2130
|
+
|
|
2131
|
+
# Update preview
|
|
2132
|
+
self.update_preview()
|
|
2133
|
+
|
|
2134
|
+
def set_region_preset(self, percentage):
|
|
2135
|
+
"""Set the region to a centered area covering the given percentage of the image.
|
|
2136
|
+
|
|
2137
|
+
Parameters
|
|
2138
|
+
----------
|
|
2139
|
+
percentage : float
|
|
2140
|
+
Percentage of the image to cover (0.0 to 1.0)
|
|
2141
|
+
"""
|
|
2142
|
+
if not self.reference_image:
|
|
2143
|
+
QMessageBox.warning(
|
|
2144
|
+
self, "No Reference Image", "Please select a reference image first."
|
|
2145
|
+
)
|
|
2146
|
+
return
|
|
2147
|
+
|
|
2148
|
+
try:
|
|
2149
|
+
# Load the reference image data
|
|
2150
|
+
data, _ = load_fits_data(
|
|
2151
|
+
self.reference_image, stokes=self.stokes_combo.currentText()
|
|
2152
|
+
)
|
|
2153
|
+
|
|
2154
|
+
if data is None:
|
|
2155
|
+
return
|
|
2156
|
+
|
|
2157
|
+
# Get image dimensions
|
|
2158
|
+
height, width = data.shape
|
|
2159
|
+
|
|
2160
|
+
# Calculate the size of the region
|
|
2161
|
+
region_width = int(width * percentage)
|
|
2162
|
+
region_height = int(height * percentage)
|
|
2163
|
+
|
|
2164
|
+
# Calculate the center of the image
|
|
2165
|
+
center_x = width // 2
|
|
2166
|
+
center_y = height // 2
|
|
2167
|
+
|
|
2168
|
+
# Calculate region boundaries
|
|
2169
|
+
x_min = center_x - region_width // 2
|
|
2170
|
+
x_max = center_x + region_width // 2
|
|
2171
|
+
y_min = center_y - region_height // 2
|
|
2172
|
+
y_max = center_y + region_height // 2
|
|
2173
|
+
|
|
2174
|
+
# Ensure region is within image boundaries
|
|
2175
|
+
x_min = max(0, x_min)
|
|
2176
|
+
x_max = min(width - 1, x_max)
|
|
2177
|
+
y_min = max(0, y_min)
|
|
2178
|
+
y_max = min(height - 1, y_max)
|
|
2179
|
+
|
|
2180
|
+
# Update spinboxes
|
|
2181
|
+
self.x_min_spinbox.setValue(x_min)
|
|
2182
|
+
self.x_max_spinbox.setValue(x_max)
|
|
2183
|
+
self.y_min_spinbox.setValue(y_min)
|
|
2184
|
+
self.y_max_spinbox.setValue(y_max)
|
|
2185
|
+
|
|
2186
|
+
# Ensure region selection is enabled
|
|
2187
|
+
self.region_enabled.setChecked(True)
|
|
2188
|
+
|
|
2189
|
+
# Update preview
|
|
2190
|
+
self.update_preview()
|
|
2191
|
+
|
|
2192
|
+
except Exception as e:
|
|
2193
|
+
QMessageBox.warning(self, "Error", f"Could not set region preset: {str(e)}")
|
|
2194
|
+
|
|
2195
|
+
def toggle_region_controls(self, enabled):
|
|
2196
|
+
"""Enable or disable region controls"""
|
|
2197
|
+
self.x_min_spinbox.setEnabled(enabled)
|
|
2198
|
+
self.x_max_spinbox.setEnabled(enabled)
|
|
2199
|
+
self.y_min_spinbox.setEnabled(enabled)
|
|
2200
|
+
self.y_max_spinbox.setEnabled(enabled)
|
|
2201
|
+
self.update_region_preview()
|
|
2202
|
+
|
|
2203
|
+
def toggle_contour_mode(self, enabled):
|
|
2204
|
+
"""Toggle Contour Mode - shows/hides Input and Contours tabs"""
|
|
2205
|
+
# Input tab is at index 0, Contours tab is at index 1
|
|
2206
|
+
self.tab_widget.setTabVisible(0, not enabled)
|
|
2207
|
+
self.tab_widget.setTabVisible(1, enabled)
|
|
2208
|
+
|
|
2209
|
+
# Also toggle the contour video controls
|
|
2210
|
+
self.toggle_contour_video_controls(enabled)
|
|
2211
|
+
|
|
2212
|
+
# Update create button state based on contour mode files
|
|
2213
|
+
self.update_create_button_state()
|
|
2214
|
+
|
|
2215
|
+
# If enabling contour mode, switch to Contours tab
|
|
2216
|
+
if enabled:
|
|
2217
|
+
self.tab_widget.setCurrentIndex(1)
|
|
2218
|
+
else:
|
|
2219
|
+
self.tab_widget.setCurrentIndex(0)
|
|
2220
|
+
|
|
2221
|
+
def update_create_button_state(self):
|
|
2222
|
+
"""Update Create Video button state based on current mode and inputs"""
|
|
2223
|
+
if self.contour_video_enabled.isChecked():
|
|
2224
|
+
# In contour mode, check if required contour files are specified
|
|
2225
|
+
mode = self.contour_mode_combo.currentIndex()
|
|
2226
|
+
has_valid_input = False
|
|
2227
|
+
missing_fields = []
|
|
2228
|
+
|
|
2229
|
+
if mode == 0: # Mode A: Fixed base + evolving contours
|
|
2230
|
+
base_file = self.contour_base_file_edit.text().strip()
|
|
2231
|
+
contour_dir = self.contour_dir_edit.text().strip()
|
|
2232
|
+
if not base_file or not os.path.exists(base_file):
|
|
2233
|
+
missing_fields.append("Base Image")
|
|
2234
|
+
if not contour_dir or not os.path.isdir(contour_dir):
|
|
2235
|
+
missing_fields.append("Contour Directory")
|
|
2236
|
+
has_valid_input = len(missing_fields) == 0
|
|
2237
|
+
|
|
2238
|
+
elif mode == 1: # Mode B: Fixed contour + evolving colormap
|
|
2239
|
+
fixed_contour = self.contour_fixed_file_edit.text().strip()
|
|
2240
|
+
colormap_dir = self.contour_colormap_dir_edit.text().strip()
|
|
2241
|
+
if not fixed_contour or not os.path.exists(fixed_contour):
|
|
2242
|
+
missing_fields.append("Fixed Contour File")
|
|
2243
|
+
if not colormap_dir or not os.path.isdir(colormap_dir):
|
|
2244
|
+
missing_fields.append("Colormap Directory")
|
|
2245
|
+
has_valid_input = len(missing_fields) == 0
|
|
2246
|
+
|
|
2247
|
+
elif mode == 2: # Mode C: Both evolve
|
|
2248
|
+
contour_dir = self.contour_dir_edit.text().strip()
|
|
2249
|
+
colormap_dir = self.contour_colormap_dir_edit.text().strip()
|
|
2250
|
+
if not contour_dir or not os.path.isdir(contour_dir):
|
|
2251
|
+
missing_fields.append("Contour Directory")
|
|
2252
|
+
if not colormap_dir or not os.path.isdir(colormap_dir):
|
|
2253
|
+
missing_fields.append("Colormap Directory")
|
|
2254
|
+
has_valid_input = len(missing_fields) == 0
|
|
2255
|
+
|
|
2256
|
+
self.create_btn.setEnabled(has_valid_input)
|
|
2257
|
+
if not has_valid_input:
|
|
2258
|
+
self.create_btn.setToolTip(f"Missing: {', '.join(missing_fields)}")
|
|
2259
|
+
else:
|
|
2260
|
+
self.create_btn.setToolTip("")
|
|
2261
|
+
else:
|
|
2262
|
+
# Normal mode - enable button (existing validation on create)
|
|
2263
|
+
self.create_btn.setToolTip("")
|
|
2264
|
+
|
|
2265
|
+
def toggle_contour_video_controls(self, enabled):
|
|
2266
|
+
"""Enable or disable contour video controls"""
|
|
2267
|
+
self.contour_mode_combo.setEnabled(enabled)
|
|
2268
|
+
self.contour_files_group.setEnabled(enabled)
|
|
2269
|
+
if enabled:
|
|
2270
|
+
self.update_contour_mode_ui(self.contour_mode_combo.currentIndex())
|
|
2271
|
+
|
|
2272
|
+
def update_contour_mode_ui(self, index):
|
|
2273
|
+
"""Update visibility of contour file inputs based on mode"""
|
|
2274
|
+
# Mode A: Base image + contour directory + contour pattern
|
|
2275
|
+
# Mode B: Fixed contour + colormap directory + colormap pattern
|
|
2276
|
+
# Mode C: Contour directory + contour pattern + colormap directory + colormap pattern
|
|
2277
|
+
|
|
2278
|
+
# Get parent widgets for each row
|
|
2279
|
+
base_file_row = self.contour_base_file_edit.parent()
|
|
2280
|
+
contour_dir_row = self.contour_dir_edit.parent()
|
|
2281
|
+
fixed_contour_row = self.contour_fixed_file_edit.parent()
|
|
2282
|
+
colormap_dir_row = self.contour_colormap_dir_edit.parent()
|
|
2283
|
+
|
|
2284
|
+
if index == 0: # Mode A: Fixed base + evolving contours
|
|
2285
|
+
base_file_row.setVisible(True)
|
|
2286
|
+
contour_dir_row.setVisible(True)
|
|
2287
|
+
self.contour_pattern_widget.setVisible(True)
|
|
2288
|
+
fixed_contour_row.setVisible(False)
|
|
2289
|
+
colormap_dir_row.setVisible(False)
|
|
2290
|
+
self.colormap_pattern_widget.setVisible(False)
|
|
2291
|
+
elif index == 1: # Mode B: Fixed contours + evolving colormap
|
|
2292
|
+
base_file_row.setVisible(False)
|
|
2293
|
+
contour_dir_row.setVisible(False)
|
|
2294
|
+
self.contour_pattern_widget.setVisible(False)
|
|
2295
|
+
fixed_contour_row.setVisible(True)
|
|
2296
|
+
colormap_dir_row.setVisible(True)
|
|
2297
|
+
self.colormap_pattern_widget.setVisible(True)
|
|
2298
|
+
else: # Mode C: Both evolve
|
|
2299
|
+
base_file_row.setVisible(False)
|
|
2300
|
+
contour_dir_row.setVisible(True)
|
|
2301
|
+
self.contour_pattern_widget.setVisible(True)
|
|
2302
|
+
fixed_contour_row.setVisible(False)
|
|
2303
|
+
colormap_dir_row.setVisible(True)
|
|
2304
|
+
self.colormap_pattern_widget.setVisible(True)
|
|
2305
|
+
|
|
2306
|
+
def browse_contour_base_file(self):
|
|
2307
|
+
"""Browse for base image file"""
|
|
2308
|
+
start_dir = self.get_smart_start_directory(self.contour_base_file_edit.text().strip())
|
|
2309
|
+
file_path, _ = QFileDialog.getOpenFileName(
|
|
2310
|
+
self, "Select Base Image File", start_dir,
|
|
2311
|
+
"FITS Files (*.fits *.fts);;All Files (*)"
|
|
2312
|
+
)
|
|
2313
|
+
if file_path:
|
|
2314
|
+
self.contour_base_file_edit.setText(file_path)
|
|
2315
|
+
|
|
2316
|
+
def browse_contour_directory(self):
|
|
2317
|
+
"""Browse for contour files directory"""
|
|
2318
|
+
start_dir = self.get_smart_start_directory(self.contour_dir_edit.text().strip())
|
|
2319
|
+
directory = QFileDialog.getExistingDirectory(
|
|
2320
|
+
self, "Select Contour Files Directory", start_dir
|
|
2321
|
+
)
|
|
2322
|
+
if directory:
|
|
2323
|
+
self.contour_dir_edit.setText(directory)
|
|
2324
|
+
|
|
2325
|
+
def browse_contour_fixed_file(self):
|
|
2326
|
+
"""Browse for fixed contour file"""
|
|
2327
|
+
start_dir = self.get_smart_start_directory(self.contour_fixed_file_edit.text().strip())
|
|
2328
|
+
file_path, _ = QFileDialog.getOpenFileName(
|
|
2329
|
+
self, "Select Fixed Contour File", start_dir,
|
|
2330
|
+
"FITS Files (*.fits *.fts);;All Files (*)"
|
|
2331
|
+
)
|
|
2332
|
+
if file_path:
|
|
2333
|
+
self.contour_fixed_file_edit.setText(file_path)
|
|
2334
|
+
|
|
2335
|
+
def browse_contour_colormap_directory(self):
|
|
2336
|
+
"""Browse for colormap files directory"""
|
|
2337
|
+
start_dir = self.get_smart_start_directory(self.contour_colormap_dir_edit.text().strip())
|
|
2338
|
+
directory = QFileDialog.getExistingDirectory(
|
|
2339
|
+
self, "Select Colormap Files Directory", start_dir
|
|
2340
|
+
)
|
|
2341
|
+
if directory:
|
|
2342
|
+
self.contour_colormap_dir_edit.setText(directory)
|
|
2343
|
+
|
|
2344
|
+
def scan_contour_files(self):
|
|
2345
|
+
"""Scan contour directory and update file count label"""
|
|
2346
|
+
directory = self.contour_dir_edit.text().strip()
|
|
2347
|
+
pattern = self.contour_dir_pattern_edit.text().strip() or "*.fits"
|
|
2348
|
+
|
|
2349
|
+
if not directory or not os.path.isdir(directory):
|
|
2350
|
+
self.contour_files_count_label.setText("")
|
|
2351
|
+
self._cached_contour_files = []
|
|
2352
|
+
return
|
|
2353
|
+
|
|
2354
|
+
full_pattern = os.path.join(directory, pattern)
|
|
2355
|
+
files = sorted(glob.glob(full_pattern))
|
|
2356
|
+
self._cached_contour_files = files
|
|
2357
|
+
|
|
2358
|
+
if files:
|
|
2359
|
+
self.contour_files_count_label.setText(f"✓ {len(files)} files")
|
|
2360
|
+
self.contour_files_count_label.setStyleSheet("color: green;")
|
|
2361
|
+
else:
|
|
2362
|
+
self.contour_files_count_label.setText("No files found")
|
|
2363
|
+
self.contour_files_count_label.setStyleSheet("color: red;")
|
|
2364
|
+
|
|
2365
|
+
def scan_colormap_files(self):
|
|
2366
|
+
"""Scan colormap directory and update file count label"""
|
|
2367
|
+
directory = self.contour_colormap_dir_edit.text().strip()
|
|
2368
|
+
pattern = self.contour_colormap_pattern_edit.text().strip() or "*.fits"
|
|
2369
|
+
|
|
2370
|
+
if not directory or not os.path.isdir(directory):
|
|
2371
|
+
self.colormap_files_count_label.setText("")
|
|
2372
|
+
self._cached_colormap_files = []
|
|
2373
|
+
return
|
|
2374
|
+
|
|
2375
|
+
full_pattern = os.path.join(directory, pattern)
|
|
2376
|
+
files = sorted(glob.glob(full_pattern))
|
|
2377
|
+
self._cached_colormap_files = files
|
|
2378
|
+
|
|
2379
|
+
if files:
|
|
2380
|
+
self.colormap_files_count_label.setText(f"✓ {len(files)} files")
|
|
2381
|
+
self.colormap_files_count_label.setStyleSheet("color: green;")
|
|
2382
|
+
else:
|
|
2383
|
+
self.colormap_files_count_label.setText("No files found")
|
|
2384
|
+
self.colormap_files_count_label.setStyleSheet("color: red;")
|
|
2385
|
+
|
|
2386
|
+
def update_region_preview(self):
|
|
2387
|
+
"""Update the preview when region controls change"""
|
|
2388
|
+
if self.region_enabled.isChecked():
|
|
2389
|
+
self.update_preview()
|
|
2390
|
+
|
|
2391
|
+
def closeEvent(self, event):
|
|
2392
|
+
"""Handle dialog close event"""
|
|
2393
|
+
# Stop worker thread if running
|
|
2394
|
+
if hasattr(self, "worker") and self.worker.isRunning():
|
|
2395
|
+
self.worker.cancel()
|
|
2396
|
+
self.worker.wait(1000) # Wait up to 1 second for thread to finish
|
|
2397
|
+
event.accept()
|
|
2398
|
+
|
|
2399
|
+
def reject(self):
|
|
2400
|
+
"""Handle dialog rejection (Cancel button or Esc key)"""
|
|
2401
|
+
# Stop worker thread if running
|
|
2402
|
+
if hasattr(self, "worker") and self.worker.isRunning():
|
|
2403
|
+
self.worker.cancel()
|
|
2404
|
+
self.worker.wait(1000) # Wait up to 1 second for thread to finish
|
|
2405
|
+
super().reject()
|
|
2406
|
+
|
|
2407
|
+
def update_gamma_controls(self):
|
|
2408
|
+
"""Enable/disable gamma controls based on the selected stretch type"""
|
|
2409
|
+
stretch = self.stretch_combo.currentText().lower()
|
|
2410
|
+
enable_gamma = stretch == "power"
|
|
2411
|
+
|
|
2412
|
+
self.gamma_spinbox.setEnabled(enable_gamma)
|
|
2413
|
+
|
|
2414
|
+
def _parse_levels(self, text):
|
|
2415
|
+
"""Parse comma-separated level values from text"""
|
|
2416
|
+
try:
|
|
2417
|
+
levels = []
|
|
2418
|
+
for part in text.split(","):
|
|
2419
|
+
part = part.strip()
|
|
2420
|
+
if part:
|
|
2421
|
+
levels.append(float(part))
|
|
2422
|
+
return levels
|
|
2423
|
+
except ValueError:
|
|
2424
|
+
return [0.1, 0.3, 0.5, 0.7, 0.9] # Default levels
|
|
2425
|
+
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
# Worker thread for video creation
|
|
2429
|
+
class VideoWorker(QThread):
|
|
2430
|
+
finished = pyqtSignal(str) # Signal emitted when video creation is complete
|
|
2431
|
+
error = pyqtSignal(str) # Signal emitted when an error occurs
|
|
2432
|
+
progress = pyqtSignal(int) # Signal emitted to update progress
|
|
2433
|
+
status_update = pyqtSignal(str) # Signal emitted to update status message
|
|
2434
|
+
|
|
2435
|
+
def __init__(self, files, output_file, options, progress_dialog, cpu_count):
|
|
2436
|
+
super().__init__()
|
|
2437
|
+
self.files = files
|
|
2438
|
+
self.output_file = output_file
|
|
2439
|
+
self.options = options
|
|
2440
|
+
self.progress_dialog = progress_dialog
|
|
2441
|
+
self.is_cancelled = False
|
|
2442
|
+
self.in_global_stats_phase = False
|
|
2443
|
+
self.processing_complete = False # Flag to indicate when processing is complete
|
|
2444
|
+
|
|
2445
|
+
# Fix for progress display - multiply progress values by 10
|
|
2446
|
+
self.progress_scale_factor = 10 # Factor to scale progress values
|
|
2447
|
+
|
|
2448
|
+
# Add multiprocessing options to the options dictionary
|
|
2449
|
+
self.options["use_multiprocessing"] = True
|
|
2450
|
+
self.options["cpu_count"] = cpu_count
|
|
2451
|
+
print(f"Enabling multiprocessing with {cpu_count} cores")
|
|
2452
|
+
|
|
2453
|
+
# Connect signals
|
|
2454
|
+
self.progress.connect(self.progress_dialog.setValue)
|
|
2455
|
+
print("Connected progress signal to progress_dialog.setValue")
|
|
2456
|
+
|
|
2457
|
+
# Add a debug print to each progress value emitted
|
|
2458
|
+
def debug_progress_value(value):
|
|
2459
|
+
print(f"Progress value received by dialog: {value}")
|
|
2460
|
+
self.progress_dialog.setValue(value)
|
|
2461
|
+
|
|
2462
|
+
# Replace the standard connection with our debug version
|
|
2463
|
+
self.progress.disconnect(self.progress_dialog.setValue)
|
|
2464
|
+
self.progress.connect(debug_progress_value)
|
|
2465
|
+
|
|
2466
|
+
# Connect status update signal
|
|
2467
|
+
self.status_update.connect(self.update_progress_title)
|
|
2468
|
+
|
|
2469
|
+
self.cpu_count = cpu_count
|
|
2470
|
+
|
|
2471
|
+
# For time-based progress tracking
|
|
2472
|
+
self.start_time = None
|
|
2473
|
+
self.frame_start_time = None
|
|
2474
|
+
self.avg_frame_time = None
|
|
2475
|
+
self.total_time_estimate = None
|
|
2476
|
+
|
|
2477
|
+
# For global stats phase
|
|
2478
|
+
self.stats_progress_thread = None
|
|
2479
|
+
|
|
2480
|
+
# Progress tracker state
|
|
2481
|
+
self.frames_processed = 0
|
|
2482
|
+
self.total_frames = len(files)
|
|
2483
|
+
self.progress_update_interval = 0.25 # seconds between progress updates
|
|
2484
|
+
|
|
2485
|
+
def update_progress_title(self, message):
|
|
2486
|
+
"""Update the progress dialog title with current status"""
|
|
2487
|
+
if hasattr(self, "progress_dialog") and self.progress_dialog:
|
|
2488
|
+
self.progress_dialog.setLabelText(message)
|
|
2489
|
+
|
|
2490
|
+
def update_progress_continuously(self):
|
|
2491
|
+
"""Thread function to update progress continuously based on time"""
|
|
2492
|
+
last_update_time = time.time()
|
|
2493
|
+
pulsing_progress = 0
|
|
2494
|
+
|
|
2495
|
+
while not self.is_cancelled and hasattr(self, "progress_dialog"):
|
|
2496
|
+
current_time = time.time()
|
|
2497
|
+
|
|
2498
|
+
# Exit the loop if processing is complete
|
|
2499
|
+
if self.processing_complete:
|
|
2500
|
+
# Allow setting to 100% when complete
|
|
2501
|
+
self.progress.emit(
|
|
2502
|
+
1000
|
|
2503
|
+
) # Use 1000 instead of 100 to match our scale factor of 10
|
|
2504
|
+
break
|
|
2505
|
+
|
|
2506
|
+
# Update at most every progress_update_interval seconds
|
|
2507
|
+
if current_time - last_update_time >= self.progress_update_interval:
|
|
2508
|
+
last_update_time = current_time
|
|
2509
|
+
|
|
2510
|
+
# If no frames have been processed yet, assume we're still in initialization
|
|
2511
|
+
# or global stats phase - show pulsing progress indicator
|
|
2512
|
+
if self.frames_processed == 0:
|
|
2513
|
+
# Create pulsing effect from 1-20%
|
|
2514
|
+
elapsed = current_time - self.start_time
|
|
2515
|
+
pulsing_progress = 5 + 15 * (
|
|
2516
|
+
(elapsed % 3) / 3
|
|
2517
|
+
) # 3-second cycle from 5-20%
|
|
2518
|
+
scaled_progress = int(pulsing_progress * self.progress_scale_factor)
|
|
2519
|
+
# print(
|
|
2520
|
+
# f"Pulsing progress: {pulsing_progress}% - Scaled: {scaled_progress}"
|
|
2521
|
+
# )
|
|
2522
|
+
self.progress.emit(scaled_progress)
|
|
2523
|
+
|
|
2524
|
+
# Sleep for a short time to avoid consuming too much CPU
|
|
2525
|
+
time.sleep(0.1)
|
|
2526
|
+
|
|
2527
|
+
def run(self):
|
|
2528
|
+
try:
|
|
2529
|
+
# Directly use the create_video function instead of trying to import it
|
|
2530
|
+
from solar_radio_image_viewer.create_video import (
|
|
2531
|
+
create_video as create_video_function,
|
|
2532
|
+
)
|
|
2533
|
+
|
|
2534
|
+
# Record start time
|
|
2535
|
+
self.start_time = time.time()
|
|
2536
|
+
|
|
2537
|
+
# Show immediate initial progress
|
|
2538
|
+
self.progress.emit(0)
|
|
2539
|
+
print("Video creation started - emitting initial progress: 0")
|
|
2540
|
+
|
|
2541
|
+
# Start a separate thread to update progress continuously
|
|
2542
|
+
progress_thread = threading.Thread(target=self.update_progress_continuously)
|
|
2543
|
+
progress_thread.daemon = True
|
|
2544
|
+
progress_thread.start()
|
|
2545
|
+
|
|
2546
|
+
# Configure progress callback that works with both phases
|
|
2547
|
+
def update_progress(current_frame, total_frames):
|
|
2548
|
+
if self.is_cancelled:
|
|
2549
|
+
return False
|
|
2550
|
+
|
|
2551
|
+
# DIRECT FIX: Set progress directly based on frame count
|
|
2552
|
+
progress_percent = min(99, int(100 * current_frame / total_frames))
|
|
2553
|
+
scaled_progress = (
|
|
2554
|
+
progress_percent * 10
|
|
2555
|
+
) # Scale to match our progress dialog range (0-1000)
|
|
2556
|
+
|
|
2557
|
+
# Add debugging output
|
|
2558
|
+
# if (
|
|
2559
|
+
# current_frame % 20 == 0 or current_frame == total_frames - 1
|
|
2560
|
+
# ): # Print every 20 frames or last frame
|
|
2561
|
+
# print(
|
|
2562
|
+
# f"Frame {current_frame}/{total_frames} - Progress: {progress_percent}% - Scaled: {scaled_progress}"
|
|
2563
|
+
# )
|
|
2564
|
+
|
|
2565
|
+
self.progress.emit(scaled_progress)
|
|
2566
|
+
|
|
2567
|
+
# Update frames processed count for reference
|
|
2568
|
+
self.frames_processed = current_frame + 1
|
|
2569
|
+
|
|
2570
|
+
# Let the progress thread handle the progress update
|
|
2571
|
+
return not self.progress_dialog.wasCanceled()
|
|
2572
|
+
|
|
2573
|
+
# Create the video
|
|
2574
|
+
self.status_update.emit("Creating video...")
|
|
2575
|
+
print(f"Starting video creation process with {self.cpu_count} cores")
|
|
2576
|
+
create_video_function(
|
|
2577
|
+
self.files,
|
|
2578
|
+
self.output_file,
|
|
2579
|
+
self.options,
|
|
2580
|
+
progress_callback=update_progress,
|
|
2581
|
+
)
|
|
2582
|
+
|
|
2583
|
+
# Set processing complete flag
|
|
2584
|
+
self.processing_complete = True
|
|
2585
|
+
print("Video creation complete - setting progress to 1000 (100%)")
|
|
2586
|
+
|
|
2587
|
+
# Small delay to ensure the progress thread sees the completed flag
|
|
2588
|
+
time.sleep(0.2)
|
|
2589
|
+
|
|
2590
|
+
# Ensure progress reaches 100% when complete
|
|
2591
|
+
self.progress.emit(
|
|
2592
|
+
1000
|
|
2593
|
+
) # Use 1000 instead of 100 to match our scale factor of 10
|
|
2594
|
+
|
|
2595
|
+
# Check if cancelled before emitting signal
|
|
2596
|
+
if not self.is_cancelled:
|
|
2597
|
+
# Emit finished signal
|
|
2598
|
+
print(f"Emitting finished signal with output file: {self.output_file}")
|
|
2599
|
+
self.finished.emit(self.output_file)
|
|
2600
|
+
else:
|
|
2601
|
+
print("Video creation was cancelled")
|
|
2602
|
+
|
|
2603
|
+
except Exception as e:
|
|
2604
|
+
print(f"Error in video creation: {str(e)}")
|
|
2605
|
+
# Set processing complete to stop the progress thread
|
|
2606
|
+
self.processing_complete = True
|
|
2607
|
+
if not self.is_cancelled:
|
|
2608
|
+
# Emit error signal
|
|
2609
|
+
self.error.emit(str(e))
|
|
2610
|
+
|
|
2611
|
+
def cancel(self):
|
|
2612
|
+
"""Cancel the worker thread"""
|
|
2613
|
+
self.is_cancelled = True
|
|
2614
|
+
self.processing_complete = (
|
|
2615
|
+
True # Also mark as complete to stop the progress thread
|
|
2616
|
+
)
|
|
2617
|
+
|
|
2618
|
+
# Update progress to 100%
|
|
2619
|
+
if hasattr(self, "progress_dialog") and self.progress_dialog:
|
|
2620
|
+
self.progress_dialog.setValue(
|
|
2621
|
+
1000
|
|
2622
|
+
) # Use 1000 instead of 100 to match our scale factor of 10
|
|
2623
|
+
|
|
2624
|
+
|
|
2625
|
+
if __name__ == "__main__":
|
|
2626
|
+
app = QApplication(sys.argv)
|
|
2627
|
+
dialog = VideoCreationDialog()
|
|
2628
|
+
dialog.show()
|
|
2629
|
+
sys.exit(app.exec_())
|