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,656 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Video utilities for advanced video creation features.
|
|
5
|
+
|
|
6
|
+
This module provides:
|
|
7
|
+
- WCS coordinate detection and axes creation
|
|
8
|
+
- Min/Max timeline plotting
|
|
9
|
+
- Contour video processing
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import matplotlib.pyplot as plt
|
|
14
|
+
from matplotlib.patches import Rectangle
|
|
15
|
+
from astropy.io import fits
|
|
16
|
+
from astropy.wcs import WCS
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# =============================================================================
|
|
23
|
+
# WCS Coordinate Detection and Axes
|
|
24
|
+
# =============================================================================
|
|
25
|
+
|
|
26
|
+
def detect_coordinate_system(fits_file):
|
|
27
|
+
"""
|
|
28
|
+
Detect the coordinate system from a FITS file.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
fits_file : str
|
|
33
|
+
Path to the FITS file
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
dict
|
|
38
|
+
Dictionary with:
|
|
39
|
+
- 'type': 'radec', 'helioprojective', or 'pixel'
|
|
40
|
+
- 'wcs': WCS object if available, None otherwise
|
|
41
|
+
- 'ctype1', 'ctype2': Coordinate type strings
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
with fits.open(fits_file) as hdul:
|
|
45
|
+
header = hdul[0].header
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
wcs_obj = WCS(header, naxis=2)
|
|
49
|
+
except Exception:
|
|
50
|
+
wcs_obj = None
|
|
51
|
+
|
|
52
|
+
ctype1 = header.get('CTYPE1', '').upper()
|
|
53
|
+
ctype2 = header.get('CTYPE2', '').upper()
|
|
54
|
+
|
|
55
|
+
# Detect coordinate system
|
|
56
|
+
if 'HPLN' in ctype1 or 'HPLT' in ctype1:
|
|
57
|
+
coord_type = 'helioprojective'
|
|
58
|
+
elif 'RA' in ctype1 or 'DEC' in ctype1:
|
|
59
|
+
coord_type = 'radec'
|
|
60
|
+
elif 'GLON' in ctype1 or 'GLAT' in ctype1:
|
|
61
|
+
coord_type = 'galactic'
|
|
62
|
+
else:
|
|
63
|
+
coord_type = 'pixel'
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
'type': coord_type,
|
|
67
|
+
'wcs': wcs_obj,
|
|
68
|
+
'ctype1': ctype1,
|
|
69
|
+
'ctype2': ctype2,
|
|
70
|
+
'cunit1': header.get('CUNIT1', ''),
|
|
71
|
+
'cunit2': header.get('CUNIT2', ''),
|
|
72
|
+
}
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.error(f"Error detecting coordinate system: {e}")
|
|
75
|
+
return {
|
|
76
|
+
'type': 'pixel',
|
|
77
|
+
'wcs': None,
|
|
78
|
+
'ctype1': '',
|
|
79
|
+
'ctype2': '',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_wcs_axis_labels(coord_info):
|
|
84
|
+
"""
|
|
85
|
+
Get appropriate axis labels based on coordinate system.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
coord_info : dict
|
|
90
|
+
Dictionary from detect_coordinate_system()
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
tuple
|
|
95
|
+
(xlabel, ylabel)
|
|
96
|
+
"""
|
|
97
|
+
coord_type = coord_info.get('type', 'pixel')
|
|
98
|
+
cunit1 = coord_info.get('cunit1', '')
|
|
99
|
+
cunit2 = coord_info.get('cunit2', '')
|
|
100
|
+
|
|
101
|
+
if coord_type == 'helioprojective':
|
|
102
|
+
xlabel = f"Solar-X ({cunit1})" if cunit1 else "Solar-X (arcsec)"
|
|
103
|
+
ylabel = f"Solar-Y ({cunit2})" if cunit2 else "Solar-Y (arcsec)"
|
|
104
|
+
elif coord_type == 'radec':
|
|
105
|
+
xlabel = "Right Ascension"
|
|
106
|
+
ylabel = "Declination"
|
|
107
|
+
elif coord_type == 'galactic':
|
|
108
|
+
xlabel = "Galactic Longitude"
|
|
109
|
+
ylabel = "Galactic Latitude"
|
|
110
|
+
else:
|
|
111
|
+
xlabel = "X (pixels)"
|
|
112
|
+
ylabel = "Y (pixels)"
|
|
113
|
+
|
|
114
|
+
return xlabel, ylabel
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def create_wcs_axes(fig, wcs_obj, subplot_spec=111):
|
|
118
|
+
"""
|
|
119
|
+
Create matplotlib axes with WCS projection.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
fig : matplotlib.figure.Figure
|
|
124
|
+
Figure to add axes to
|
|
125
|
+
wcs_obj : astropy.wcs.WCS
|
|
126
|
+
WCS object for projection
|
|
127
|
+
subplot_spec : int or SubplotSpec
|
|
128
|
+
Subplot specification
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
matplotlib.axes.Axes
|
|
133
|
+
Axes with WCS projection
|
|
134
|
+
"""
|
|
135
|
+
if wcs_obj is not None:
|
|
136
|
+
ax = fig.add_subplot(subplot_spec, projection=wcs_obj)
|
|
137
|
+
else:
|
|
138
|
+
ax = fig.add_subplot(subplot_spec)
|
|
139
|
+
return ax
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# =============================================================================
|
|
143
|
+
# Min/Max Timeline
|
|
144
|
+
# =============================================================================
|
|
145
|
+
|
|
146
|
+
class MinMaxTimeline:
|
|
147
|
+
"""
|
|
148
|
+
Manages min/max timeline plotting for video frames.
|
|
149
|
+
|
|
150
|
+
The timeline shows a continuous plot of min and max pixel values
|
|
151
|
+
across all frames processed so far.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(self, total_frames, position='bottom-left', log_scale=False):
|
|
155
|
+
"""
|
|
156
|
+
Initialize the timeline.
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
total_frames : int
|
|
161
|
+
Total number of frames in the video
|
|
162
|
+
position : str
|
|
163
|
+
Position of the timeline: 'bottom-left', 'bottom-right',
|
|
164
|
+
'top-left', 'top-right'
|
|
165
|
+
log_scale : bool
|
|
166
|
+
If True, use logarithmic scale for y-axis
|
|
167
|
+
"""
|
|
168
|
+
self.total_frames = total_frames
|
|
169
|
+
self.position = position
|
|
170
|
+
self.log_scale = log_scale
|
|
171
|
+
self.min_values = []
|
|
172
|
+
self.max_values = []
|
|
173
|
+
self.frame_numbers = []
|
|
174
|
+
|
|
175
|
+
# Position configurations [left, bottom, width, height]
|
|
176
|
+
self.positions = {
|
|
177
|
+
'bottom-left': [0.05, 0.05, 0.20, 0.10],
|
|
178
|
+
'bottom-right': [0.75, 0.05, 0.20, 0.10],
|
|
179
|
+
'top-left': [0.05, 0.85, 0.20, 0.10],
|
|
180
|
+
'top-right': [0.75, 0.85, 0.20, 0.10],
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
def add_frame_stats(self, frame_idx, vmin, vmax):
|
|
184
|
+
"""
|
|
185
|
+
Add statistics for a frame.
|
|
186
|
+
|
|
187
|
+
Parameters
|
|
188
|
+
----------
|
|
189
|
+
frame_idx : int
|
|
190
|
+
Frame index (0-based)
|
|
191
|
+
vmin : float
|
|
192
|
+
Minimum pixel value for this frame
|
|
193
|
+
vmax : float
|
|
194
|
+
Maximum pixel value for this frame
|
|
195
|
+
"""
|
|
196
|
+
self.frame_numbers.append(frame_idx)
|
|
197
|
+
self.min_values.append(vmin)
|
|
198
|
+
self.max_values.append(vmax)
|
|
199
|
+
|
|
200
|
+
def precompute_stats(self, files, options, load_func):
|
|
201
|
+
"""
|
|
202
|
+
Precompute min/max statistics for all frames.
|
|
203
|
+
|
|
204
|
+
Parameters
|
|
205
|
+
----------
|
|
206
|
+
files : list
|
|
207
|
+
List of file paths
|
|
208
|
+
options : dict
|
|
209
|
+
Video creation options
|
|
210
|
+
load_func : callable
|
|
211
|
+
Function to load FITS data: load_func(file, stokes) -> (data, header)
|
|
212
|
+
|
|
213
|
+
Returns
|
|
214
|
+
-------
|
|
215
|
+
tuple
|
|
216
|
+
(all_mins, all_maxs) lists
|
|
217
|
+
"""
|
|
218
|
+
all_mins = []
|
|
219
|
+
all_maxs = []
|
|
220
|
+
stokes = options.get('stokes', 'I')
|
|
221
|
+
|
|
222
|
+
for file_path in files:
|
|
223
|
+
try:
|
|
224
|
+
data, _ = load_func(file_path, stokes=stokes)
|
|
225
|
+
if data is not None:
|
|
226
|
+
# Apply region if enabled
|
|
227
|
+
if options.get('region_enabled', False):
|
|
228
|
+
x_min = options.get('x_min', 0)
|
|
229
|
+
x_max = options.get('x_max', data.shape[1] - 1)
|
|
230
|
+
y_min = options.get('y_min', 0)
|
|
231
|
+
y_max = options.get('y_max', data.shape[0] - 1)
|
|
232
|
+
data = data[y_min:y_max+1, x_min:x_max+1]
|
|
233
|
+
|
|
234
|
+
all_mins.append(np.nanmin(data))
|
|
235
|
+
all_maxs.append(np.nanmax(data))
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.warning(f"Error computing stats for {file_path}: {e}")
|
|
238
|
+
all_mins.append(np.nan)
|
|
239
|
+
all_maxs.append(np.nan)
|
|
240
|
+
|
|
241
|
+
self.min_values = all_mins
|
|
242
|
+
self.max_values = all_maxs
|
|
243
|
+
self.frame_numbers = list(range(len(files)))
|
|
244
|
+
|
|
245
|
+
return all_mins, all_maxs
|
|
246
|
+
|
|
247
|
+
def draw_timeline(self, fig, current_frame_idx, ax=None):
|
|
248
|
+
"""
|
|
249
|
+
Draw the timeline on the figure.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
fig : matplotlib.figure.Figure
|
|
254
|
+
Figure to draw on
|
|
255
|
+
current_frame_idx : int
|
|
256
|
+
Current frame index (0-based)
|
|
257
|
+
ax : matplotlib.axes.Axes, optional
|
|
258
|
+
If provided, use this axes instead of creating an overlay
|
|
259
|
+
|
|
260
|
+
Returns
|
|
261
|
+
-------
|
|
262
|
+
matplotlib.axes.Axes
|
|
263
|
+
The timeline axes
|
|
264
|
+
"""
|
|
265
|
+
# Use provided axes (dock panel mode) or create overlay (legacy mode)
|
|
266
|
+
if ax is None:
|
|
267
|
+
pos = self.positions.get(self.position, self.positions['bottom-left'])
|
|
268
|
+
ax = fig.add_axes(pos)
|
|
269
|
+
|
|
270
|
+
# Plot data up to current frame
|
|
271
|
+
frames_to_show = self.frame_numbers[:current_frame_idx + 1]
|
|
272
|
+
mins_to_show = self.min_values[:current_frame_idx + 1]
|
|
273
|
+
maxs_to_show = self.max_values[:current_frame_idx + 1]
|
|
274
|
+
|
|
275
|
+
# Plot filled area between min and max (shows range)
|
|
276
|
+
if len(frames_to_show) > 1:
|
|
277
|
+
ax.fill_between(frames_to_show, mins_to_show, maxs_to_show,
|
|
278
|
+
alpha=0.3, color='#4FC3F7', label='Range')
|
|
279
|
+
|
|
280
|
+
# Plot min and max lines
|
|
281
|
+
ax.plot(frames_to_show, mins_to_show, color='#29B6F6', linewidth=1.5, label='Min')
|
|
282
|
+
ax.plot(frames_to_show, maxs_to_show, color='#FF7043', linewidth=1.5, label='Max')
|
|
283
|
+
|
|
284
|
+
# Plot future data as faded lines
|
|
285
|
+
if current_frame_idx < len(self.min_values) - 1:
|
|
286
|
+
future_frames = self.frame_numbers[current_frame_idx:]
|
|
287
|
+
future_mins = self.min_values[current_frame_idx:]
|
|
288
|
+
future_maxs = self.max_values[current_frame_idx:]
|
|
289
|
+
ax.plot(future_frames, future_mins, color='#29B6F6', linewidth=0.8, alpha=0.3)
|
|
290
|
+
ax.plot(future_frames, future_maxs, color='#FF7043', linewidth=0.8, alpha=0.3)
|
|
291
|
+
|
|
292
|
+
# Current frame marker - vertical line and points
|
|
293
|
+
if current_frame_idx < len(self.min_values):
|
|
294
|
+
# Use gray for current frame line to work on both light/dark backgrounds
|
|
295
|
+
ax.axvline(current_frame_idx, color='gray', linestyle='-',
|
|
296
|
+
linewidth=1.5, alpha=0.7)
|
|
297
|
+
ax.plot(current_frame_idx, self.min_values[current_frame_idx],
|
|
298
|
+
'o', color='#29B6F6', markersize=6, markeredgecolor='gray', markeredgewidth=1)
|
|
299
|
+
ax.plot(current_frame_idx, self.max_values[current_frame_idx],
|
|
300
|
+
'o', color='#FF7043', markersize=6, markeredgecolor='gray', markeredgewidth=1)
|
|
301
|
+
|
|
302
|
+
# Set x limits to show full range
|
|
303
|
+
if self.total_frames > 1:
|
|
304
|
+
ax.set_xlim(-0.5, self.total_frames - 0.5)
|
|
305
|
+
|
|
306
|
+
# Set y limits based on all data with margin
|
|
307
|
+
all_vals = self.min_values + self.max_values
|
|
308
|
+
valid_vals = [v for v in all_vals if not np.isnan(v)]
|
|
309
|
+
if valid_vals:
|
|
310
|
+
ymin, ymax = min(valid_vals), max(valid_vals)
|
|
311
|
+
margin = (ymax - ymin) * 0.15 if ymax != ymin else abs(ymax) * 0.1
|
|
312
|
+
ax.set_ylim(ymin - margin, ymax + margin)
|
|
313
|
+
|
|
314
|
+
# Style for dock panel (dynamic theme)
|
|
315
|
+
# ax.set_facecolor('#1a1a2e') # Let theme decide facecolor
|
|
316
|
+
ax.tick_params(labelsize=10, direction='in', length=3)
|
|
317
|
+
|
|
318
|
+
# Show only left and bottom spines (let theme decide color)
|
|
319
|
+
# ax.spines['bottom'].set_color('#555555')
|
|
320
|
+
# ax.spines['left'].set_color('#555555')
|
|
321
|
+
ax.spines['top'].set_visible(False)
|
|
322
|
+
ax.spines['right'].set_visible(False)
|
|
323
|
+
|
|
324
|
+
# X-axis: show frame numbers
|
|
325
|
+
ax.set_xlabel('Frame', fontsize=11, labelpad=2)
|
|
326
|
+
|
|
327
|
+
# Y-axis: show value range with scientific notation if needed
|
|
328
|
+
ax.set_ylabel('Value', fontsize=11, labelpad=2)
|
|
329
|
+
|
|
330
|
+
# Apply log scale if enabled
|
|
331
|
+
if self.log_scale:
|
|
332
|
+
ax.set_yscale('log')
|
|
333
|
+
else:
|
|
334
|
+
ax.yaxis.set_major_locator(plt.MaxNLocator(3)) # Limit to 3 ticks
|
|
335
|
+
|
|
336
|
+
# Format y-axis with scientific notation for large values
|
|
337
|
+
from matplotlib.ticker import ScalarFormatter
|
|
338
|
+
formatter = ScalarFormatter(useMathText=True)
|
|
339
|
+
formatter.set_scientific(True)
|
|
340
|
+
formatter.set_powerlimits((-2, 3))
|
|
341
|
+
ax.yaxis.set_major_formatter(formatter)
|
|
342
|
+
# ax.yaxis.get_offset_text().set_color('white')
|
|
343
|
+
ax.yaxis.get_offset_text().set_fontsize(8)
|
|
344
|
+
|
|
345
|
+
# Add current values annotation
|
|
346
|
+
if current_frame_idx < len(self.min_values):
|
|
347
|
+
curr_min = self.min_values[current_frame_idx]
|
|
348
|
+
curr_max = self.max_values[current_frame_idx]
|
|
349
|
+
info_text = f"Frame {current_frame_idx + 1}/{self.total_frames}"
|
|
350
|
+
ax.text(0.98, 0.92, info_text, transform=ax.transAxes, fontsize=10,
|
|
351
|
+
ha='right', va='top', alpha=0.9)
|
|
352
|
+
|
|
353
|
+
return ax
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# =============================================================================
|
|
357
|
+
# Contour Video Processor
|
|
358
|
+
# =============================================================================
|
|
359
|
+
|
|
360
|
+
class ContourVideoProcessor:
|
|
361
|
+
"""
|
|
362
|
+
Handles contour processing for video creation.
|
|
363
|
+
|
|
364
|
+
Supports three modes:
|
|
365
|
+
- Mode A: Fixed base image, evolving contours
|
|
366
|
+
- Mode B: Fixed contours, evolving colormap image
|
|
367
|
+
- Mode C: Both evolve
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
def __init__(self, mode='A', contour_settings=None):
|
|
371
|
+
"""
|
|
372
|
+
Initialize the contour processor.
|
|
373
|
+
|
|
374
|
+
Parameters
|
|
375
|
+
----------
|
|
376
|
+
mode : str
|
|
377
|
+
'A', 'B', or 'C'
|
|
378
|
+
contour_settings : dict
|
|
379
|
+
Contour configuration dictionary
|
|
380
|
+
"""
|
|
381
|
+
self.mode = mode.upper()
|
|
382
|
+
self.contour_settings = contour_settings or self._default_settings()
|
|
383
|
+
|
|
384
|
+
# Cache for base image / contour data
|
|
385
|
+
self.base_image_data = None
|
|
386
|
+
self.base_image_wcs = None
|
|
387
|
+
self.fixed_contour_data = None
|
|
388
|
+
self.fixed_contour_wcs = None
|
|
389
|
+
self.fixed_contour_levels = None
|
|
390
|
+
|
|
391
|
+
def _default_settings(self):
|
|
392
|
+
"""Return default contour settings."""
|
|
393
|
+
return {
|
|
394
|
+
'level_type': 'fraction', # 'fraction', 'sigma', 'absolute'
|
|
395
|
+
'pos_levels': [0.1, 0.3, 0.5, 0.7, 0.9],
|
|
396
|
+
'neg_levels': [0.1, 0.3, 0.5, 0.7, 0.9],
|
|
397
|
+
'pos_color': 'white',
|
|
398
|
+
'neg_color': 'cyan',
|
|
399
|
+
'linewidth': 1.0,
|
|
400
|
+
'pos_linestyle': '-',
|
|
401
|
+
'neg_linestyle': '--',
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
def load_base_image(self, file_path, stokes='I', load_func=None):
|
|
405
|
+
"""Load the fixed base image (for mode A)."""
|
|
406
|
+
if load_func is None:
|
|
407
|
+
from .create_video import load_fits_data
|
|
408
|
+
load_func = load_fits_data
|
|
409
|
+
|
|
410
|
+
self.base_image_data, header = load_func(file_path, stokes=stokes)
|
|
411
|
+
|
|
412
|
+
# Create WCS from the same header used for data loading
|
|
413
|
+
if header:
|
|
414
|
+
try:
|
|
415
|
+
self.base_image_wcs = WCS(header, naxis=2)
|
|
416
|
+
logger.info(f"Loaded base_image_wcs: CRPIX={self.base_image_wcs.wcs.crpix}")
|
|
417
|
+
except Exception as e:
|
|
418
|
+
logger.warning(f"Failed to create base_image_wcs: {e}")
|
|
419
|
+
self.base_image_wcs = None
|
|
420
|
+
else:
|
|
421
|
+
self.base_image_wcs = None
|
|
422
|
+
|
|
423
|
+
def load_fixed_contour(self, file_path, stokes='I', load_func=None):
|
|
424
|
+
"""Load the fixed contour data (for mode B)."""
|
|
425
|
+
if load_func is None:
|
|
426
|
+
from .create_video import load_fits_data
|
|
427
|
+
load_func = load_fits_data
|
|
428
|
+
|
|
429
|
+
self.fixed_contour_data, header = load_func(file_path, stokes=stokes)
|
|
430
|
+
|
|
431
|
+
# Create WCS from the same header used for data loading
|
|
432
|
+
if header:
|
|
433
|
+
try:
|
|
434
|
+
self.fixed_contour_wcs = WCS(header, naxis=2)
|
|
435
|
+
except Exception:
|
|
436
|
+
self.fixed_contour_wcs = None
|
|
437
|
+
else:
|
|
438
|
+
self.fixed_contour_wcs = None
|
|
439
|
+
|
|
440
|
+
# Pre-compute levels
|
|
441
|
+
self._compute_contour_levels(self.fixed_contour_data)
|
|
442
|
+
|
|
443
|
+
def _compute_contour_levels(self, data):
|
|
444
|
+
"""Compute contour levels based on settings."""
|
|
445
|
+
settings = self.contour_settings
|
|
446
|
+
level_type = settings.get('level_type', 'fraction')
|
|
447
|
+
pos_levels = settings.get('pos_levels', [])
|
|
448
|
+
neg_levels = settings.get('neg_levels', [])
|
|
449
|
+
|
|
450
|
+
abs_max = np.nanmax(np.abs(data))
|
|
451
|
+
|
|
452
|
+
if level_type == 'fraction':
|
|
453
|
+
pos = sorted([level * abs_max for level in pos_levels])
|
|
454
|
+
neg = sorted([-level * abs_max for level in neg_levels])
|
|
455
|
+
elif level_type == 'sigma':
|
|
456
|
+
# Use bottom 10% of image (full width, bottom 10% height) for RMS calculation
|
|
457
|
+
# This avoids including the sun in the noise estimate
|
|
458
|
+
height = data.shape[0]
|
|
459
|
+
bottom_10_pct = max(1, int(height * 0.1)) # At least 1 row
|
|
460
|
+
noise_region = data[:bottom_10_pct, :] # Bottom rows (low y indices)
|
|
461
|
+
rms = np.nanstd(noise_region)
|
|
462
|
+
pos = sorted([level * rms for level in pos_levels])
|
|
463
|
+
neg = sorted([-level * rms for level in neg_levels])
|
|
464
|
+
else: # absolute
|
|
465
|
+
pos = sorted(pos_levels)
|
|
466
|
+
neg = sorted([-level for level in neg_levels])
|
|
467
|
+
|
|
468
|
+
self.fixed_contour_levels = {'pos': pos, 'neg': neg}
|
|
469
|
+
return self.fixed_contour_levels
|
|
470
|
+
|
|
471
|
+
def compute_contour_levels(self, data):
|
|
472
|
+
"""
|
|
473
|
+
Compute contour levels for given data.
|
|
474
|
+
|
|
475
|
+
Parameters
|
|
476
|
+
----------
|
|
477
|
+
data : ndarray
|
|
478
|
+
Image data
|
|
479
|
+
|
|
480
|
+
Returns
|
|
481
|
+
-------
|
|
482
|
+
dict
|
|
483
|
+
{'pos': [...], 'neg': [...]}
|
|
484
|
+
"""
|
|
485
|
+
return self._compute_contour_levels(data)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def draw_contours(self, ax, contour_data, levels=None, target_wcs=None,
|
|
489
|
+
contour_wcs=None, target_shape=None, region_info=None):
|
|
490
|
+
"""
|
|
491
|
+
Draw contours on axes with support for WCS reprojection.
|
|
492
|
+
|
|
493
|
+
Parameters
|
|
494
|
+
----------
|
|
495
|
+
region_info : dict, optional
|
|
496
|
+
If provided, contains 'x_min', 'y_min', 'x_max', 'y_max', 'full_shape'
|
|
497
|
+
Used to reproject to full shape first, then crop.
|
|
498
|
+
"""
|
|
499
|
+
settings = self.contour_settings
|
|
500
|
+
|
|
501
|
+
if levels is None:
|
|
502
|
+
levels = self.compute_contour_levels(contour_data)
|
|
503
|
+
|
|
504
|
+
collections = []
|
|
505
|
+
# Reprojection logic
|
|
506
|
+
if target_wcs and contour_wcs and target_shape is not None:
|
|
507
|
+
try:
|
|
508
|
+
from reproject import reproject_interp
|
|
509
|
+
from astropy.wcs import WCS
|
|
510
|
+
|
|
511
|
+
# Create axis-swapped WCS objects matching viewer.py's approach
|
|
512
|
+
def create_swapped_wcs(orig_wcs):
|
|
513
|
+
"""Create WCS with swapped axes for reprojection."""
|
|
514
|
+
swapped = WCS(naxis=2)
|
|
515
|
+
swapped.wcs.crpix = [orig_wcs.wcs.crpix[1], orig_wcs.wcs.crpix[0]]
|
|
516
|
+
swapped.wcs.crval = [orig_wcs.wcs.crval[1], orig_wcs.wcs.crval[0]]
|
|
517
|
+
try:
|
|
518
|
+
swapped.wcs.cdelt = [orig_wcs.wcs.cdelt[1], orig_wcs.wcs.cdelt[0]]
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
if orig_wcs.wcs.ctype[0] and orig_wcs.wcs.ctype[1]:
|
|
522
|
+
swapped.wcs.ctype = [orig_wcs.wcs.ctype[1], orig_wcs.wcs.ctype[0]]
|
|
523
|
+
return swapped
|
|
524
|
+
|
|
525
|
+
# Swap both WCS for consistent reprojection
|
|
526
|
+
target_wcs_swapped = create_swapped_wcs(target_wcs)
|
|
527
|
+
contour_wcs_swapped = create_swapped_wcs(contour_wcs)
|
|
528
|
+
|
|
529
|
+
# If region_info provided, reproject to FULL shape first, then crop
|
|
530
|
+
if region_info:
|
|
531
|
+
full_shape = region_info.get('full_shape', target_shape)
|
|
532
|
+
|
|
533
|
+
# Reproject to full base image shape
|
|
534
|
+
reprojected_data, footprint = reproject_interp(
|
|
535
|
+
(contour_data, contour_wcs_swapped),
|
|
536
|
+
target_wcs_swapped,
|
|
537
|
+
shape_out=full_shape
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# Then crop to the region
|
|
541
|
+
x_min = region_info.get('x_min', 0)
|
|
542
|
+
y_min = region_info.get('y_min', 0)
|
|
543
|
+
x_max = region_info.get('x_max', full_shape[1] - 1)
|
|
544
|
+
y_max = region_info.get('y_max', full_shape[0] - 1)
|
|
545
|
+
reprojected_data = reprojected_data[y_min:y_max+1, x_min:x_max+1]
|
|
546
|
+
else:
|
|
547
|
+
# No region - reproject directly to target shape
|
|
548
|
+
reprojected_data, footprint = reproject_interp(
|
|
549
|
+
(contour_data, contour_wcs_swapped),
|
|
550
|
+
target_wcs_swapped,
|
|
551
|
+
shape_out=target_shape
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# Log reprojection result
|
|
555
|
+
nan_pct = 100 * np.isnan(reprojected_data).sum() / reprojected_data.size
|
|
556
|
+
logger.info(f"[REPROJ] Result: {nan_pct:.1f}% NaNs")
|
|
557
|
+
|
|
558
|
+
# Check validity of reprojected data
|
|
559
|
+
is_all_nan = np.all(np.isnan(reprojected_data))
|
|
560
|
+
is_all_zero = np.all(np.nan_to_num(reprojected_data) == 0)
|
|
561
|
+
|
|
562
|
+
if is_all_nan or is_all_zero:
|
|
563
|
+
logger.warning("Reprojection yielded empty data. Falling back to pixel overlay.")
|
|
564
|
+
else:
|
|
565
|
+
contour_data = reprojected_data
|
|
566
|
+
|
|
567
|
+
except Exception as e:
|
|
568
|
+
logger.warning(f"Contour reprojection failed: {e}. Falling back to pixel overlay.")
|
|
569
|
+
|
|
570
|
+
# Draw positive contours
|
|
571
|
+
if levels.get('pos'):
|
|
572
|
+
try:
|
|
573
|
+
cs_pos = ax.contour(
|
|
574
|
+
contour_data,
|
|
575
|
+
levels=levels['pos'],
|
|
576
|
+
colors=settings.get('pos_color', 'white'),
|
|
577
|
+
linewidths=settings.get('linewidth', 1.0),
|
|
578
|
+
linestyles=settings.get('pos_linestyle', '-'),
|
|
579
|
+
)
|
|
580
|
+
collections.append(cs_pos)
|
|
581
|
+
except Exception as e:
|
|
582
|
+
logger.warning(f"Error drawing positive contours: {e}")
|
|
583
|
+
|
|
584
|
+
# Draw negative contours
|
|
585
|
+
if levels.get('neg'):
|
|
586
|
+
try:
|
|
587
|
+
cs_neg = ax.contour(
|
|
588
|
+
contour_data,
|
|
589
|
+
levels=levels['neg'],
|
|
590
|
+
colors=settings.get('neg_color', 'cyan'),
|
|
591
|
+
linewidths=settings.get('linewidth', 1.0),
|
|
592
|
+
linestyles=settings.get('neg_linestyle', '--'),
|
|
593
|
+
)
|
|
594
|
+
collections.append(cs_neg)
|
|
595
|
+
except Exception as e:
|
|
596
|
+
logger.warning(f"Error drawing negative contours: {e}")
|
|
597
|
+
|
|
598
|
+
return collections
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def get_frame_data(self, frame_idx, colormap_files=None, contour_files=None,
|
|
602
|
+
stokes='I', load_func=None):
|
|
603
|
+
"""
|
|
604
|
+
Get data for a specific frame based on mode.
|
|
605
|
+
|
|
606
|
+
Parameters
|
|
607
|
+
----------
|
|
608
|
+
frame_idx : int
|
|
609
|
+
Frame index
|
|
610
|
+
colormap_files : list
|
|
611
|
+
List of colormap image files
|
|
612
|
+
contour_files : list
|
|
613
|
+
List of contour image files
|
|
614
|
+
stokes : str
|
|
615
|
+
Stokes parameter
|
|
616
|
+
load_func : callable
|
|
617
|
+
Function to load FITS data
|
|
618
|
+
|
|
619
|
+
Returns
|
|
620
|
+
-------
|
|
621
|
+
dict
|
|
622
|
+
{'colormap_data': ..., 'contour_data': ..., 'contour_levels': ...}
|
|
623
|
+
"""
|
|
624
|
+
if load_func is None:
|
|
625
|
+
from .create_video import load_fits_data
|
|
626
|
+
load_func = load_fits_data
|
|
627
|
+
|
|
628
|
+
result = {}
|
|
629
|
+
|
|
630
|
+
if self.mode == 'A':
|
|
631
|
+
# Fixed base, evolving contours
|
|
632
|
+
result['colormap_data'] = self.base_image_data
|
|
633
|
+
if contour_files and frame_idx < len(contour_files):
|
|
634
|
+
data, _ = load_func(contour_files[frame_idx], stokes=stokes)
|
|
635
|
+
result['contour_data'] = data
|
|
636
|
+
result['contour_levels'] = self.compute_contour_levels(data)
|
|
637
|
+
|
|
638
|
+
elif self.mode == 'B':
|
|
639
|
+
# Fixed contours, evolving colormap
|
|
640
|
+
result['contour_data'] = self.fixed_contour_data
|
|
641
|
+
result['contour_levels'] = self.fixed_contour_levels
|
|
642
|
+
if colormap_files and frame_idx < len(colormap_files):
|
|
643
|
+
data, _ = load_func(colormap_files[frame_idx], stokes=stokes)
|
|
644
|
+
result['colormap_data'] = data
|
|
645
|
+
|
|
646
|
+
elif self.mode == 'C':
|
|
647
|
+
# Both evolve
|
|
648
|
+
if colormap_files and frame_idx < len(colormap_files):
|
|
649
|
+
data, _ = load_func(colormap_files[frame_idx], stokes=stokes)
|
|
650
|
+
result['colormap_data'] = data
|
|
651
|
+
if contour_files and frame_idx < len(contour_files):
|
|
652
|
+
data, _ = load_func(contour_files[frame_idx], stokes=stokes)
|
|
653
|
+
result['contour_data'] = data
|
|
654
|
+
result['contour_levels'] = self.compute_contour_levels(data)
|
|
655
|
+
|
|
656
|
+
return result
|