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.
Files changed (82) hide show
  1. solar_radio_image_viewer/__init__.py +12 -0
  2. solar_radio_image_viewer/assets/add_tab_default.png +0 -0
  3. solar_radio_image_viewer/assets/add_tab_default_light.png +0 -0
  4. solar_radio_image_viewer/assets/add_tab_hover.png +0 -0
  5. solar_radio_image_viewer/assets/add_tab_hover_light.png +0 -0
  6. solar_radio_image_viewer/assets/browse.png +0 -0
  7. solar_radio_image_viewer/assets/browse_light.png +0 -0
  8. solar_radio_image_viewer/assets/close_tab_default.png +0 -0
  9. solar_radio_image_viewer/assets/close_tab_default_light.png +0 -0
  10. solar_radio_image_viewer/assets/close_tab_hover.png +0 -0
  11. solar_radio_image_viewer/assets/close_tab_hover_light.png +0 -0
  12. solar_radio_image_viewer/assets/ellipse_selection.png +0 -0
  13. solar_radio_image_viewer/assets/ellipse_selection_light.png +0 -0
  14. solar_radio_image_viewer/assets/icons8-ellipse-90.png +0 -0
  15. solar_radio_image_viewer/assets/icons8-ellipse-90_light.png +0 -0
  16. solar_radio_image_viewer/assets/icons8-info-90.png +0 -0
  17. solar_radio_image_viewer/assets/icons8-info-90_light.png +0 -0
  18. solar_radio_image_viewer/assets/profile.png +0 -0
  19. solar_radio_image_viewer/assets/profile_light.png +0 -0
  20. solar_radio_image_viewer/assets/rectangle_selection.png +0 -0
  21. solar_radio_image_viewer/assets/rectangle_selection_light.png +0 -0
  22. solar_radio_image_viewer/assets/reset.png +0 -0
  23. solar_radio_image_viewer/assets/reset_light.png +0 -0
  24. solar_radio_image_viewer/assets/ruler.png +0 -0
  25. solar_radio_image_viewer/assets/ruler_light.png +0 -0
  26. solar_radio_image_viewer/assets/search.png +0 -0
  27. solar_radio_image_viewer/assets/search_light.png +0 -0
  28. solar_radio_image_viewer/assets/settings.png +0 -0
  29. solar_radio_image_viewer/assets/settings_light.png +0 -0
  30. solar_radio_image_viewer/assets/splash.fits +0 -0
  31. solar_radio_image_viewer/assets/zoom_60arcmin.png +0 -0
  32. solar_radio_image_viewer/assets/zoom_60arcmin_light.png +0 -0
  33. solar_radio_image_viewer/assets/zoom_in.png +0 -0
  34. solar_radio_image_viewer/assets/zoom_in_light.png +0 -0
  35. solar_radio_image_viewer/assets/zoom_out.png +0 -0
  36. solar_radio_image_viewer/assets/zoom_out_light.png +0 -0
  37. solar_radio_image_viewer/create_video.py +1345 -0
  38. solar_radio_image_viewer/dialogs.py +2665 -0
  39. solar_radio_image_viewer/from_simpl/__init__.py +184 -0
  40. solar_radio_image_viewer/from_simpl/caltable_visualizer.py +1001 -0
  41. solar_radio_image_viewer/from_simpl/dynamic_spectra_dialog.py +332 -0
  42. solar_radio_image_viewer/from_simpl/make_dynamic_spectra.py +351 -0
  43. solar_radio_image_viewer/from_simpl/pipeline_logger_gui.py +1232 -0
  44. solar_radio_image_viewer/from_simpl/simpl_theme.py +352 -0
  45. solar_radio_image_viewer/from_simpl/utils.py +984 -0
  46. solar_radio_image_viewer/from_simpl/view_dynamic_spectra_GUI.py +1975 -0
  47. solar_radio_image_viewer/helioprojective.py +1916 -0
  48. solar_radio_image_viewer/helioprojective_viewer.py +817 -0
  49. solar_radio_image_viewer/helioviewer_browser.py +1514 -0
  50. solar_radio_image_viewer/main.py +148 -0
  51. solar_radio_image_viewer/move_phasecenter.py +1269 -0
  52. solar_radio_image_viewer/napari_viewer.py +368 -0
  53. solar_radio_image_viewer/noaa_events/__init__.py +32 -0
  54. solar_radio_image_viewer/noaa_events/noaa_events.py +430 -0
  55. solar_radio_image_viewer/noaa_events/noaa_events_gui.py +1922 -0
  56. solar_radio_image_viewer/norms.py +293 -0
  57. solar_radio_image_viewer/radio_data_downloader/__init__.py +25 -0
  58. solar_radio_image_viewer/radio_data_downloader/radio_data_downloader.py +756 -0
  59. solar_radio_image_viewer/radio_data_downloader/radio_data_downloader_gui.py +528 -0
  60. solar_radio_image_viewer/searchable_combobox.py +220 -0
  61. solar_radio_image_viewer/solar_context/__init__.py +41 -0
  62. solar_radio_image_viewer/solar_context/active_regions.py +371 -0
  63. solar_radio_image_viewer/solar_context/cme_alerts.py +234 -0
  64. solar_radio_image_viewer/solar_context/context_images.py +297 -0
  65. solar_radio_image_viewer/solar_context/realtime_data.py +528 -0
  66. solar_radio_image_viewer/solar_data_downloader/__init__.py +35 -0
  67. solar_radio_image_viewer/solar_data_downloader/solar_data_downloader.py +1667 -0
  68. solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_cli.py +901 -0
  69. solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_gui.py +1210 -0
  70. solar_radio_image_viewer/styles.py +643 -0
  71. solar_radio_image_viewer/utils/__init__.py +32 -0
  72. solar_radio_image_viewer/utils/rate_limiter.py +255 -0
  73. solar_radio_image_viewer/utils.py +952 -0
  74. solar_radio_image_viewer/video_dialog.py +2629 -0
  75. solar_radio_image_viewer/video_utils.py +656 -0
  76. solar_radio_image_viewer/viewer.py +11174 -0
  77. solarviewer-1.0.2.dist-info/METADATA +343 -0
  78. solarviewer-1.0.2.dist-info/RECORD +82 -0
  79. solarviewer-1.0.2.dist-info/WHEEL +5 -0
  80. solarviewer-1.0.2.dist-info/entry_points.txt +8 -0
  81. solarviewer-1.0.2.dist-info/licenses/LICENSE +21 -0
  82. 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_())