solarviewer 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solar_radio_image_viewer/__init__.py +12 -0
- solar_radio_image_viewer/assets/add_tab_default.png +0 -0
- solar_radio_image_viewer/assets/add_tab_default_light.png +0 -0
- solar_radio_image_viewer/assets/add_tab_hover.png +0 -0
- solar_radio_image_viewer/assets/add_tab_hover_light.png +0 -0
- solar_radio_image_viewer/assets/browse.png +0 -0
- solar_radio_image_viewer/assets/browse_light.png +0 -0
- solar_radio_image_viewer/assets/close_tab_default.png +0 -0
- solar_radio_image_viewer/assets/close_tab_default_light.png +0 -0
- solar_radio_image_viewer/assets/close_tab_hover.png +0 -0
- solar_radio_image_viewer/assets/close_tab_hover_light.png +0 -0
- solar_radio_image_viewer/assets/ellipse_selection.png +0 -0
- solar_radio_image_viewer/assets/ellipse_selection_light.png +0 -0
- solar_radio_image_viewer/assets/icons8-ellipse-90.png +0 -0
- solar_radio_image_viewer/assets/icons8-ellipse-90_light.png +0 -0
- solar_radio_image_viewer/assets/icons8-info-90.png +0 -0
- solar_radio_image_viewer/assets/icons8-info-90_light.png +0 -0
- solar_radio_image_viewer/assets/profile.png +0 -0
- solar_radio_image_viewer/assets/profile_light.png +0 -0
- solar_radio_image_viewer/assets/rectangle_selection.png +0 -0
- solar_radio_image_viewer/assets/rectangle_selection_light.png +0 -0
- solar_radio_image_viewer/assets/reset.png +0 -0
- solar_radio_image_viewer/assets/reset_light.png +0 -0
- solar_radio_image_viewer/assets/ruler.png +0 -0
- solar_radio_image_viewer/assets/ruler_light.png +0 -0
- solar_radio_image_viewer/assets/search.png +0 -0
- solar_radio_image_viewer/assets/search_light.png +0 -0
- solar_radio_image_viewer/assets/settings.png +0 -0
- solar_radio_image_viewer/assets/settings_light.png +0 -0
- solar_radio_image_viewer/assets/splash.fits +0 -0
- solar_radio_image_viewer/assets/zoom_60arcmin.png +0 -0
- solar_radio_image_viewer/assets/zoom_60arcmin_light.png +0 -0
- solar_radio_image_viewer/assets/zoom_in.png +0 -0
- solar_radio_image_viewer/assets/zoom_in_light.png +0 -0
- solar_radio_image_viewer/assets/zoom_out.png +0 -0
- solar_radio_image_viewer/assets/zoom_out_light.png +0 -0
- solar_radio_image_viewer/create_video.py +1345 -0
- solar_radio_image_viewer/dialogs.py +2665 -0
- solar_radio_image_viewer/from_simpl/__init__.py +184 -0
- solar_radio_image_viewer/from_simpl/caltable_visualizer.py +1001 -0
- solar_radio_image_viewer/from_simpl/dynamic_spectra_dialog.py +332 -0
- solar_radio_image_viewer/from_simpl/make_dynamic_spectra.py +351 -0
- solar_radio_image_viewer/from_simpl/pipeline_logger_gui.py +1232 -0
- solar_radio_image_viewer/from_simpl/simpl_theme.py +352 -0
- solar_radio_image_viewer/from_simpl/utils.py +984 -0
- solar_radio_image_viewer/from_simpl/view_dynamic_spectra_GUI.py +1975 -0
- solar_radio_image_viewer/helioprojective.py +1916 -0
- solar_radio_image_viewer/helioprojective_viewer.py +817 -0
- solar_radio_image_viewer/helioviewer_browser.py +1514 -0
- solar_radio_image_viewer/main.py +148 -0
- solar_radio_image_viewer/move_phasecenter.py +1269 -0
- solar_radio_image_viewer/napari_viewer.py +368 -0
- solar_radio_image_viewer/noaa_events/__init__.py +32 -0
- solar_radio_image_viewer/noaa_events/noaa_events.py +430 -0
- solar_radio_image_viewer/noaa_events/noaa_events_gui.py +1922 -0
- solar_radio_image_viewer/norms.py +293 -0
- solar_radio_image_viewer/radio_data_downloader/__init__.py +25 -0
- solar_radio_image_viewer/radio_data_downloader/radio_data_downloader.py +756 -0
- solar_radio_image_viewer/radio_data_downloader/radio_data_downloader_gui.py +528 -0
- solar_radio_image_viewer/searchable_combobox.py +220 -0
- solar_radio_image_viewer/solar_context/__init__.py +41 -0
- solar_radio_image_viewer/solar_context/active_regions.py +371 -0
- solar_radio_image_viewer/solar_context/cme_alerts.py +234 -0
- solar_radio_image_viewer/solar_context/context_images.py +297 -0
- solar_radio_image_viewer/solar_context/realtime_data.py +528 -0
- solar_radio_image_viewer/solar_data_downloader/__init__.py +35 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader.py +1667 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_cli.py +901 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_gui.py +1210 -0
- solar_radio_image_viewer/styles.py +643 -0
- solar_radio_image_viewer/utils/__init__.py +32 -0
- solar_radio_image_viewer/utils/rate_limiter.py +255 -0
- solar_radio_image_viewer/utils.py +952 -0
- solar_radio_image_viewer/video_dialog.py +2629 -0
- solar_radio_image_viewer/video_utils.py +656 -0
- solar_radio_image_viewer/viewer.py +11174 -0
- solarviewer-1.0.2.dist-info/METADATA +343 -0
- solarviewer-1.0.2.dist-info/RECORD +82 -0
- solarviewer-1.0.2.dist-info/WHEEL +5 -0
- solarviewer-1.0.2.dist-info/entry_points.txt +8 -0
- solarviewer-1.0.2.dist-info/licenses/LICENSE +21 -0
- solarviewer-1.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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()
|