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,1975 @@
1
+ """
2
+ Dynamic Spectrum Viewer & RFI Cleaning Tool
3
+ =============================================
4
+
5
+ Description:
6
+ This graphical user interface (GUI) application is designed for viewing,
7
+ cleaning, and processing dynamic spectra generated by the pipeline or
8
+ 'make_dynamic_spectra.py'. Specially tested with LOFAR data, the tool
9
+ provides users with the ability to:
10
+ 1. View the dynamic spectrum.
11
+ 2. Flag and mask regions within the dynamic spectrum.
12
+ 3. Perform bandpass normalization.
13
+ 4. Extract active solar radio emissions from the dynamic spectrum.
14
+ 5. Save the cleaned dynamic spectrum as a FITS file.
15
+
16
+ Features:
17
+ - Interactive visualization using Matplotlib integrated with PyQt5.
18
+ - ROI (Region of Interest) selection for RFI (Radio Frequency Interference) flagging.
19
+ - Cross-section mode for extracting and analyzing time and frequency slices.
20
+ - Auto-scaling and adjustable visualization parameters.
21
+ - Undo/Redo support for modification steps.
22
+ - Option to view FITS file metadata via a menu command.
23
+
24
+ Dependencies:
25
+ - Python 3.x
26
+ - NumPy, SciPy, OpenCV (cv2)
27
+ - Astropy
28
+ - Matplotlib
29
+ - PyQt5
30
+
31
+ Tested Environment:
32
+ - This tool has been specifically tested with LOFAR data.
33
+
34
+ Usage:
35
+ - Launch the application to view dynamic spectra.
36
+ - Use the provided controls to mask unwanted regions, normalize the bandpass,
37
+ and extract specific source features.
38
+ - Use Undo (Ctrl+Z) and Redo (Ctrl+Y) to revert modifications.
39
+ - Select "View Metadata" from the menu to see the FITS file header.
40
+ - Save the processed dynamic spectrum as a FITS file for further analysis.
41
+
42
+ Authors:
43
+ Soham Dey, Deepan Patra
44
+
45
+ Version:
46
+ 1.0
47
+
48
+ Date:
49
+ 6th February 2025
50
+
51
+ Notes:
52
+ - Ensure that all required dependencies are installed.
53
+ - For any queries or issues, please refer to the documentation.
54
+ """
55
+
56
+ import sys
57
+ import os
58
+ import numpy as np
59
+ import numpy.ma as ma
60
+ import cv2
61
+
62
+ from astropy.io import fits
63
+ from astropy.time import Time
64
+
65
+ from PyQt5.QtWidgets import (
66
+ QMainWindow, QApplication, QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
67
+ QAction, QFileDialog, QMessageBox, QStatusBar, QMenuBar,
68
+ QLabel, QSlider, QComboBox, QFormLayout, QPushButton,
69
+ QDockWidget, QDoubleSpinBox, QSpinBox, QCheckBox, QInputDialog, QGroupBox,
70
+ QDialog, QTextEdit, QVBoxLayout as QVBoxLayoutDialog, QScrollArea
71
+ )
72
+ from PyQt5 import QtWidgets
73
+ from PyQt5.QtCore import Qt, pyqtSlot
74
+
75
+ import matplotlib
76
+ import matplotlib.pyplot as plt
77
+ from matplotlib.colors import LogNorm, Normalize, PowerNorm
78
+ from matplotlib.backends.backend_qt5agg import (
79
+ FigureCanvasQTAgg as FigureCanvas,
80
+ NavigationToolbar2QT as NavigationToolbar
81
+ )
82
+ from matplotlib.widgets import RectangleSelector
83
+ from matplotlib.dates import date2num, DateFormatter
84
+
85
+
86
+ ###############################################################################
87
+ # RFI CLEANING UTILITIES #
88
+ ###############################################################################
89
+
90
+ def create_binary(data, thresh):
91
+ """Create a binary DS: 0 where data < thresh, 1 where data >= thresh."""
92
+ return np.where(data < thresh, 0.0, 1.0)
93
+
94
+ def region_detection(original_image, binary_image, min_width=1, min_height=5):
95
+ """
96
+ Finds closed contours in the binary image.
97
+ Returns (closed_image, valid_contours, overlay_image).
98
+ """
99
+ binary_image_uint8 = np.uint8(binary_image * 255)
100
+ kernel = np.ones((2, 2), np.uint8)
101
+ closed_image = cv2.morphologyEx(binary_image_uint8, cv2.MORPH_CLOSE, kernel)
102
+ contours, _ = cv2.findContours(closed_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
103
+
104
+ if len(original_image.shape) == 2:
105
+ overlay_image = cv2.cvtColor(original_image.astype(np.float32), cv2.COLOR_GRAY2BGR)
106
+ else:
107
+ overlay_image = original_image.copy()
108
+
109
+ valid_contours = []
110
+ for contour in contours:
111
+ x, y, w, h = cv2.boundingRect(contour)
112
+ if w >= min_width and h >= min_height:
113
+ valid_contours.append(contour)
114
+ cv2.rectangle(overlay_image, (x, y), (x + w, y + h), (255, 0, 0), 3)
115
+ return closed_image, valid_contours, overlay_image
116
+
117
+ def create_mask(original_binary, contours):
118
+ """
119
+ Create a mask with detected contours filled.
120
+ """
121
+ mask = np.zeros(original_binary.shape, dtype=np.uint8)
122
+ for contour in contours:
123
+ cv2.drawContours(mask, [contour], -1, 255, thickness=cv2.FILLED)
124
+ return mask
125
+
126
+ def subtract_contours(original_image, mask):
127
+ """
128
+ Zero out the flagged (RFI) region in original_image.
129
+ """
130
+ result_image = original_image.copy()
131
+ result_image[mask > 0] = 0
132
+ return result_image
133
+
134
+
135
+ ###############################################################################
136
+ # MATPLOTLIB CANVAS FOR DYNAMIC SPECTRUM DISPLAY #
137
+ ###############################################################################
138
+
139
+ class DynamicSpectrumCanvas(FigureCanvas):
140
+ """
141
+ A Matplotlib canvas for displaying a dynamic spectrum (time vs freq).
142
+ """
143
+ def __init__(self, parent=None):
144
+ # Setup logging
145
+ import logging
146
+ self.logger = logging.getLogger("DynamicSpectrumCanvas")
147
+ self.logger.setLevel(logging.DEBUG)
148
+
149
+ # Apply initial theme settings to matplotlib
150
+ self._apply_initial_theme()
151
+
152
+ # Create figure with better DPI and constrained layout for better auto-resizing
153
+ self.fig = plt.figure(figsize=(9, 6), dpi=100, constrained_layout=True)
154
+ self.ax = self.fig.add_subplot(111)
155
+ super().__init__(self.fig)
156
+ self.setParent(parent)
157
+
158
+ # Set minimum size to avoid layout issues
159
+ self.setMinimumSize(400, 300)
160
+
161
+ self.ax.set_title("Dynamic Spectrum Viewer", fontsize=14)
162
+ self.ax.set_xlabel("Time (UTC)", fontsize=12)
163
+ self.ax.set_ylabel("Frequency (MHz)", fontsize=12)
164
+
165
+ # Apply theme to the newly created canvas
166
+ self._apply_canvas_theme_colors()
167
+
168
+ # Internal references
169
+ self._data = None # Float array with NaNs
170
+ self._time_axis = None
171
+ self._freq_axis = None
172
+ self._extent = None
173
+
174
+ self._colorbar = None
175
+ self._im = None
176
+
177
+ # Visualization parameters
178
+ self._scale_mode = "Linear" # Options: "Linear", "Log", "Sqrt", "Gamma"
179
+ self._gamma = 1.0
180
+ self._vmin = None
181
+ self._vmax = None
182
+ self._cmap = 'inferno'
183
+ self._smart_scale = "0.5-99.5%" # Options: "0-100%", "0.1-99.9%", "0.5-99.5%", "1-99%"
184
+
185
+ # Interactive ROI
186
+ self.rect_selector = None
187
+ self.roi_active = False
188
+ self.roi_callback = None
189
+
190
+ # Cross-section mode
191
+ self.cross_section_active = False
192
+
193
+ # Mouse hover info control
194
+ self.hover_info_enabled = False # Default disabled
195
+ self._last_hover_text = "" # Cache last hover text to avoid unnecessary redraws
196
+ self._hover_throttle_counter = 0 # Throttle hover updates
197
+ self._hover_throttle_limit = 3 # Update every N mouse events
198
+
199
+ # Store event connection IDs for reconnection
200
+ self._event_connections = {}
201
+ self._connect_events()
202
+
203
+ # Mouse readout: text annotation on the axes for amplitude values (default to dark theme)
204
+ self._hover_text = self.ax.text(
205
+ 1.0, 1.05, "",
206
+ transform=self.ax.transAxes, ha="right", va="top",
207
+ fontsize=10, color="white",
208
+ bbox=dict(facecolor="black", alpha=0.8)
209
+ )
210
+
211
+ self.fig.tight_layout()
212
+
213
+ def _apply_initial_theme(self):
214
+ """Apply dark theme as default for matplotlib."""
215
+ import matplotlib.pyplot as plt
216
+ # Set dark theme as default
217
+ plt.rcParams.update({
218
+ 'figure.facecolor': '#2b2b2b',
219
+ 'axes.facecolor': '#2b2b2b',
220
+ 'axes.edgecolor': 'white',
221
+ 'axes.labelcolor': 'white',
222
+ 'xtick.color': 'white',
223
+ 'ytick.color': 'white',
224
+ 'text.color': 'white',
225
+ 'axes.grid': True,
226
+ 'grid.color': '#505050',
227
+ 'grid.alpha': 0.3
228
+ })
229
+
230
+ def _apply_canvas_theme_colors(self):
231
+ """Apply theme colors to the canvas elements."""
232
+ # Get parent window to access theme information
233
+ parent = self.parent()
234
+ while parent is not None and not hasattr(parent, 'current_theme'):
235
+ parent = parent.parent()
236
+
237
+ # Default to dark theme if no parent found
238
+ if parent is None or not hasattr(parent, 'themes'):
239
+ theme = {
240
+ 'plot_bg': '#2b2b2b',
241
+ 'plot_text': 'white',
242
+ 'plot_grid': '#505050'
243
+ }
244
+ else:
245
+ theme = parent.themes[parent.current_theme]
246
+
247
+ # Set figure and axes background
248
+ self.fig.patch.set_facecolor(theme['plot_bg'])
249
+ self.ax.set_facecolor(theme['plot_bg'])
250
+
251
+ # Set text colors
252
+ self.ax.tick_params(colors=theme['plot_text'])
253
+ self.ax.xaxis.label.set_color(theme['plot_text'])
254
+ self.ax.yaxis.label.set_color(theme['plot_text'])
255
+ self.ax.title.set_color(theme['plot_text'])
256
+
257
+ # Set border colors
258
+ for spine in self.ax.spines.values():
259
+ spine.set_edgecolor(theme['plot_text'])
260
+
261
+ # Enable grid with theme colors
262
+ self.ax.grid(True, color=theme['plot_grid'], alpha=0.3)
263
+
264
+ # Update hover text colors
265
+ if hasattr(self, '_hover_text'):
266
+ if parent and parent.current_theme == "dark":
267
+ self._hover_text.set_color("white")
268
+ self._hover_text.set_bbox(dict(facecolor="black", alpha=0.8))
269
+ else:
270
+ self._hover_text.set_color("black")
271
+ self._hover_text.set_bbox(dict(facecolor="white", alpha=0.8))
272
+
273
+ def _connect_events(self):
274
+ """Connect all necessary event handlers"""
275
+ # Disconnect existing connections if they exist
276
+ self._disconnect_events()
277
+
278
+ # Connect mouse events
279
+ self._event_connections['button_press'] = self.mpl_connect("button_press_event", self.on_mouse_click)
280
+ self._event_connections['motion_notify'] = self.mpl_connect("motion_notify_event", self.on_mouse_move)
281
+
282
+ # If ROI selector is active, reconnect it
283
+ if self.roi_active and self.roi_callback:
284
+ self.enable_roi_selector(True, self.roi_callback)
285
+
286
+ def _disconnect_events(self):
287
+ """Disconnect all matplotlib event connections"""
288
+ for event_id in self._event_connections.values():
289
+ try:
290
+ self.mpl_disconnect(event_id)
291
+ except:
292
+ pass
293
+ self._event_connections = {}
294
+
295
+ def clear_plot(self):
296
+ """Clear the axes and remove the colorbar."""
297
+ # Store current states before clearing
298
+ roi_was_active = self.roi_active
299
+ roi_callback = self.roi_callback
300
+ cross_was_active = self.cross_section_active
301
+
302
+ # Turn off rectangle selector before clearing
303
+ if self.rect_selector is not None:
304
+ try:
305
+ self.rect_selector.set_active(False)
306
+ self.rect_selector.set_visible(False)
307
+ # For Matplotlib 3.x, disconnect events
308
+ if hasattr(self.rect_selector, 'disconnect_events'):
309
+ self.rect_selector.disconnect_events()
310
+ # Invalidate the selector
311
+ self.rect_selector = None
312
+ except Exception as e:
313
+ print(f"Error disabling selector: {e}")
314
+
315
+ # Disconnect existing events before clearing
316
+ self._disconnect_events()
317
+
318
+ # Clear the axis
319
+ self.ax.clear()
320
+
321
+ # Reset basic axis properties
322
+ self.ax.set_title(" ", fontsize=14)
323
+ self.ax.set_xlabel("Time (UTC)", fontsize=12)
324
+ self.ax.set_ylabel("Frequency (MHz)", fontsize=12)
325
+
326
+ # Handle colorbar removal
327
+ if self._colorbar is not None:
328
+ try:
329
+ self._colorbar.remove()
330
+ except (AttributeError, ValueError):
331
+ # Handle case where colorbar removal fails - recreate the figure
332
+ self.fig.clf()
333
+ self.ax = self.fig.add_subplot(111)
334
+
335
+ # Reset basic axis properties
336
+ self.ax.set_title(" ", fontsize=14)
337
+ self.ax.set_xlabel("Time (UTC)", fontsize=12)
338
+ self.ax.set_ylabel("Frequency (MHz)", fontsize=12)
339
+ self._colorbar = None
340
+
341
+ self._im = None
342
+ self._extent = None
343
+
344
+ # Recreate hover text after clearing with theme-aware colors
345
+ parent = self.parent()
346
+ while parent is not None and not hasattr(parent, 'current_theme'):
347
+ parent = parent.parent()
348
+
349
+ # Set colors based on current theme
350
+ if parent and hasattr(parent, 'current_theme') and parent.current_theme == "light":
351
+ text_color = "black"
352
+ bg_color = "white"
353
+ else:
354
+ text_color = "white"
355
+ bg_color = "black"
356
+
357
+ self._hover_text = self.ax.text(
358
+ 1.0, 1.05, "",
359
+ transform=self.ax.transAxes, ha="right", va="top",
360
+ fontsize=10, color=text_color,
361
+ bbox=dict(facecolor=bg_color, alpha=0.8)
362
+ )
363
+
364
+ # Reconnect event handlers
365
+ self._connect_events()
366
+
367
+ # Restore interactive features if they were active
368
+ if roi_was_active and roi_callback:
369
+ self.enable_roi_selector(True, roi_callback)
370
+
371
+ self.cross_section_active = cross_was_active
372
+
373
+ def set_data(self, data, time_axis=None, freq_axis=None):
374
+ """Set data and optional time and frequency axes."""
375
+ self._data = data
376
+ self._time_axis = time_axis
377
+ self._freq_axis = freq_axis
378
+
379
+ def draw_spectrum(self):
380
+ """Plot the dynamic spectrum using the current visualization parameters."""
381
+ self.clear_plot()
382
+ if self._data is None:
383
+ self.draw()
384
+ return
385
+
386
+ # Reset axis limits before plotting
387
+ self.ax.set_xlim(auto=True)
388
+ self.ax.set_ylim(auto=True)
389
+
390
+ data_ma = ma.masked_invalid(self._data)
391
+ data_flat = data_ma.compressed()
392
+ if len(data_flat) == 0:
393
+ self._vmin, self._vmax = 0, 1
394
+ else:
395
+ if self._smart_scale == "0-100%":
396
+ self._vmin, self._vmax = data_flat.min(), data_flat.max()
397
+ elif self._smart_scale == "0.1-99.9%":
398
+ p01 = np.percentile(data_flat, 0.1)
399
+ p999 = np.percentile(data_flat, 99.9)
400
+ self._vmin, self._vmax = p01, p999
401
+ elif self._smart_scale == "0.5-99.5%":
402
+ p05 = np.percentile(data_flat, 0.5)
403
+ p995 = np.percentile(data_flat, 99.5)
404
+ self._vmin, self._vmax = p05, p995
405
+ elif self._smart_scale == "1-99%":
406
+ p1 = np.percentile(data_flat, 1)
407
+ p99 = np.percentile(data_flat, 99)
408
+ self._vmin, self._vmax = p1, p99
409
+
410
+
411
+ if self._vmin == self._vmax:
412
+ self._vmin -= 1e-9
413
+ self._vmax += 1e-9
414
+
415
+ if self._scale_mode == "Log":
416
+ norm = LogNorm(vmin=max(self._vmin, 1e-12), vmax=self._vmax)
417
+ elif self._scale_mode == "Sqrt":
418
+ norm = PowerNorm(gamma=0.5, vmin=self._vmin, vmax=self._vmax)
419
+ elif self._scale_mode == "Gamma":
420
+ norm = PowerNorm(gamma=self._gamma, vmin=self._vmin, vmax=self._vmax)
421
+ else:
422
+ norm = Normalize(vmin=self._vmin, vmax=self._vmax)
423
+
424
+ if (self._time_axis is None) or (self._freq_axis is None):
425
+ self._im = self.ax.imshow(data_ma.T, aspect='auto', origin='lower',
426
+ cmap=self._cmap, norm=norm)
427
+ self.ax.set_xlabel("Time index")
428
+ self.ax.set_ylabel("Frequency index")
429
+ else:
430
+ time_mjd = self._time_axis / 86400.0
431
+ utc_dt = Time(time_mjd, format='mjd', scale='utc').to_datetime()
432
+ utc_num = np.array([date2num(dt) for dt in utc_dt])
433
+ self._extent = [utc_num[0], utc_num[-1], self._freq_axis[0], self._freq_axis[-1]]
434
+ self._im = self.ax.imshow(data_ma.T, aspect='auto', origin='lower',
435
+ extent=self._extent, cmap=self._cmap, norm=norm)
436
+ date_formatter = DateFormatter('%Y-%m-%d\n%H:%M:%S')
437
+ self.ax.xaxis.set_major_formatter(date_formatter)
438
+ self.fig.autofmt_xdate()
439
+ self.ax.set_xlabel("Time (UTC)")
440
+ self.ax.set_ylabel("Frequency (MHz)")
441
+
442
+ self._colorbar = self.fig.colorbar(self._im, ax=self.ax, label="Amplitude")
443
+
444
+ # Apply theme colors to the newly created colorbar
445
+ self._apply_colorbar_theme()
446
+
447
+ # Ensure the figure layout is updated
448
+ self.fig.tight_layout()
449
+ self.draw()
450
+
451
+ def _apply_colorbar_theme(self):
452
+ """Apply theme colors to the colorbar."""
453
+ if self._colorbar is None:
454
+ return
455
+
456
+ # Get parent window to access theme information
457
+ parent = self.parent()
458
+ while parent is not None and not hasattr(parent, 'current_theme'):
459
+ parent = parent.parent()
460
+
461
+ if parent is None or not hasattr(parent, 'themes'):
462
+ return
463
+
464
+ theme = parent.themes[parent.current_theme]
465
+
466
+ # Update colorbar text colors
467
+ self._colorbar.ax.tick_params(colors=theme['plot_text'])
468
+ self._colorbar.ax.yaxis.label.set_color(theme['plot_text'])
469
+
470
+ # Update colorbar outline
471
+ for spine in self._colorbar.ax.spines.values():
472
+ spine.set_edgecolor(theme['plot_text'])
473
+
474
+ def set_scale_mode(self, mode):
475
+ self._scale_mode = mode
476
+ self.draw_spectrum()
477
+
478
+ def set_gamma(self, gamma):
479
+ self._gamma = gamma
480
+ if self._scale_mode == "Gamma":
481
+ self.draw_spectrum()
482
+
483
+ def set_cmap(self, cmap_name):
484
+ self._cmap = cmap_name
485
+ self.draw_spectrum()
486
+
487
+ def set_smart_scale(self, scale_option):
488
+ """Set the percentile scale option for auto scaling."""
489
+ self._smart_scale = scale_option
490
+ self.draw_spectrum()
491
+
492
+ def get_normalization(self, vmin, vmax):
493
+ if self._scale_mode == "Log":
494
+ return LogNorm(vmin=max(vmin, 1e-12), vmax=vmax)
495
+ elif self._scale_mode == "Sqrt":
496
+ return PowerNorm(gamma=0.5, vmin=vmin, vmax=vmax)
497
+ elif self._scale_mode == "Gamma":
498
+ return PowerNorm(gamma=self._gamma, vmin=vmin, vmax=vmax)
499
+ else:
500
+ return Normalize(vmin=vmin, vmax=vmax)
501
+
502
+ # -------------------------- ROI Selector ------------------------------------
503
+ def enable_roi_selector(self, enable, callback=None):
504
+ """
505
+ Enable or disable the ROI selection mode.
506
+ When enabled, allows the user to draw a rectangle to select a region of interest.
507
+ """
508
+ self.roi_active = enable
509
+ self.roi_callback = callback
510
+
511
+ if enable:
512
+ # Disconnect any existing rectangle selector
513
+ if self.rect_selector is not None:
514
+ try:
515
+ if hasattr(self.rect_selector, 'disconnect_events'):
516
+ self.rect_selector.disconnect_events()
517
+ self.rect_selector = None
518
+ except:
519
+ pass
520
+
521
+ # Clear any existing rectangles
522
+ self._clear_all_rects()
523
+
524
+ # Use direct event handling rather than RectangleSelector
525
+ self.logger.info("Setting up direct ROI event handlers")
526
+
527
+ # Set up our own event handlers for more direct control
528
+ self._roi_start_point = None
529
+ self._roi_current_rect = None
530
+
531
+ # Connect direct mouse events for ROI selection
532
+ if 'roi_press' in self._event_connections:
533
+ self.mpl_disconnect(self._event_connections['roi_press'])
534
+ if 'roi_release' in self._event_connections:
535
+ self.mpl_disconnect(self._event_connections['roi_release'])
536
+ if 'roi_motion' in self._event_connections:
537
+ self.mpl_disconnect(self._event_connections['roi_motion'])
538
+
539
+ self._event_connections['roi_press'] = self.mpl_connect('button_press_event', self._roi_on_press)
540
+ self._event_connections['roi_release'] = self.mpl_connect('button_release_event', self._roi_on_release)
541
+ self._event_connections['roi_motion'] = self.mpl_connect('motion_notify_event', self._roi_on_motion)
542
+
543
+ # Add a highly visible message to indicate ROI selection is active
544
+ self._hover_text.set_text("ROI selection active: Click and drag to select a region")
545
+ self._hover_text.set_bbox(dict(facecolor='orange', alpha=0.9, boxstyle='round'))
546
+ self._hover_text.set_color('black')
547
+ self._hover_text.set_fontsize(12)
548
+
549
+ self.draw_idle()
550
+ else:
551
+ # Disable all ROI events
552
+ for event_name in ['roi_press', 'roi_release', 'roi_motion']:
553
+ if event_name in self._event_connections:
554
+ try:
555
+ self.mpl_disconnect(self._event_connections[event_name])
556
+ del self._event_connections[event_name]
557
+ except:
558
+ pass
559
+
560
+ # Clean up any remaining rectangles
561
+ self._clear_all_rects()
562
+
563
+ # Reset hover text with theme-aware colors
564
+ parent = self.parent()
565
+ while parent is not None and not hasattr(parent, 'current_theme'):
566
+ parent = parent.parent()
567
+
568
+ if parent and hasattr(parent, 'current_theme') and parent.current_theme == "light":
569
+ text_color = "black"
570
+ bg_color = "white"
571
+ else:
572
+ text_color = "white"
573
+ bg_color = "black"
574
+
575
+ self._hover_text.set_text("")
576
+ self._hover_text.set_bbox(dict(facecolor=bg_color, alpha=0.8))
577
+ self._hover_text.set_color(text_color)
578
+ self._hover_text.set_fontsize(10)
579
+
580
+ self._roi_start_point = None
581
+ self._roi_current_rect = None
582
+
583
+ self.draw_idle()
584
+
585
+ def _clear_all_rects(self):
586
+ """Clear all rectangle patches from the axes."""
587
+ try:
588
+ # Remove all rectangle patches that are not the axes background
589
+ for patch in self.ax.patches[:]:
590
+ if isinstance(patch, matplotlib.patches.Rectangle):
591
+ if patch != self.ax.patch: # Don't remove the axes background
592
+ patch.remove()
593
+ self.draw_idle()
594
+ except Exception as e:
595
+ self.logger.error(f"Error clearing rectangles: {e}")
596
+
597
+ def _roi_on_press(self, event):
598
+ """Handle mouse button press for ROI selection."""
599
+ if not self.roi_active or event.inaxes != self.ax or event.button != 1:
600
+ return
601
+
602
+ # Clear any existing rectangles
603
+ self._clear_all_rects()
604
+
605
+ # Store the starting point
606
+ self._roi_start_point = (event.xdata, event.ydata)
607
+
608
+ # Update status message
609
+ self._hover_text.set_text("Dragging: release to confirm selection")
610
+ self.draw_idle()
611
+
612
+ def _roi_on_motion(self, event):
613
+ """Handle mouse motion for ROI selection."""
614
+ if not self.roi_active or self._roi_start_point is None or event.inaxes != self.ax:
615
+ return
616
+
617
+ # Remove the previous rectangle if it exists
618
+ if self._roi_current_rect is not None:
619
+ self._roi_current_rect.remove()
620
+ self._roi_current_rect = None
621
+
622
+ # Create a new rectangle from start point to current position
623
+ start_x, start_y = self._roi_start_point
624
+ current_x, current_y = event.xdata, event.ydata
625
+
626
+ # Don't draw if we're outside the axes
627
+ if None in (start_x, start_y, current_x, current_y):
628
+ return
629
+
630
+ # Calculate rectangle parameters
631
+ x = min(start_x, current_x)
632
+ y = min(start_y, current_y)
633
+ width = abs(current_x - start_x)
634
+ height = abs(current_y - start_y)
635
+
636
+ # Create a very visible rectangle
637
+ from matplotlib.patches import Rectangle
638
+ self._roi_current_rect = Rectangle(
639
+ (x, y), width, height,
640
+ facecolor='red', edgecolor='yellow',
641
+ alpha=0.4, fill=True, linewidth=3.0,
642
+ zorder=1000 # Ensure it's on top of everything
643
+ )
644
+
645
+ # Add the rectangle to the plot
646
+ self.ax.add_patch(self._roi_current_rect)
647
+
648
+ # Draw immediately
649
+ self.draw_idle()
650
+
651
+ def _roi_on_release(self, event):
652
+ """Handle mouse button release for ROI selection."""
653
+ if not self.roi_active or self._roi_start_point is None or event.inaxes != self.ax or event.button != 1:
654
+ return
655
+
656
+ # Get the final coordinates
657
+ start_x, start_y = self._roi_start_point
658
+ end_x, end_y = event.xdata, event.ydata
659
+
660
+ # Don't process if we're outside the axes
661
+ if None in (start_x, start_y, end_x, end_y):
662
+ self._roi_start_point = None
663
+ if self._roi_current_rect is not None:
664
+ self._roi_current_rect.remove()
665
+ self._roi_current_rect = None
666
+ self.draw_idle()
667
+ return
668
+
669
+ # Process the selection
670
+ self.logger.info(f"ROI selection: ({start_x}, {start_y}) to ({end_x}, {end_y})")
671
+
672
+ # Keep the rectangle visible for feedback
673
+ if self._roi_current_rect is not None:
674
+ # Make it slightly transparent to show it's being processed
675
+ self._roi_current_rect.set_alpha(0.6)
676
+ self._roi_current_rect.set_edgecolor('lime') # Change color to indicate processing
677
+ self.draw_idle()
678
+
679
+ # Calculate indices from data coordinates
680
+ try:
681
+ nt, nf = self._data.shape
682
+ if self._extent is not None:
683
+ x0, x1_, y0, y1_ = self._extent
684
+
685
+ # Get selection bounds
686
+ xmin, xmax = sorted([start_x, end_x])
687
+ ymin, ymax = sorted([start_y, end_y])
688
+
689
+ # Check for division by zero
690
+ if x1_ == x0 or y1_ == y0:
691
+ self.logger.warning("Invalid extent: division by zero")
692
+ return
693
+
694
+ frac_xmin = (xmin - x0) / (x1_ - x0)
695
+ frac_xmax = (xmax - x0) / (x1_ - x0)
696
+ frac_ymin = (ymin - y0) / (y1_ - y0)
697
+ frac_ymax = (ymax - y0) / (y1_ - y0)
698
+
699
+ # Check for values outside valid range
700
+ if (frac_xmin < 0 and frac_xmax < 0) or (frac_xmin > 1 and frac_xmax > 1) or \
701
+ (frac_ymin < 0 and frac_ymax < 0) or (frac_ymin > 1 and frac_ymax > 1):
702
+ self.logger.warning("Selection outside valid range")
703
+ return
704
+
705
+ ixmin = int(max(0, min(1, frac_xmin)) * (nt - 1))
706
+ ixmax = int(max(0, min(1, frac_xmax)) * (nt - 1))
707
+ iymin = int(max(0, min(1, frac_ymin)) * (nf - 1))
708
+ iymax = int(max(0, min(1, frac_ymax)) * (nf - 1))
709
+ else:
710
+ # Direct pixel coordinates
711
+ xmin, xmax = sorted([int(start_x), int(end_x)])
712
+ ymin, ymax = sorted([int(start_y), int(end_y)])
713
+ ixmin, ixmax = xmin, xmax
714
+ iymin, iymax = ymin, ymax
715
+
716
+ # Ensure indices are within valid range
717
+ ixmin = max(0, min(ixmin, nt - 1))
718
+ ixmax = max(0, min(ixmax, nt - 1))
719
+ iymin = max(0, min(iymin, nf - 1))
720
+ iymax = max(0, min(iymax, nf - 1))
721
+
722
+ # Call the callback function if defined
723
+ if self.roi_callback and ixmin <= ixmax and iymin <= iymax:
724
+ self.roi_callback(ixmin, ixmax, iymin, iymax)
725
+ except Exception as e:
726
+ self.logger.error(f"Error processing ROI selection: {e}")
727
+
728
+ # Clean up for next selection
729
+ self._roi_start_point = None
730
+ if self._roi_current_rect is not None:
731
+ self._roi_current_rect.remove()
732
+ self._roi_current_rect = None
733
+
734
+ # Reset hover text
735
+ self._hover_text.set_text("ROI selection active: Click and drag to select a region")
736
+ self.draw_idle()
737
+
738
+ # --------------------- Cross-Section Mode -----------------------------------
739
+ def enable_cross_section(self, enable):
740
+ self.cross_section_active = enable
741
+
742
+ def on_mouse_click(self, event):
743
+ if not self.cross_section_active or self._data is None:
744
+ return
745
+ if event.inaxes != self.ax or event.button != 1:
746
+ return
747
+
748
+ try:
749
+ nt, nf = self._data.shape
750
+ if self._extent is not None:
751
+ x0, x1, y0, y1 = self._extent
752
+ # Ensure valid division
753
+ if x1 == x0 or y1 == y0:
754
+ return
755
+
756
+ frac_x = (event.xdata - x0) / (x1 - x0)
757
+ frac_y = (event.ydata - y0) / (y1 - y0)
758
+
759
+ # Check if click is within valid data range
760
+ if not (0 <= frac_x <= 1) or not (0 <= frac_y <= 1):
761
+ return
762
+
763
+ time_idx = int(frac_x * (nt - 1))
764
+ freq_idx = int(frac_y * (nf - 1))
765
+ else:
766
+ # Handle integer indices case
767
+ if event.xdata is None or event.ydata is None:
768
+ return
769
+ time_idx = int(event.xdata)
770
+ freq_idx = int(event.ydata)
771
+
772
+ # Ensure indices are within valid range
773
+ time_idx = max(0, min(time_idx, nt - 1))
774
+ freq_idx = max(0, min(freq_idx, nf - 1))
775
+
776
+ options = [
777
+ f"Time slice at freq_idx = {freq_idx}",
778
+ f"Freq slice at time_idx = {time_idx}"
779
+ ]
780
+ choice, ok = QInputDialog.getItem(
781
+ None, "Cross Section", "Which slice to plot?", options, 0, False
782
+ )
783
+ if not ok:
784
+ return
785
+
786
+ if choice.startswith("Time slice"):
787
+ data_slice = self._data[:, freq_idx]
788
+ self._plot_1d_time(data_slice, freq_idx)
789
+ else:
790
+ data_slice = self._data[time_idx, :]
791
+ self._plot_1d_freq(data_slice, time_idx)
792
+ except (ValueError, TypeError, IndexError) as e:
793
+ # Handle any errors in coordinate conversion
794
+ QMessageBox.warning(None, "Cross Section Error",
795
+ f"Could not extract cross-section: {str(e)}")
796
+
797
+ def _plot_1d_time(self, data_slice, freq_idx):
798
+ nt = data_slice.shape[0]
799
+
800
+ # Get theme colors from parent window
801
+ parent = self.parent()
802
+ while parent is not None and not hasattr(parent, 'current_theme'):
803
+ parent = parent.parent()
804
+
805
+ # Determine colors based on theme
806
+ if parent and hasattr(parent, 'themes') and hasattr(parent, 'current_theme'):
807
+ theme = parent.themes[parent.current_theme]
808
+ plot_bg = theme['plot_bg']
809
+ plot_text = theme['plot_text']
810
+ line_color = plot_text # Use theme text color for line
811
+ else:
812
+ # Default to dark theme
813
+ plot_bg = '#2b2b2b'
814
+ plot_text = 'white'
815
+ line_color = 'white'
816
+
817
+ fig, ax = plt.subplots(facecolor=plot_bg)
818
+ ax.set_facecolor(plot_bg)
819
+ yvals = np.ma.masked_invalid(data_slice)
820
+
821
+ if self._time_axis is not None:
822
+ time_mjd = self._time_axis / 86400.0
823
+ utc_dt = Time(time_mjd, format='mjd', scale='utc').to_datetime()
824
+ xvals = np.array([date2num(dt) for dt in utc_dt])
825
+ if np.any(np.diff(xvals) <= 0):
826
+ xvals = np.linspace(xvals[0], xvals[-1], nt)
827
+ ax.plot_date(xvals, yvals, '-', lw=1.5, color=line_color)
828
+ date_formatter = DateFormatter('%H:%M:%S')
829
+ ax.xaxis.set_major_formatter(date_formatter)
830
+ fig.autofmt_xdate()
831
+ xlabel = "Time (UTC)"
832
+ else:
833
+ xvals = np.linspace(0, nt - 1, nt)
834
+ ax.plot(xvals, yvals, '-', lw=1.5, color=line_color)
835
+ xlabel = "Time index"
836
+
837
+ # Apply theme colors to all text elements
838
+ ax.set_title(f"Time Slice @ freq_idx={freq_idx}", color=plot_text)
839
+ ax.set_xlabel(xlabel, color=plot_text)
840
+ ax.set_ylabel("Amplitude", color=plot_text)
841
+ ax.tick_params(colors=plot_text)
842
+
843
+ # Set spine colors
844
+ for spine in ax.spines.values():
845
+ spine.set_edgecolor(plot_text)
846
+
847
+ fig.tight_layout()
848
+ fig.show()
849
+
850
+ def _plot_1d_freq(self, data_slice, time_idx):
851
+ nf = data_slice.shape[0]
852
+
853
+ # Get theme colors from parent window
854
+ parent = self.parent()
855
+ while parent is not None and not hasattr(parent, 'current_theme'):
856
+ parent = parent.parent()
857
+
858
+ # Determine colors based on theme
859
+ if parent and hasattr(parent, 'themes') and hasattr(parent, 'current_theme'):
860
+ theme = parent.themes[parent.current_theme]
861
+ plot_bg = theme['plot_bg']
862
+ plot_text = theme['plot_text']
863
+ line_color = plot_text # Use theme text color for line
864
+ else:
865
+ # Default to dark theme
866
+ plot_bg = '#2b2b2b'
867
+ plot_text = 'white'
868
+ line_color = 'white'
869
+
870
+ fig, ax = plt.subplots(facecolor=plot_bg)
871
+ ax.set_facecolor(plot_bg)
872
+ yvals = np.ma.masked_invalid(data_slice)
873
+
874
+ if self._freq_axis is not None:
875
+ xvals = self._freq_axis
876
+ ax.plot(xvals, yvals, '-', lw=1.5, color=line_color)
877
+ xlabel = "Frequency (MHz)"
878
+ else:
879
+ xvals = np.linspace(0, nf - 1, nf)
880
+ ax.plot(xvals, yvals, '-', lw=1.5, color=line_color)
881
+ xlabel = "Frequency index"
882
+
883
+ # Apply theme colors to all text elements
884
+ ax.set_title(f"Freq Slice @ time_idx={time_idx}", color=plot_text)
885
+ ax.set_xlabel(xlabel, color=plot_text)
886
+ ax.set_ylabel("Amplitude", color=plot_text)
887
+ ax.tick_params(colors=plot_text)
888
+
889
+ # Set spine colors
890
+ for spine in ax.spines.values():
891
+ spine.set_edgecolor(plot_text)
892
+
893
+ fig.tight_layout()
894
+ fig.show()
895
+
896
+ # ------------------ Mouse Hover: Display Amplitude --------------------------
897
+ def on_mouse_move(self, event):
898
+ # Check if hover info is enabled
899
+ if not self.hover_info_enabled:
900
+ # Only clear text and redraw if text was previously showing
901
+ if self._last_hover_text:
902
+ self._hover_text.set_text("")
903
+ self._last_hover_text = ""
904
+ self.draw_idle()
905
+ return
906
+
907
+ if event.inaxes != self.ax or self._data is None:
908
+ # Only clear text and redraw if text was previously showing
909
+ if self._last_hover_text:
910
+ self._hover_text.set_text("")
911
+ self._last_hover_text = ""
912
+ self.draw_idle()
913
+ return
914
+
915
+ # Throttle hover updates to reduce CPU usage
916
+ self._hover_throttle_counter += 1
917
+ if self._hover_throttle_counter < self._hover_throttle_limit:
918
+ return
919
+ self._hover_throttle_counter = 0
920
+
921
+ try:
922
+ nt, nf = self._data.shape
923
+ if self._extent is not None:
924
+ x0, x1, y0, y1 = self._extent
925
+ frac_x = (event.xdata - x0) / (x1 - x0)
926
+ time_idx = int(frac_x * (nt - 1))
927
+ frac_y = (event.ydata - y0) / (y1 - y0)
928
+ freq_idx = int(frac_y * (nf - 1))
929
+ else:
930
+ time_idx = int(event.xdata)
931
+ freq_idx = int(event.ydata)
932
+
933
+ time_idx = max(0, min(time_idx, nt - 1))
934
+ freq_idx = max(0, min(freq_idx, nf - 1))
935
+
936
+ val = self._data[time_idx, freq_idx]
937
+ if np.isnan(val):
938
+ msg = f"Time={time_idx}, Freq={freq_idx}, Amp=NaN"
939
+ else:
940
+ msg = f"Time={time_idx}, Freq={freq_idx}, Amp={val:.3f}"
941
+
942
+ # Only update and redraw if the text has changed
943
+ if msg != self._last_hover_text:
944
+ self._hover_text.set_text(msg)
945
+ self._last_hover_text = msg
946
+ self.draw_idle()
947
+
948
+ except (TypeError, ValueError, IndexError) as e:
949
+ # Handle any errors in coordinate conversion - only clear if needed
950
+ if self._last_hover_text:
951
+ self._hover_text.set_text("")
952
+ self._last_hover_text = ""
953
+ self.draw_idle()
954
+
955
+
956
+ ###############################################################################
957
+ # MAIN WINDOW - PyQt5 APPLICATION #
958
+ ###############################################################################
959
+
960
+ class MainWindow(QMainWindow):
961
+ def __init__(self, theme="dark"):
962
+ super(MainWindow, self).__init__()
963
+ self.setWindowTitle("Dynamic Spectra Viewer")
964
+ self.resize(1500, 850)
965
+
966
+ # Store theme from parameter (allows external theme override)
967
+ self._external_theme = theme
968
+
969
+ # Data placeholders
970
+ self._original_unmodified = None # For revert
971
+ self._original_data = None # Working data (float array with NaNs)
972
+ self._time_axis = None
973
+ self._freq_axis = None
974
+
975
+ # Metadata string (FITS header)
976
+ self._metadata = ""
977
+
978
+ # Undo/Redo stacks (store copies of working data)
979
+ self.undo_stack = []
980
+ self.redo_stack = []
981
+
982
+ # Theme management - use theme from constructor parameter
983
+ self.current_theme = self._external_theme if self._external_theme in ["dark", "light"] else "dark"
984
+
985
+ # Theme palettes matching solarviewer's styles.py for consistency
986
+ self.themes = {
987
+ "light": {
988
+ # Solarviewer LIGHT_PALETTE colors
989
+ "main_bg": "#A59D84", # window
990
+ "panel_bg": "#ECEBDE", # base/surface
991
+ "panel_border": "#b0b0b0", # border
992
+ "text_color": "#1a1a1a", # text
993
+ "button_bg": "#f0f0f0", # button
994
+ "button_hover": "#e0e0e0", # button_hover
995
+ "button_pressed": "#d0d0d0", # button_pressed
996
+ "menubar_bg": "#D7D3BF", # toolbar_bg
997
+ "statusbar_bg": "#ECEBDE", # surface
998
+ "groupbox_bg": "#ECEBDE", # surface
999
+ "input_bg": "#ECEBDE", # base
1000
+ "input_border": "#b0b0b0", # border
1001
+ "highlight": "#0066cc", # highlight
1002
+ "plot_bg": "#ECEBDE", # plot_bg
1003
+ "plot_text": "#1a1a1a", # plot_text
1004
+ "plot_grid": "#d0d0d0" # plot_grid
1005
+ },
1006
+ "dark": {
1007
+ # Solarviewer DARK_PALETTE colors
1008
+ "main_bg": "#1a1a2e", # window
1009
+ "panel_bg": "#1f2940", # surface
1010
+ "panel_border": "#2a3f5f", # border
1011
+ "text_color": "#eeeeee", # text
1012
+ "button_bg": "#0f3460", # button
1013
+ "button_hover": "#1a4a7a", # button_hover
1014
+ "button_pressed": "#0a2540", # button_pressed
1015
+ "menubar_bg": "#1a1a2e", # window
1016
+ "statusbar_bg": "#1f2940", # surface
1017
+ "groupbox_bg": "#1f2940", # surface
1018
+ "input_bg": "#16213e", # base
1019
+ "input_border": "#2a3f5f", # border
1020
+ "highlight": "#e94560", # highlight
1021
+ "plot_bg": "#16213e", # base
1022
+ "plot_text": "#eeeeee", # text
1023
+ "plot_grid": "#2a3f5f" # border
1024
+ }
1025
+ }
1026
+
1027
+ self._setupLogger()
1028
+ self._createActions()
1029
+ self._createMenuBar()
1030
+ self._createMainWidgets()
1031
+ self._createStatusBar()
1032
+ self._applyStyle()
1033
+
1034
+ # Connect resize event
1035
+ self.resizeTimer = None
1036
+
1037
+ def _setupLogger(self):
1038
+ import logging
1039
+ self.logger = logging.getLogger("DynamicSpectrumViewer")
1040
+ self.logger.setLevel(logging.DEBUG)
1041
+ if not self.logger.handlers:
1042
+ ch = logging.StreamHandler()
1043
+ ch.setLevel(logging.DEBUG)
1044
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
1045
+ ch.setFormatter(formatter)
1046
+ self.logger.addHandler(ch)
1047
+
1048
+ # ------------------------- Actions and Menu -------------------------------
1049
+ def _createActions(self):
1050
+ self.openAct = QAction("&Open FITS...", self)
1051
+ self.openAct.setShortcut("Ctrl+O")
1052
+ self.openAct.triggered.connect(self.openFile)
1053
+
1054
+ self.saveAct = QAction("&Save FITS...", self)
1055
+ self.saveAct.setShortcut("Ctrl+S")
1056
+ self.saveAct.triggered.connect(self.saveCleanedData)
1057
+
1058
+ self.exitAct = QAction("E&xit", self)
1059
+ self.exitAct.setShortcut("Ctrl+Q")
1060
+ self.exitAct.triggered.connect(self.close)
1061
+
1062
+ self.compareAct = QAction("Compare &Original vs Cleaned", self)
1063
+ self.compareAct.setShortcut("Ctrl+C")
1064
+ self.compareAct.triggered.connect(self.compareOriginalCleaned)
1065
+
1066
+ self.revertAct = QAction("&Revert to Original", self)
1067
+ self.revertAct.setShortcut("Ctrl+R")
1068
+ self.revertAct.triggered.connect(self.revertToOriginal)
1069
+
1070
+ # New Undo/Redo actions
1071
+ self.undoAct = QAction("&Undo", self)
1072
+ self.undoAct.setShortcut("Ctrl+Z")
1073
+ self.undoAct.triggered.connect(self.undo)
1074
+
1075
+ self.redoAct = QAction("&Redo", self)
1076
+ self.redoAct.setShortcut("Ctrl+Y")
1077
+ self.redoAct.triggered.connect(self.redo)
1078
+
1079
+ # New action for viewing metadata
1080
+ self.viewMetaAct = QAction("View &Metadata", self)
1081
+ self.viewMetaAct.triggered.connect(self.viewMetadata)
1082
+
1083
+ # Theme toggle action
1084
+ self.toggleThemeAct = QAction("Toggle &Dark/Light Theme", self)
1085
+ self.toggleThemeAct.setShortcut("Ctrl+T")
1086
+ self.toggleThemeAct.triggered.connect(self.toggleTheme)
1087
+
1088
+ def _createMenuBar(self):
1089
+ menubar = QMenuBar(self)
1090
+ self.setMenuBar(menubar)
1091
+
1092
+ fileMenu = menubar.addMenu("&File")
1093
+ fileMenu.addAction(self.openAct)
1094
+ fileMenu.addAction(self.saveAct)
1095
+ fileMenu.addSeparator()
1096
+ fileMenu.addAction(self.exitAct)
1097
+
1098
+ editMenu = menubar.addMenu("&Edit")
1099
+ editMenu.addAction(self.undoAct)
1100
+ editMenu.addAction(self.redoAct)
1101
+
1102
+ viewMenu = menubar.addMenu("&View")
1103
+ viewMenu.addAction(self.viewMetaAct)
1104
+ viewMenu.addSeparator()
1105
+ viewMenu.addAction(self.toggleThemeAct)
1106
+
1107
+ toolsMenu = menubar.addMenu("&Tools")
1108
+ toolsMenu.addAction(self.compareAct)
1109
+ toolsMenu.addAction(self.revertAct)
1110
+
1111
+ # -------------------------- Central Widgets ---------------------------------
1112
+ def _createMainWidgets(self):
1113
+ centralWidget = QWidget()
1114
+ self.setCentralWidget(centralWidget)
1115
+ layout = QHBoxLayout(centralWidget)
1116
+
1117
+ # Left side: Matplotlib canvas and toolbar
1118
+ leftWidget = QWidget()
1119
+ leftLayout = QVBoxLayout(leftWidget)
1120
+
1121
+ self.canvas = DynamicSpectrumCanvas(self)
1122
+ self.navbar = NavigationToolbar(self.canvas, self)
1123
+
1124
+ leftLayout.addWidget(self.canvas)
1125
+ leftLayout.addWidget(self.navbar)
1126
+
1127
+ # Connect canvas resize events to ensure proper redrawing
1128
+ self.canvas.setSizePolicy(
1129
+ QtWidgets.QSizePolicy.Expanding,
1130
+ QtWidgets.QSizePolicy.Expanding
1131
+ )
1132
+ self.canvas.updateGeometry()
1133
+
1134
+ # Right side: Control Panel
1135
+ rightWidget = QWidget()
1136
+ rightLayout = QVBoxLayout(rightWidget)
1137
+
1138
+ # Set size policies for the left and right panels
1139
+ leftWidget.setSizePolicy(
1140
+ QtWidgets.QSizePolicy.Expanding,
1141
+ QtWidgets.QSizePolicy.Expanding
1142
+ )
1143
+ # Right panel: allow horizontal expansion to fill splitter space
1144
+ rightWidget.setSizePolicy(
1145
+ QtWidgets.QSizePolicy.Preferred,
1146
+ QtWidgets.QSizePolicy.Preferred
1147
+ )
1148
+ rightWidget.setMinimumWidth(250)
1149
+
1150
+ # (A) Scale mode
1151
+ scaleLabel = QLabel("Intensity Scale:")
1152
+ self.scaleCombo = QComboBox()
1153
+ self.scaleCombo.addItems(["Linear", "Log", "Sqrt", "Gamma"])
1154
+ self.scaleCombo.setCurrentText("Linear")
1155
+ self.scaleCombo.currentTextChanged.connect(self.onScaleModeChanged)
1156
+ rightLayout.addWidget(scaleLabel)
1157
+ rightLayout.addWidget(self.scaleCombo)
1158
+
1159
+ # (B) Gamma slider
1160
+ self.gammaLabel = QLabel("Gamma:")
1161
+ self.gammaSlider = QSlider(Qt.Horizontal)
1162
+ self.gammaSlider.setRange(1, 300)
1163
+ self.gammaSlider.setValue(100)
1164
+ self.gammaSlider.valueChanged.connect(self.onGammaChanged)
1165
+ self.gammaLabel.setEnabled(False)
1166
+ self.gammaSlider.setEnabled(False)
1167
+ rightLayout.addWidget(self.gammaLabel)
1168
+ rightLayout.addWidget(self.gammaSlider)
1169
+
1170
+ # (C) Colormap
1171
+ cmapLabel = QLabel("Colormap:")
1172
+ self.cmapCombo = QComboBox()
1173
+ cmaps = ["viridis", "plasma", "inferno", "magma", "cividis",
1174
+ "jet", "gray", "bone", "turbo", "afmhot", "afmhot_r", "cubehelix", "Greens", "gist_heat", "gist_heat_r"]
1175
+ self.cmapCombo.addItems(cmaps)
1176
+ self.cmapCombo.setCurrentText("inferno")
1177
+ self.cmapCombo.currentTextChanged.connect(self.onCmapChanged)
1178
+ rightLayout.addWidget(cmapLabel)
1179
+ rightLayout.addWidget(self.cmapCombo)
1180
+
1181
+ # (D) Smart Auto-Scale - replace checkbox with dropdown
1182
+ scaleRangeLabel = QLabel("Intensity Range:")
1183
+ self.scaleRangeCombo = QComboBox()
1184
+ scale_options = ["0-100%", "0.1-99.9%", "0.5-99.5%", "1-99%"]
1185
+ self.scaleRangeCombo.addItems(scale_options)
1186
+ self.scaleRangeCombo.setCurrentText("0.5-99.5%")
1187
+ self.scaleRangeCombo.currentTextChanged.connect(self.onScaleRangeChanged)
1188
+ rightLayout.addWidget(scaleRangeLabel)
1189
+ rightLayout.addWidget(self.scaleRangeCombo)
1190
+
1191
+ # (E) ROI selection (mask region)
1192
+ self.roiButton = QPushButton("Mask Region (ROI)")
1193
+ self.roiButton.setCheckable(True)
1194
+ self.roiButton.toggled.connect(self.onRoiToggled)
1195
+ rightLayout.addWidget(self.roiButton)
1196
+
1197
+ # (F) Mouse Hover Info
1198
+ hoverGroup = QGroupBox("Mouse Hover Settings")
1199
+ hoverLayout = QVBoxLayout(hoverGroup)
1200
+
1201
+ self.hoverInfoBox = QCheckBox("Show Mouse Hover Info")
1202
+ self.hoverInfoBox.setChecked(False) # Default disabled
1203
+ self.hoverInfoBox.toggled.connect(self.onHoverInfoToggled)
1204
+ hoverLayout.addWidget(self.hoverInfoBox)
1205
+
1206
+ # Hover update frequency (throttling)
1207
+ hoverFreqLayout = QHBoxLayout()
1208
+ hoverFreqLabel = QLabel("Update Rate:")
1209
+ self.hoverFreqSpin = QSpinBox()
1210
+ self.hoverFreqSpin.setRange(1, 20)
1211
+ self.hoverFreqSpin.setValue(3) # Default: update every 3rd mouse event
1212
+ self.hoverFreqSpin.setSuffix(" events")
1213
+ self.hoverFreqSpin.setToolTip("Higher values = less frequent updates = better performance")
1214
+ self.hoverFreqSpin.valueChanged.connect(self.onHoverFrequencyChanged)
1215
+ hoverFreqLayout.addWidget(hoverFreqLabel)
1216
+ hoverFreqLayout.addWidget(self.hoverFreqSpin)
1217
+ hoverLayout.addLayout(hoverFreqLayout)
1218
+
1219
+ rightLayout.addWidget(hoverGroup)
1220
+
1221
+ # (G) Theme Toggle
1222
+ # self.themeToggleBtn = QPushButton("☀️ Light Theme") # Start with dark theme, so button shows "switch to light"
1223
+ # self.themeToggleBtn.setToolTip("Toggle between Dark and Light themes (Ctrl+T)")
1224
+ # self.themeToggleBtn.clicked.connect(self.toggleTheme)
1225
+ # rightLayout.addWidget(self.themeToggleBtn)
1226
+
1227
+ # (H) Cross Section
1228
+ self.crossSectionBtn = QPushButton("Cross Section Mode")
1229
+ self.crossSectionBtn.setCheckable(True)
1230
+ self.crossSectionBtn.toggled.connect(self.onCrossSectionToggled)
1231
+ rightLayout.addWidget(self.crossSectionBtn)
1232
+
1233
+ # (H) Region Detection box
1234
+ detectGroup = QGroupBox("Region Detection")
1235
+ detectLayout = QVBoxLayout(detectGroup)
1236
+ form = QFormLayout()
1237
+ self.threshSpin = QDoubleSpinBox()
1238
+ self.threshSpin.setRange(0.0, 1e9)
1239
+ self.threshSpin.setValue(5.0)
1240
+ form.addRow("Threshold:", self.threshSpin)
1241
+ self.minWidthSpin = QSpinBox()
1242
+ self.minWidthSpin.setRange(1, 9999)
1243
+ self.minWidthSpin.setValue(1)
1244
+ form.addRow("Min Width:", self.minWidthSpin)
1245
+ self.minHeightSpin = QSpinBox()
1246
+ self.minHeightSpin.setRange(1, 9999)
1247
+ self.minHeightSpin.setValue(5)
1248
+ form.addRow("Min Height:", self.minHeightSpin)
1249
+ regionDetectBtn = QPushButton("Region Detection")
1250
+ regionDetectBtn.clicked.connect(self.onCleanRFIRegionDetect)
1251
+ form.addRow(regionDetectBtn)
1252
+ detectLayout.addLayout(form)
1253
+ rightLayout.addWidget(detectGroup)
1254
+
1255
+ # (I) Bandpass Normalization
1256
+ self.bandpassBtn = QPushButton("Bandpass Norm")
1257
+ self.bandpassBtn.clicked.connect(self.onBandpassNorm)
1258
+ rightLayout.addWidget(self.bandpassBtn)
1259
+
1260
+ rightLayout.addStretch()
1261
+
1262
+ # Wrap right widget in a scroll area for long control panels
1263
+ scrollArea = QScrollArea()
1264
+ scrollArea.setWidget(rightWidget)
1265
+ scrollArea.setWidgetResizable(True)
1266
+ scrollArea.setMinimumWidth(250)
1267
+
1268
+ # Use QSplitter for draggable resizable panels
1269
+ splitter = QSplitter(Qt.Horizontal)
1270
+ splitter.addWidget(leftWidget)
1271
+ splitter.addWidget(scrollArea)
1272
+ splitter.setStretchFactor(0, 1) # Canvas gets more space
1273
+ splitter.setStretchFactor(1, 0) # Control panel stays compact
1274
+ splitter.setSizes([900, 300]) # Initial sizes
1275
+
1276
+ layout.addWidget(splitter)
1277
+
1278
+ # ---------------------------- Status Bar ------------------------------------
1279
+ def _createStatusBar(self):
1280
+ self.statusBar = QStatusBar()
1281
+ self.setStatusBar(self.statusBar)
1282
+ self.amplitudeLabel = QLabel("")
1283
+ self.statusBar.addPermanentWidget(self.amplitudeLabel)
1284
+
1285
+ # --------------------- View Metadata Dialog ---------------------------------
1286
+ def viewMetadata(self):
1287
+ if not self._metadata:
1288
+ QMessageBox.information(self, "Metadata", "No metadata available.")
1289
+ return
1290
+ dlg = QDialog(self)
1291
+ dlg.setWindowTitle("FITS File Metadata")
1292
+ dlg.resize(600, 400)
1293
+ layout = QVBoxLayout(dlg)
1294
+ textEdit = QTextEdit(dlg)
1295
+ textEdit.setReadOnly(True)
1296
+ textEdit.setPlainText(self._metadata)
1297
+ layout.addWidget(textEdit)
1298
+ dlg.exec_()
1299
+
1300
+ def _applyStyle(self):
1301
+ """Apply the current theme's styling to all UI elements."""
1302
+ theme = self.themes[self.current_theme]
1303
+
1304
+ style = f"""
1305
+ QMainWindow {{
1306
+ background-color: {theme['main_bg']};
1307
+ color: {theme['text_color']};
1308
+ }}
1309
+
1310
+ QMenuBar {{
1311
+ background-color: {theme['menubar_bg']};
1312
+ color: {theme['text_color']};
1313
+ font-size: 12pt;
1314
+ border-bottom: 1px solid {theme['panel_border']};
1315
+ }}
1316
+
1317
+ QMenuBar::item {{
1318
+ background-color: transparent;
1319
+ padding: 4px 8px;
1320
+ }}
1321
+
1322
+ QMenuBar::item:selected {{
1323
+ background-color: {theme['button_hover']};
1324
+ }}
1325
+
1326
+ QMenu {{
1327
+ background-color: {theme['panel_bg']};
1328
+ color: {theme['text_color']};
1329
+ border: 1px solid {theme['panel_border']};
1330
+ }}
1331
+
1332
+ QMenu::item:selected {{
1333
+ background-color: {theme['button_hover']};
1334
+ }}
1335
+
1336
+ QStatusBar {{
1337
+ background-color: {theme['statusbar_bg']};
1338
+ color: {theme['text_color']};
1339
+ font-size: 10pt;
1340
+ border-top: 1px solid {theme['panel_border']};
1341
+ }}
1342
+
1343
+ QWidget {{
1344
+ background-color: {theme['panel_bg']};
1345
+ color: {theme['text_color']};
1346
+ }}
1347
+
1348
+ QPushButton {{
1349
+ background-color: {theme['button_bg']};
1350
+ color: {theme['text_color']};
1351
+ border: 1px solid {theme['panel_border']};
1352
+ padding: 6px 12px;
1353
+ border-radius: 4px;
1354
+ font-weight: bold;
1355
+ }}
1356
+
1357
+ QPushButton:hover {{
1358
+ background-color: {theme['button_hover']};
1359
+ }}
1360
+
1361
+ QPushButton:pressed {{
1362
+ background-color: {theme['button_pressed']};
1363
+ }}
1364
+
1365
+ QPushButton:checked {{
1366
+ background-color: {theme['button_pressed']};
1367
+ border: 2px solid {theme['text_color']};
1368
+ }}
1369
+
1370
+ QGroupBox {{
1371
+ background-color: {theme['groupbox_bg']};
1372
+ color: {theme['text_color']};
1373
+ border: 2px solid {theme['panel_border']};
1374
+ border-radius: 6px;
1375
+ margin-top: 6px;
1376
+ font-weight: bold;
1377
+ }}
1378
+
1379
+ QGroupBox::title {{
1380
+ subcontrol-origin: margin;
1381
+ left: 10px;
1382
+ padding: 0 5px 0 5px;
1383
+ }}
1384
+
1385
+ QComboBox {{
1386
+ background-color: {theme['input_bg']};
1387
+ color: {theme['text_color']};
1388
+ border: 1px solid {theme['input_border']};
1389
+ padding: 4px;
1390
+ border-radius: 4px;
1391
+ }}
1392
+
1393
+ QComboBox:drop-down {{
1394
+ border: none;
1395
+ }}
1396
+
1397
+ QComboBox::drop-down:hover {{
1398
+ background-color: {theme['button_hover']};
1399
+ }}
1400
+
1401
+ QSpinBox, QDoubleSpinBox {{
1402
+ background-color: {theme['input_bg']};
1403
+ color: {theme['text_color']};
1404
+ border: 1px solid {theme['input_border']};
1405
+ padding: 4px;
1406
+ border-radius: 4px;
1407
+ }}
1408
+
1409
+ QSlider::groove:horizontal {{
1410
+ border: 1px solid {theme['input_border']};
1411
+ height: 6px;
1412
+ background: {theme['input_bg']};
1413
+ border-radius: 3px;
1414
+ }}
1415
+
1416
+ QSlider::sub-page:horizontal {{
1417
+ background: {theme['highlight']};
1418
+ border-radius: 3px;
1419
+ }}
1420
+
1421
+ QSlider::handle:horizontal {{
1422
+ background: {theme['highlight']};
1423
+ border: 2px solid {theme['text_color']};
1424
+ width: 16px;
1425
+ height: 16px;
1426
+ margin: -6px 0;
1427
+ border-radius: 9px;
1428
+ }}
1429
+
1430
+ QSlider::handle:horizontal:hover {{
1431
+ background: {theme['button_hover']};
1432
+ border: 2px solid {theme['text_color']};
1433
+ }}
1434
+
1435
+ QCheckBox {{
1436
+ color: {theme['text_color']};
1437
+ }}
1438
+
1439
+ QCheckBox::indicator {{
1440
+ width: 16px;
1441
+ height: 16px;
1442
+ background-color: {theme['input_bg']};
1443
+ border: 1px solid {theme['input_border']};
1444
+ border-radius: 3px;
1445
+ }}
1446
+
1447
+ QCheckBox::indicator:checked {{
1448
+ background-color: {theme['button_pressed']};
1449
+ border: 2px solid {theme['text_color']};
1450
+ }}
1451
+
1452
+ QLabel {{
1453
+ color: {theme['text_color']};
1454
+ }}
1455
+
1456
+ QTextEdit {{
1457
+ background-color: {theme['input_bg']};
1458
+ color: {theme['text_color']};
1459
+ border: 1px solid {theme['input_border']};
1460
+ border-radius: 4px;
1461
+ }}
1462
+ """
1463
+
1464
+ self.setStyleSheet(style)
1465
+
1466
+ # Apply theme to matplotlib canvas
1467
+ self._applyCanvasTheme()
1468
+
1469
+ def _applyCanvasTheme(self):
1470
+ """Apply the current theme to the matplotlib canvas."""
1471
+ if not hasattr(self, 'canvas'):
1472
+ return
1473
+
1474
+ theme = self.themes[self.current_theme]
1475
+
1476
+ # Set matplotlib style parameters
1477
+ import matplotlib.pyplot as plt
1478
+ plt.rcParams.update({
1479
+ 'figure.facecolor': theme['plot_bg'],
1480
+ 'axes.facecolor': theme['plot_bg'],
1481
+ 'axes.edgecolor': theme['plot_text'],
1482
+ 'axes.labelcolor': theme['plot_text'],
1483
+ 'xtick.color': theme['plot_text'],
1484
+ 'ytick.color': theme['plot_text'],
1485
+ 'text.color': theme['plot_text'],
1486
+ 'axes.grid': True,
1487
+ 'grid.color': theme['plot_grid'],
1488
+ 'grid.alpha': 0.3
1489
+ })
1490
+
1491
+ # Always update the canvas theme, regardless of whether data is loaded
1492
+ self.canvas.fig.patch.set_facecolor(theme['plot_bg'])
1493
+ self.canvas.ax.set_facecolor(theme['plot_bg'])
1494
+
1495
+ # Update axis colors and spines (borders)
1496
+ self.canvas.ax.tick_params(colors=theme['plot_text'])
1497
+ self.canvas.ax.xaxis.label.set_color(theme['plot_text'])
1498
+ self.canvas.ax.yaxis.label.set_color(theme['plot_text'])
1499
+ self.canvas.ax.title.set_color(theme['plot_text'])
1500
+
1501
+ # Update spine (border) colors
1502
+ for spine in self.canvas.ax.spines.values():
1503
+ spine.set_edgecolor(theme['plot_text'])
1504
+
1505
+ # Update grid
1506
+ self.canvas.ax.grid(True, color=theme['plot_grid'], alpha=0.3)
1507
+
1508
+ # Update colorbar if it exists
1509
+ if hasattr(self.canvas, '_colorbar') and self.canvas._colorbar is not None:
1510
+ # Update colorbar text colors
1511
+ self.canvas._colorbar.ax.tick_params(colors=theme['plot_text'])
1512
+ self.canvas._colorbar.ax.yaxis.label.set_color(theme['plot_text'])
1513
+
1514
+ # Update colorbar outline
1515
+ for spine in self.canvas._colorbar.ax.spines.values():
1516
+ spine.set_edgecolor(theme['plot_text'])
1517
+
1518
+ # Update hover text colors if it exists
1519
+ if hasattr(self.canvas, '_hover_text'):
1520
+ if self.current_theme == "dark":
1521
+ self.canvas._hover_text.set_color("white")
1522
+ self.canvas._hover_text.set_bbox(dict(facecolor="black", alpha=0.8))
1523
+ else:
1524
+ self.canvas._hover_text.set_color("black")
1525
+ self.canvas._hover_text.set_bbox(dict(facecolor="white", alpha=0.8))
1526
+
1527
+ # Always redraw the canvas to show theme changes
1528
+ self.canvas.draw()
1529
+
1530
+ def toggleTheme(self):
1531
+ """Toggle between dark and light themes."""
1532
+ if self.current_theme == "light":
1533
+ self.current_theme = "dark"
1534
+ self.themeToggleBtn.setText("☀️ Light Theme")
1535
+ else:
1536
+ self.current_theme = "light"
1537
+ self.themeToggleBtn.setText("🌙 Dark Theme")
1538
+
1539
+ # Apply the new theme
1540
+ self._applyStyle()
1541
+
1542
+ # Update status message
1543
+ self.statusBar.showMessage(f"Switched to {self.current_theme} theme", 3000)
1544
+
1545
+ # ---------------------------- File Operations -------------------------------
1546
+ def openFile(self):
1547
+ fileName, _ = QFileDialog.getOpenFileName(
1548
+ self, "Open FITS File", "", "FITS Files (*.fits *.fts);;All Files (*)"
1549
+ )
1550
+ if not fileName:
1551
+ return
1552
+ try:
1553
+ self.logger.info(f"Opening FITS file: {fileName}")
1554
+ hdul = fits.open(fileName)
1555
+ data = hdul[0].data
1556
+ header = hdul[0].header # Get primary HDU header as metadata
1557
+ self._metadata = str(header)
1558
+ time_axis = None
1559
+ freq_axis = None
1560
+
1561
+ # First try to get axes from dedicated extensions (LOFAR/Learmonth format)
1562
+ for hdu in hdul:
1563
+ if hdu.name.upper() == "TIME_AXIS":
1564
+ time_mjd = hdu.data["TIME_MJD"]
1565
+ time_axis = time_mjd * 86400.0
1566
+ if hdu.name.upper() == "FREQ_AXIS":
1567
+ freq_axis = hdu.data["FREQ_MHz"]
1568
+
1569
+ # Fallback: try to extract from WCS headers if extensions not found
1570
+ if time_axis is None or freq_axis is None:
1571
+ self.logger.info("No axis extensions found, trying WCS headers...")
1572
+ try:
1573
+ # Get data shape - shape is (freq, time) or (time, freq) depending on file
1574
+ naxis1 = header.get('NAXIS1', data.shape[1] if len(data.shape) > 1 else data.shape[0])
1575
+ naxis2 = header.get('NAXIS2', data.shape[0])
1576
+
1577
+ # Try to get frequency axis from CTYPE1/CRVAL1/CDELT1
1578
+ ctype1 = header.get('CTYPE1', '')
1579
+ if freq_axis is None and 'FREQ' in ctype1.upper():
1580
+ crval1 = header.get('CRVAL1', 0)
1581
+ cdelt1 = header.get('CDELT1', 1)
1582
+ crpix1 = header.get('CRPIX1', 1)
1583
+ freq_axis = crval1 + (np.arange(naxis1) - (crpix1 - 1)) * cdelt1
1584
+ self.logger.info(f"Extracted freq_axis from WCS: {freq_axis[0]:.2f} to {freq_axis[-1]:.2f} MHz")
1585
+
1586
+ # Try to get time axis from CTYPE2/CRVAL2/CDELT2
1587
+ ctype2 = header.get('CTYPE2', '')
1588
+ if time_axis is None and 'TIME' in ctype2.upper():
1589
+ # Get start time from DATE-OBS
1590
+ date_obs = header.get('DATE-OBS', None)
1591
+ cdelt2 = header.get('CDELT2', 3.0) # seconds between samples
1592
+ if date_obs:
1593
+ from astropy.time import Time as AstroTime
1594
+ t_start = AstroTime(date_obs)
1595
+ # Create time array in MJD seconds
1596
+ time_axis = (t_start.mjd * 86400.0) + np.arange(naxis2) * cdelt2
1597
+ self.logger.info(f"Extracted time_axis from WCS: {naxis2} samples, dt={cdelt2}s")
1598
+
1599
+ # Alternative: check if axes are swapped (CTYPE1=TIME, CTYPE2=FREQ)
1600
+ if freq_axis is None and 'FREQ' in ctype2.upper():
1601
+ crval2 = header.get('CRVAL2', 0)
1602
+ cdelt2 = header.get('CDELT2', 1)
1603
+ crpix2 = header.get('CRPIX2', 1)
1604
+ freq_axis = crval2 + (np.arange(naxis2) - (crpix2 - 1)) * cdelt2
1605
+ self.logger.info(f"Extracted freq_axis from WCS (CTYPE2): {freq_axis[0]:.2f} to {freq_axis[-1]:.2f} MHz")
1606
+
1607
+ if time_axis is None and 'TIME' in ctype1.upper():
1608
+ date_obs = header.get('DATE-OBS', None)
1609
+ cdelt1 = header.get('CDELT1', 3.0)
1610
+ if date_obs:
1611
+ from astropy.time import Time as AstroTime
1612
+ t_start = AstroTime(date_obs)
1613
+ time_axis = (t_start.mjd * 86400.0) + np.arange(naxis1) * cdelt1
1614
+ self.logger.info(f"Extracted time_axis from WCS (CTYPE1): {naxis1} samples")
1615
+ except Exception as wcs_err:
1616
+ self.logger.warning(f"Could not extract axes from WCS: {wcs_err}")
1617
+
1618
+ hdul.close()
1619
+
1620
+ data = np.array(data, dtype=np.float32)
1621
+ data[np.isnan(data)] = np.nan
1622
+ data[np.isinf(data)] = np.nan
1623
+
1624
+ self.undo_stack.clear()
1625
+ self.redo_stack.clear()
1626
+
1627
+ self._original_unmodified = data.copy()
1628
+ self._original_data = data.copy()
1629
+ self._time_axis = time_axis
1630
+ self._freq_axis = freq_axis
1631
+
1632
+ self.canvas.set_data(self._original_data, self._time_axis, self._freq_axis)
1633
+ self.canvas.draw_spectrum()
1634
+ self.statusBar.showMessage(f"Loaded {os.path.basename(fileName)}", 5000)
1635
+ except Exception as e:
1636
+ self.logger.exception(f"Failed to open or plot FITS file: {e}")
1637
+ QMessageBox.critical(self, "Error", f"Failed to open FITS file:\n{e}")
1638
+
1639
+ def saveCleanedData(self):
1640
+ if self._original_data is None:
1641
+ QMessageBox.information(self, "Info", "No data to save.")
1642
+ return
1643
+ fileName, _ = QFileDialog.getSaveFileName(
1644
+ self, "Save Cleaned FITS", "", "FITS Files (*.fits)"
1645
+ )
1646
+ if not fileName:
1647
+ return
1648
+ try:
1649
+ hdu = fits.PrimaryHDU(self._original_data)
1650
+ hdul = fits.HDUList([hdu])
1651
+ if self._time_axis is not None:
1652
+ from astropy.table import Table
1653
+ t = Table()
1654
+ t["TIME_MJD"] = self._time_axis / 86400.0
1655
+ time_hdu = fits.BinTableHDU(t, name="TIME_AXIS")
1656
+ hdul.append(time_hdu)
1657
+ if self._freq_axis is not None:
1658
+ from astropy.table import Table
1659
+ t = Table()
1660
+ t["FREQ_MHz"] = self._freq_axis
1661
+ freq_hdu = fits.BinTableHDU(t, name="FREQ_AXIS")
1662
+ hdul.append(freq_hdu)
1663
+ hdul.writeto(fileName, overwrite=True)
1664
+ self.statusBar.showMessage(f"Saved cleaned data to {fileName}", 5000)
1665
+ except Exception as e:
1666
+ QMessageBox.critical(self, "Error", f"Failed to save FITS:\n{e}")
1667
+
1668
+ def compareOriginalCleaned(self):
1669
+ if self._original_unmodified is None or self._original_data is None:
1670
+ QMessageBox.information(self, "Info", "No data loaded.")
1671
+ return
1672
+ fig, axes = plt.subplots(1, 2, figsize=(12, 5), sharey=True)
1673
+ axes[0].set_title("Original Unmodified")
1674
+ axes[1].set_title("Current Cleaned")
1675
+ orig_ma = ma.masked_invalid(self._original_unmodified)
1676
+ cle_ma = ma.masked_invalid(self._original_data)
1677
+
1678
+ # Apply the same scaling as the current view
1679
+ if self.canvas._smart_scale == "0-100%":
1680
+ orig_vals = orig_ma.compressed()
1681
+ cle_vals = cle_ma.compressed()
1682
+ orig_vmin, orig_vmax = orig_vals.min(), orig_vals.max()
1683
+ cle_vmin, cle_vmax = cle_vals.min(), cle_vals.max()
1684
+ elif self.canvas._smart_scale == "0.1-99.9%":
1685
+ orig_vals = orig_ma.compressed()
1686
+ cle_vals = cle_ma.compressed()
1687
+ orig_vmin = np.percentile(orig_vals, 0.1)
1688
+ orig_vmax = np.percentile(orig_vals, 99.9)
1689
+ cle_vmin = np.percentile(cle_vals, 0.1)
1690
+ cle_vmax = np.percentile(cle_vals, 99.9)
1691
+ elif self.canvas._smart_scale == "0.5-99.5%":
1692
+ orig_vals = orig_ma.compressed()
1693
+ cle_vals = cle_ma.compressed()
1694
+ orig_vmin = np.percentile(orig_vals, 0.5)
1695
+ orig_vmax = np.percentile(orig_vals, 99.5)
1696
+ cle_vmin = np.percentile(cle_vals, 0.5)
1697
+ cle_vmax = np.percentile(cle_vals, 99.5)
1698
+ else: # "1-99%"
1699
+ orig_vals = orig_ma.compressed()
1700
+ cle_vals = cle_ma.compressed()
1701
+ orig_vmin = np.percentile(orig_vals, 1)
1702
+ orig_vmax = np.percentile(orig_vals, 99)
1703
+ cle_vmin = np.percentile(cle_vals, 1)
1704
+ cle_vmax = np.percentile(cle_vals, 99)
1705
+
1706
+ norm1 = self.canvas.get_normalization(orig_vmin, orig_vmax)
1707
+ norm2 = self.canvas.get_normalization(cle_vmin, cle_vmax)
1708
+ im1 = axes[0].imshow(orig_ma.T, origin='lower', aspect='auto',
1709
+ norm=norm1, cmap=self.canvas._cmap)
1710
+ fig.colorbar(im1, ax=axes[0], label="Amplitude")
1711
+ im2 = axes[1].imshow(cle_ma.T, origin='lower', aspect='auto',
1712
+ norm=norm2, cmap=self.canvas._cmap)
1713
+ fig.colorbar(im2, ax=axes[1], label="Amplitude")
1714
+ axes[0].set_xlabel("Time Index")
1715
+ axes[1].set_xlabel("Time Index")
1716
+ axes[0].set_ylabel("Frequency Index")
1717
+ axes[1].set_ylabel("Frequency Index")
1718
+ fig.suptitle("Original vs Cleaned")
1719
+ fig.tight_layout()
1720
+ fig.show()
1721
+
1722
+ def revertToOriginal(self):
1723
+ if self._original_unmodified is None:
1724
+ QMessageBox.warning(self, "Warning", "No original data to revert to.")
1725
+ return
1726
+ self._original_data = self._original_unmodified.copy()
1727
+ self.canvas.set_data(self._original_data, self._time_axis, self._freq_axis)
1728
+ self.canvas.draw_spectrum()
1729
+ self.statusBar.showMessage("Reverted to original data.", 5000)
1730
+
1731
+ # ----------------------- Undo/Redo Functionality ----------------------------
1732
+ def undo(self):
1733
+ if self.undo_stack:
1734
+ self.redo_stack.append(self._original_data.copy())
1735
+ self._original_data = self.undo_stack.pop()
1736
+ self.canvas.set_data(self._original_data, self._time_axis, self._freq_axis)
1737
+ self.canvas.draw_spectrum()
1738
+ else:
1739
+ QMessageBox.information(self, "Undo", "No more actions to undo.")
1740
+
1741
+ def redo(self):
1742
+ if self.redo_stack:
1743
+ self.undo_stack.append(self._original_data.copy())
1744
+ self._original_data = self.redo_stack.pop()
1745
+ self.canvas.set_data(self._original_data, self._time_axis, self._freq_axis)
1746
+ self.canvas.draw_spectrum()
1747
+ else:
1748
+ QMessageBox.information(self, "Redo", "No more actions to redo.")
1749
+
1750
+ # In each modifying operation, push current state to undo stack and clear redo stack.
1751
+ def onCleanRFIRegionDetect(self):
1752
+ if self._original_data is None:
1753
+ QMessageBox.information(self, "Info", "No data loaded.")
1754
+ return
1755
+ self.undo_stack.append(self._original_data.copy())
1756
+ self.redo_stack.clear()
1757
+ thresh = self.threshSpin.value()
1758
+ min_w = self.minWidthSpin.value()
1759
+ min_h = self.minHeightSpin.value()
1760
+ data_2d = self._original_data.copy()
1761
+ data_2d[np.isnan(data_2d)] = 0
1762
+ data_T = data_2d.T
1763
+ med_band = np.nanmedian(data_T, axis=1, keepdims=True)
1764
+ normed_data = data_T / (med_band + 1e-20)
1765
+ binary_image = create_binary(normed_data, thresh=thresh)
1766
+ _, valid_contours, _ = region_detection(normed_data, binary_image,
1767
+ min_width=min_w, min_height=min_h)
1768
+ mask = create_mask(binary_image, valid_contours)
1769
+ rfi_map = subtract_contours(normed_data, mask=~mask)
1770
+ cleaned_data = rfi_map.T
1771
+ cleaned_data[np.isnan(cleaned_data)] = np.nan
1772
+ self._original_data = cleaned_data
1773
+ self.canvas.set_data(self._original_data, self._time_axis, self._freq_axis)
1774
+ self.canvas.draw_spectrum()
1775
+ self.statusBar.showMessage("Region detection done.", 5000)
1776
+
1777
+ def onBandpassNorm(self):
1778
+ if self._original_data is None:
1779
+ QMessageBox.information(self, "Info", "No data loaded.")
1780
+ return
1781
+ self.undo_stack.append(self._original_data.copy())
1782
+ self.redo_stack.clear()
1783
+ data_2d = self._original_data
1784
+ freq_profile = np.nanmedian(data_2d, axis=0)
1785
+ freq_profile[freq_profile == 0] = 1e-20
1786
+ for i in range(len(freq_profile)):
1787
+ if np.isnan(freq_profile[i]):
1788
+ freq_profile[i] = 1e-20
1789
+ normed = data_2d / freq_profile
1790
+ normed[np.isnan(normed)] = np.nan
1791
+ self._original_data = normed
1792
+ self.canvas.set_data(self._original_data, self._time_axis, self._freq_axis)
1793
+ self.canvas.draw_spectrum()
1794
+ self.statusBar.showMessage("Bandpass normalization applied.", 5000)
1795
+
1796
+ def _mask_roi_values(self, ixmin, ixmax, iymin, iymax):
1797
+ """Mask a region of interest with NaN values."""
1798
+ try:
1799
+ self.logger.info(f"Masking ROI: time=[{ixmin},{ixmax}], freq=[{iymin},{iymax}]")
1800
+
1801
+ # Validate indices
1802
+ if ixmax < ixmin or iymax < iymin:
1803
+ self.logger.warning("Invalid ROI coordinates: Empty region")
1804
+ return
1805
+
1806
+ # Validate against data dimensions
1807
+ if self._original_data is None:
1808
+ self.logger.error("No data loaded")
1809
+ return
1810
+
1811
+ # Save current state for undo
1812
+ self.undo_stack.append(self._original_data.copy())
1813
+ self.redo_stack.clear()
1814
+
1815
+ # Apply the mask
1816
+ self._original_data[ixmin:ixmax+1, iymin:iymax+1] = np.nan
1817
+
1818
+ # Update the display
1819
+ self.canvas.set_data(self._original_data, self._time_axis, self._freq_axis)
1820
+ self.canvas.draw_spectrum()
1821
+
1822
+ # Update status
1823
+ self.statusBar.showMessage(f"ROI masked: time=[{ixmin},{ixmax}], freq=[{iymin},{iymax}] => NaN", 5000)
1824
+ self.logger.info("ROI masked successfully")
1825
+ except Exception as e:
1826
+ self.logger.exception(f"Error masking ROI: {e}")
1827
+ QMessageBox.warning(self, "Error", f"Failed to mask region: {str(e)}")
1828
+ # Disable ROI selector on error
1829
+ self.roiButton.setChecked(False)
1830
+ self.canvas.enable_roi_selector(False)
1831
+ self.statusBar.showMessage("ROI masking failed", 5000)
1832
+
1833
+ # ----------------------- Scale / Colormap Controls --------------------------
1834
+ @pyqtSlot(str)
1835
+ def onScaleModeChanged(self, mode):
1836
+ self.canvas.set_scale_mode(mode)
1837
+ if mode == "Gamma":
1838
+ self.gammaLabel.setEnabled(True)
1839
+ self.gammaSlider.setEnabled(True)
1840
+ else:
1841
+ self.gammaLabel.setEnabled(False)
1842
+ self.gammaSlider.setEnabled(False)
1843
+
1844
+ @pyqtSlot(int)
1845
+ def onGammaChanged(self, sliderValue):
1846
+ gamma = sliderValue / 100.0
1847
+ self.canvas.set_gamma(gamma)
1848
+
1849
+ @pyqtSlot(str)
1850
+ def onCmapChanged(self, cmap_name):
1851
+ self.canvas.set_cmap(cmap_name)
1852
+
1853
+ @pyqtSlot(str)
1854
+ def onScaleRangeChanged(self, scale_option):
1855
+ """Handle changes to the scale range dropdown."""
1856
+ self.canvas.set_smart_scale(scale_option)
1857
+
1858
+ # ------------------------- ROI / Cross-Section -----------------------------
1859
+ def onRoiToggled(self, checked):
1860
+ if self._original_data is None:
1861
+ QMessageBox.information(self, "Info", "No data loaded.")
1862
+ self.roiButton.setChecked(False)
1863
+ return
1864
+
1865
+ try:
1866
+ if checked:
1867
+ self.logger.info("Enabling ROI selector...")
1868
+ self.canvas.enable_roi_selector(True, self._mask_roi_values)
1869
+ self.statusBar.showMessage("Mask Region ON: Click and drag to select an area to mask.")
1870
+ self.logger.info("ROI selector enabled successfully")
1871
+ else:
1872
+ self.logger.info("Disabling ROI selector...")
1873
+ self.canvas.enable_roi_selector(False)
1874
+ self.statusBar.clearMessage()
1875
+ self.logger.info("ROI selector disabled")
1876
+ except Exception as e:
1877
+ # Handle any exceptions and inform the user
1878
+ error_msg = f"There was an error with the ROI selector: {str(e)}"
1879
+ self.logger.exception(error_msg)
1880
+ QMessageBox.warning(
1881
+ self,
1882
+ "ROI Selection Error",
1883
+ f"{error_msg}\n\n"
1884
+ "This may be due to compatibility issues with your Matplotlib version.\n"
1885
+ "Please try updating Matplotlib or using a different selection method."
1886
+ )
1887
+
1888
+ # Reset button state
1889
+ self.roiButton.setChecked(False)
1890
+
1891
+ # Disable the selector and clean up
1892
+ try:
1893
+ self.canvas.roi_active = False
1894
+ if hasattr(self.canvas, 'rect_selector') and self.canvas.rect_selector is not None:
1895
+ self.canvas.rect_selector.set_active(False)
1896
+ if hasattr(self.canvas.rect_selector, 'disconnect_events'):
1897
+ self.canvas.rect_selector.disconnect_events()
1898
+ self.canvas.rect_selector = None
1899
+ except Exception as cleanup_error:
1900
+ self.logger.error(f"Error during cleanup: {cleanup_error}")
1901
+
1902
+ # Update the status bar
1903
+ self.statusBar.showMessage("ROI selection disabled due to an error", 5000)
1904
+
1905
+ def onHoverInfoToggled(self, checked):
1906
+ """Toggle the mouse hover info display on/off."""
1907
+ if hasattr(self.canvas, 'hover_info_enabled'):
1908
+ self.canvas.hover_info_enabled = checked
1909
+ else:
1910
+ # For backwards compatibility, set the attribute
1911
+ self.canvas.hover_info_enabled = checked
1912
+
1913
+ # Clear hover text and reset cache if disabled
1914
+ if not checked and hasattr(self.canvas, '_hover_text'):
1915
+ self.canvas._hover_text.set_text("")
1916
+ if hasattr(self.canvas, '_last_hover_text'):
1917
+ self.canvas._last_hover_text = ""
1918
+ self.canvas.draw_idle()
1919
+
1920
+ def onHoverFrequencyChanged(self, value):
1921
+ """Update the hover throttle limit for performance tuning."""
1922
+ if hasattr(self.canvas, '_hover_throttle_limit'):
1923
+ self.canvas._hover_throttle_limit = value
1924
+ # Reset counter to apply change immediately
1925
+ if hasattr(self.canvas, '_hover_throttle_counter'):
1926
+ self.canvas._hover_throttle_counter = 0
1927
+
1928
+ def onCrossSectionToggled(self, checked):
1929
+ if self._original_data is None:
1930
+ QMessageBox.information(self, "Info", "No data loaded.")
1931
+ self.crossSectionBtn.setChecked(False)
1932
+ return
1933
+ self.canvas.enable_cross_section(checked)
1934
+ if checked:
1935
+ self.statusBar.showMessage("Cross-section mode ON. Click on data.")
1936
+ else:
1937
+ self.statusBar.clearMessage()
1938
+
1939
+ def resizeEvent(self, event):
1940
+ """Handle window resize events to ensure canvas redraws properly"""
1941
+ super().resizeEvent(event)
1942
+ # Use a timer to avoid excessive redrawing during resize
1943
+ if self.resizeTimer is not None:
1944
+ self.resizeTimer.stop()
1945
+
1946
+ # Wait for resize to complete before redrawing
1947
+ from PyQt5.QtCore import QTimer
1948
+ self.resizeTimer = QTimer()
1949
+ self.resizeTimer.setSingleShot(True)
1950
+ self.resizeTimer.timeout.connect(self._delayed_redraw)
1951
+ self.resizeTimer.start(200) # 200ms delay
1952
+
1953
+ def _delayed_redraw(self):
1954
+ """Redraw the canvas after resize is complete"""
1955
+ if hasattr(self, 'canvas') and self.canvas._data is not None:
1956
+ self.canvas.draw_idle()
1957
+
1958
+ ###############################################################################
1959
+ # MAIN #
1960
+ ###############################################################################
1961
+
1962
+ def main():
1963
+ """Entry point for viewds command."""
1964
+ app = QApplication(sys.argv)
1965
+
1966
+ # Apply dark theme from solarviewer
1967
+ from solar_radio_image_viewer.from_simpl.simpl_theme import apply_theme
1968
+ apply_theme(app, "dark")
1969
+
1970
+ w = MainWindow(theme="dark")
1971
+ w.show()
1972
+ sys.exit(app.exec_())
1973
+
1974
+ if __name__ == '__main__':
1975
+ main()