solarviewer 1.0.2__py3-none-any.whl

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