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,1345 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import os
5
+ import glob
6
+ import re
7
+ import numpy as np
8
+ from datetime import datetime
9
+ import matplotlib.pyplot as plt
10
+ from matplotlib.colors import Normalize, LogNorm, PowerNorm
11
+ from matplotlib.figure import Figure
12
+ from matplotlib.gridspec import GridSpec
13
+ from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
14
+ import matplotlib.dates as mdates
15
+ from astropy.io import fits
16
+ import imageio
17
+ from tqdm import tqdm
18
+ from pathlib import Path
19
+ import threading
20
+ import time
21
+ from multiprocessing import Pool, cpu_count
22
+ from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
23
+ import concurrent.futures # Add this import for as_completed
24
+ from astropy.visualization import (
25
+ ImageNormalize,
26
+ LinearStretch,
27
+ LogStretch,
28
+ SqrtStretch,
29
+ PowerStretch,
30
+ )
31
+ import warnings
32
+ from functools import partial
33
+ import logging
34
+
35
+ # Import custom norms
36
+ try:
37
+ from .norms import (
38
+ SqrtNorm,
39
+ AsinhNorm,
40
+ PowerNorm as CustomPowerNorm,
41
+ ZScaleNorm,
42
+ HistEqNorm,
43
+ )
44
+ from .video_utils import (
45
+ detect_coordinate_system,
46
+ get_wcs_axis_labels,
47
+ create_wcs_axes,
48
+ MinMaxTimeline,
49
+ ContourVideoProcessor,
50
+ )
51
+ except ImportError:
52
+ from norms import (
53
+ SqrtNorm,
54
+ AsinhNorm,
55
+ PowerNorm as CustomPowerNorm,
56
+ ZScaleNorm,
57
+ HistEqNorm,
58
+ )
59
+ from video_utils import (
60
+ detect_coordinate_system,
61
+ get_wcs_axis_labels,
62
+ create_wcs_axes,
63
+ MinMaxTimeline,
64
+ ContourVideoProcessor,
65
+ )
66
+
67
+ # Configure logging
68
+ logging.basicConfig(
69
+ level=logging.INFO,
70
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
71
+ )
72
+ logger = logging.getLogger(__name__)
73
+
74
+ # Suppress common warnings
75
+ warnings.filterwarnings("ignore", category=UserWarning, module="matplotlib")
76
+ warnings.filterwarnings("ignore", category=UserWarning, module="astropy")
77
+
78
+
79
+ def ensure_even_dimensions(image):
80
+ """
81
+ Ensure image has even dimensions by padding if necessary.
82
+ This is required for video codecs like H.264 that require dimensions divisible by 2.
83
+
84
+ Parameters
85
+ ----------
86
+ image : numpy.ndarray
87
+ Input image array
88
+
89
+ Returns
90
+ -------
91
+ numpy.ndarray
92
+ Image with even dimensions
93
+ """
94
+ height, width = image.shape[:2]
95
+ pad_height = 0 if height % 2 == 0 else 1
96
+ pad_width = 0 if width % 2 == 0 else 1
97
+
98
+ if pad_height > 0 or pad_width > 0:
99
+ # Pad to make dimensions even
100
+ return np.pad(image, ((0, pad_height), (0, pad_width), (0, 0)), mode="edge")
101
+ return image
102
+
103
+
104
+ def create_error_frame(options, error_msg="Error loading frame", frame_idx=0, filename=""):
105
+ """
106
+ Create a blank frame with axis and error message when data loading fails.
107
+
108
+ This ensures the video continues with consistent frame count instead of
109
+ showing weird plots or skipping frames entirely.
110
+ """
111
+ dpi = 100
112
+
113
+ # Use specified dimensions or default
114
+ if options.get("width", 0) > 0 and options.get("height", 0) > 0:
115
+ figsize = (options["width"] / dpi, options["height"] / dpi)
116
+ else:
117
+ figsize = (10, 10)
118
+
119
+ fig = Figure(figsize=figsize, dpi=dpi)
120
+ canvas = FigureCanvas(fig)
121
+ ax = fig.add_subplot(111)
122
+
123
+ # Create a blank image (gray background)
124
+ blank_data = np.zeros((100, 100))
125
+ ax.imshow(blank_data, cmap='gray', vmin=0, vmax=1)
126
+
127
+ # Add error message in the center
128
+ ax.text(0.5, 0.5, error_msg, transform=ax.transAxes,
129
+ ha='center', va='center', fontsize=12, color='red',
130
+ bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))
131
+
132
+ # Add frame info
133
+ if filename:
134
+ ax.set_title(f"Frame {frame_idx}: {filename}", fontsize=10)
135
+ else:
136
+ ax.set_title(f"Frame {frame_idx}", fontsize=10)
137
+
138
+ ax.set_xlabel("X")
139
+ ax.set_ylabel("Y")
140
+
141
+ # Render to image
142
+ fig.tight_layout()
143
+ canvas.draw()
144
+
145
+ # Convert to numpy array
146
+ width, height = fig.canvas.get_width_height()
147
+ image = np.frombuffer(fig.canvas.tostring_rgb(), dtype=np.uint8)
148
+ image = image.reshape(height, width, 3)
149
+
150
+ plt.close(fig)
151
+
152
+ return image
153
+
154
+
155
+ def natural_sort_key(s):
156
+ """
157
+ Sort strings containing numbers naturally (e.g., 'file1', 'file2', 'file10')
158
+ """
159
+ return [
160
+ int(text) if text.isdigit() else text.lower() for text in re.split(r"(\d+)", s)
161
+ ]
162
+
163
+
164
+ def extract_datetime(filepath):
165
+ """
166
+ Try to extract datetime from filename or FITS header
167
+ Returns a datetime object if successful, None otherwise
168
+ """
169
+ try:
170
+ # First try to get from FITS header
171
+ with fits.open(filepath) as hdul:
172
+ header = hdul[0].header
173
+ if "DATE-OBS" in header:
174
+ try:
175
+ # Try different date formats
176
+ formats = [
177
+ "%Y-%m-%dT%H:%M:%S.%f",
178
+ "%Y-%m-%dT%H:%M:%S",
179
+ "%Y-%m-%d %H:%M:%S.%f",
180
+ "%Y-%m-%d %H:%M:%S",
181
+ "%Y/%m/%d %H:%M:%S",
182
+ "%Y-%m-%d",
183
+ ]
184
+
185
+ date_str = header["DATE-OBS"]
186
+ for fmt in formats:
187
+ try:
188
+ return datetime.strptime(date_str, fmt)
189
+ except ValueError:
190
+ continue
191
+ except Exception:
192
+ pass
193
+ except Exception:
194
+ pass
195
+
196
+ # Fall back to filename pattern matching
197
+ filename = os.path.basename(filepath)
198
+ patterns = [
199
+ r"(\d{4}[-_]\d{2}[-_]\d{2}[-_T]\d{2}[-_]\d{2}[-_]\d{2})", # 2023-01-01-12-30-00
200
+ r"(\d{4}[-_]\d{2}[-_]\d{2})", # 2023-01-01
201
+ r"(\d{8}[-_]\d{6})", # 20230101_123000
202
+ r"(\d{8})", # 20230101
203
+ ]
204
+
205
+ for pattern in patterns:
206
+ match = re.search(pattern, filename)
207
+ if match:
208
+ date_str = match.group(1)
209
+ formats = [
210
+ "%Y-%m-%d-%H-%M-%S",
211
+ "%Y_%m_%d_%H_%M_%S",
212
+ "%Y-%m-%dT%H-%M-%S",
213
+ "%Y-%m-%d",
214
+ "%Y_%m_%d",
215
+ "%Y%m%d_%H%M%S",
216
+ "%Y%m%d",
217
+ ]
218
+
219
+ for fmt in formats:
220
+ try:
221
+ return datetime.strptime(date_str, fmt)
222
+ except ValueError:
223
+ continue
224
+
225
+ return None
226
+
227
+
228
+ def load_fits_data(filepath, stokes="I"):
229
+ """
230
+ Load data from a FITS file
231
+ """
232
+ try:
233
+ with fits.open(filepath) as hdul:
234
+ header = hdul[0].header
235
+ data = hdul[0].data
236
+
237
+ # Handle data dimensionality
238
+ if data.ndim == 4: # [stokes, freq, y, x]
239
+ # Find the Stokes index
240
+ stokes_index = 0 # default to first index
241
+ if stokes == "I" or stokes == 0:
242
+ stokes_index = 0
243
+ elif stokes == "Q" or stokes == 1:
244
+ stokes_index = 1
245
+ elif stokes == "U" or stokes == 2:
246
+ stokes_index = 2
247
+ elif stokes == "V" or stokes == 3:
248
+ stokes_index = 3
249
+
250
+ # Use first frequency channel
251
+ data = data[stokes_index, 0, :, :]
252
+ elif data.ndim == 3: # [freq, y, x] or [stokes, y, x]
253
+ # Assume [stokes, y, x] format
254
+ stokes_index = 0 # default to first index
255
+ if stokes == "I" or stokes == 0:
256
+ stokes_index = 0
257
+ elif stokes == "Q" or stokes == 1:
258
+ stokes_index = 1
259
+ elif stokes == "U" or stokes == 2:
260
+ stokes_index = 2
261
+ elif stokes == "V" or stokes == 3:
262
+ stokes_index = 3
263
+
264
+ data = data[stokes_index, :, :]
265
+
266
+ return data, header
267
+ except Exception as e:
268
+ logger.error(f"Error loading {filepath}: {e}")
269
+ return None, None
270
+
271
+
272
+ def apply_visualization(data, vmin=None, vmax=None, stretch="linear", gamma=1.0):
273
+ """
274
+ Apply visualization transformations (normalization and stretching)
275
+ """
276
+ if data is None:
277
+ return None
278
+
279
+ # Handle NaN values
280
+ data = np.nan_to_num(data, nan=0.0)
281
+
282
+ # Apply normalization
283
+ if vmin is None:
284
+ vmin = np.percentile(data, 0)
285
+ if vmax is None:
286
+ vmax = np.percentile(data, 100)
287
+
288
+ # Create normalized data according to the stretch
289
+ if stretch == "linear":
290
+ norm = Normalize(vmin=vmin, vmax=vmax)
291
+ normalized_data = norm(data)
292
+ elif stretch == "log":
293
+ # Ensure data is positive for log stretch
294
+ if vmin <= 0:
295
+ vmin = max(np.min(data[data > 0]), 1e-10)
296
+ norm = LogNorm(vmin=vmin, vmax=vmax)
297
+ normalized_data = norm(data)
298
+ elif stretch == "sqrt":
299
+ norm = PowerNorm(gamma=0.5, vmin=vmin, vmax=vmax)
300
+ normalized_data = norm(data)
301
+ elif stretch == "power":
302
+ norm = PowerNorm(gamma=gamma, vmin=vmin, vmax=vmax)
303
+ normalized_data = norm(data)
304
+ else:
305
+ # Default to linear
306
+ norm = Normalize(vmin=vmin, vmax=vmax)
307
+ normalized_data = norm(data)
308
+
309
+ # Ensure range [0, 1]
310
+ normalized_data = np.clip(normalized_data, 0, 1)
311
+
312
+ return normalized_data
313
+
314
+
315
+ def format_timestamp(dt_str):
316
+ """Format timestamp in a consistent way similar to the main application"""
317
+ try:
318
+ # Try different date formats
319
+ formats = [
320
+ "%Y-%m-%dT%H:%M:%S.%f",
321
+ "%Y-%m-%dT%H:%M:%S",
322
+ "%Y-%m-%d %H:%M:%S.%f",
323
+ "%Y-%m-%d %H:%M:%S",
324
+ "%Y/%m/%d %H:%M:%S",
325
+ "%Y-%m-%d",
326
+ ]
327
+
328
+ for fmt in formats:
329
+ try:
330
+ dt = datetime.strptime(dt_str, fmt)
331
+ # Format like "2023-01-01 12:30:45 UTC"
332
+ return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
333
+ except ValueError:
334
+ continue
335
+ except:
336
+ pass
337
+
338
+ # Return original if parsing fails
339
+ return dt_str
340
+
341
+
342
+ def get_norm(stretch_type, vmin, vmax, gamma=1.0):
343
+ """Create a normalization object based on the stretch type"""
344
+ stretch_type = stretch_type.lower()
345
+
346
+ if stretch_type == "linear":
347
+ return Normalize(vmin=vmin, vmax=vmax)
348
+
349
+ elif stretch_type == "log":
350
+ # Ensure positive values for log stretch
351
+ if vmin <= 0:
352
+ vmin = max(1e-10, np.min([1e-10, vmax / 1000]))
353
+ return ImageNormalize(vmin=vmin, vmax=vmax, stretch=LogStretch())
354
+
355
+ elif stretch_type == "sqrt":
356
+ return SqrtNorm(vmin=vmin, vmax=vmax)
357
+
358
+ elif stretch_type == "power":
359
+ return CustomPowerNorm(vmin=vmin, vmax=vmax, gamma=gamma)
360
+
361
+ elif stretch_type == "arcsinh":
362
+ return AsinhNorm(vmin=vmin, vmax=vmax)
363
+
364
+ elif stretch_type == "zscale":
365
+ return ZScaleNorm(vmin=vmin, vmax=vmax)
366
+
367
+ elif stretch_type == "histogram equalization":
368
+ return HistEqNorm(vmin=vmin, vmax=vmax)
369
+
370
+ else:
371
+ # Default to linear if unknown
372
+ logger.warning(f"Unknown stretch type: {stretch_type}, defaulting to linear")
373
+ return Normalize(vmin=vmin, vmax=vmax)
374
+
375
+
376
+ def process_image(file_path, options, global_stats=None, contour_processor=None, frame_idx=0):
377
+ """Process a FITS image and return a frame for the video"""
378
+ try:
379
+ # Load FITS data
380
+ data, header = load_fits_data(file_path, stokes=options.get("stokes", "I"))
381
+
382
+ if data is None:
383
+ logger.warning(f"Could not load data from {file_path}")
384
+ return create_error_frame(options, "Could not load data", frame_idx, os.path.basename(file_path))
385
+
386
+ # Check for blank/all-NaN data
387
+ if np.all(np.isnan(data)) or np.nanmax(data) == np.nanmin(data):
388
+ logger.warning(f"Blank or constant data in {file_path}")
389
+ return create_error_frame(options, "Blank/constant data", frame_idx, os.path.basename(file_path))
390
+
391
+ # Apply region selection if enabled
392
+ if options.get("region_enabled", False):
393
+ # Store original full shape BEFORE cropping for contour reprojection
394
+ options["full_shape"] = data.shape
395
+
396
+ # Get region coordinates
397
+ x_min = options.get("x_min", 0)
398
+ x_max = options.get("x_max", data.shape[1] - 1)
399
+ y_min = options.get("y_min", 0)
400
+ y_max = options.get("y_max", data.shape[0] - 1)
401
+
402
+ # Ensure proper order
403
+ x_min, x_max = min(x_min, x_max), max(x_min, x_max)
404
+ y_min, y_max = min(y_min, y_max), max(y_min, y_max)
405
+
406
+ # Apply consistent region dimensions if provided
407
+ if "region_width" in options and "region_height" in options:
408
+ region_width = options["region_width"]
409
+ region_height = options["region_height"]
410
+
411
+ # Make sure the region doesn't exceed the image dimensions
412
+ if x_min >= data.shape[1] or y_min >= data.shape[0]:
413
+ logger.warning(f"Region outside image bounds for {file_path}")
414
+ x_min = 0
415
+ y_min = 0
416
+ x_max = min(region_width - 1, data.shape[1] - 1)
417
+ y_max = min(region_height - 1, data.shape[0] - 1)
418
+ else:
419
+ # Adjust end points to match the required dimensions
420
+ x_max = min(x_min + region_width - 1, data.shape[1] - 1)
421
+ y_max = min(y_min + region_height - 1, data.shape[0] - 1)
422
+
423
+ # Check boundaries
424
+ x_min = max(0, min(x_min, data.shape[1] - 1))
425
+ x_max = max(0, min(x_max, data.shape[1] - 1))
426
+ y_min = max(0, min(y_min, data.shape[0] - 1))
427
+ y_max = max(0, min(y_max, data.shape[0] - 1))
428
+
429
+ # Extract the region
430
+ data = data[y_min : y_max + 1, x_min : x_max + 1]
431
+
432
+ # Update the region dimensions in options for reference
433
+ options["actual_region_width"] = data.shape[1]
434
+ options["actual_region_height"] = data.shape[0]
435
+
436
+ # Get filename for display
437
+ filename = os.path.basename(file_path)
438
+
439
+ # Create figure
440
+ dpi = 100
441
+
442
+ # Determine figure size based on data dimensions and any resize options
443
+ if options.get("width", 0) > 0 and options.get("height", 0) > 0:
444
+ # Use specified dimensions
445
+ figsize = (options["width"] / dpi, options["height"] / dpi)
446
+ else:
447
+ # Use data dimensions
448
+ # figsize = (data.shape[1] / dpi + 1, data.shape[0] / dpi)
449
+ figsize = (10, 10)
450
+
451
+ fig = Figure(figsize=figsize, dpi=dpi)
452
+ canvas = FigureCanvas(fig)
453
+
454
+ # Create axes - use WCS projection if enabled
455
+ wcs_enabled = options.get("wcs_enabled", False)
456
+ coord_info = options.get("coord_info", None)
457
+
458
+ # Check if timeline is enabled to use GridSpec layout
459
+ timeline_enabled = options.get("minmax_timeline_enabled", False)
460
+ timeline_ax = None
461
+
462
+ if timeline_enabled:
463
+ # Use GridSpec for bottom dock panel layout: 88% image, 12% timeline
464
+ gs = GridSpec(2, 1, height_ratios=[88, 12], hspace=0.25, figure=fig)
465
+
466
+ if wcs_enabled and coord_info and coord_info.get("wcs"):
467
+ ax = fig.add_subplot(gs[0], projection=coord_info["wcs"])
468
+ else:
469
+ ax = fig.add_subplot(gs[0])
470
+
471
+ # Create timeline axes
472
+ timeline_ax = fig.add_subplot(gs[1])
473
+ # timeline_ax.set_facecolor('#1a1a2e') # Removed to support dynamic themes
474
+ else:
475
+ # No timeline - use full figure for image
476
+ if wcs_enabled and coord_info and coord_info.get("wcs"):
477
+ ax = create_wcs_axes(fig, coord_info["wcs"])
478
+ else:
479
+ ax = fig.add_subplot(111)
480
+
481
+ # Determine min/max values based on range_mode
482
+ range_mode = options.get("range_mode", 1) # Default to Auto Per Frame
483
+
484
+ if range_mode == 0: # Fixed Range
485
+ vmin = options.get("vmin", np.nanmin(data))
486
+ vmax = options.get("vmax", np.nanmax(data))
487
+ elif range_mode == 1: # Auto Per Frame
488
+ # Calculate percentiles for this frame
489
+ lower_percentile = options.get("lower_percentile", 0)
490
+ upper_percentile = options.get("upper_percentile", 100)
491
+ vmin = np.nanpercentile(data, lower_percentile)
492
+ vmax = np.nanpercentile(data, upper_percentile)
493
+ else: # Global Auto
494
+ # Use pre-calculated global stats
495
+ if global_stats:
496
+ vmin, vmax = global_stats
497
+ else:
498
+ # Fallback to Auto Per Frame if global stats not available
499
+ lower_percentile = options.get("lower_percentile", 0)
500
+ upper_percentile = options.get("upper_percentile", 100)
501
+ vmin = np.nanpercentile(data, lower_percentile)
502
+ vmax = np.nanpercentile(data, upper_percentile)
503
+
504
+ # Ensure min/max are proper
505
+ if vmin >= vmax:
506
+ vmax = vmin + 1.0
507
+
508
+ # Create normalization
509
+ stretch = options.get("stretch", "linear")
510
+ gamma = options.get("gamma", 1.0)
511
+ norm = get_norm(stretch, vmin, vmax, gamma)
512
+
513
+ # Display the image
514
+ cmap = options.get("colormap", "viridis")
515
+ img = ax.imshow(
516
+ data,
517
+ cmap=cmap,
518
+ norm=norm,
519
+ origin="lower",
520
+ interpolation="none",
521
+ # aspect="auto",
522
+ )
523
+
524
+ # Draw Contours if enabled
525
+ if contour_processor:
526
+ try:
527
+ from astropy.wcs import WCS
528
+ c_data = None
529
+ c_wcs = None
530
+ c_levels = None
531
+
532
+ if contour_processor.mode == 'A':
533
+ # Mode A: Fixed base, evolving contours
534
+ # Get contour data from contour_processor's cached data for this frame
535
+ contour_files = options.get("contour_files", [])
536
+ if frame_idx < len(contour_files):
537
+ c_data, c_header = load_fits_data(contour_files[frame_idx], stokes=options.get("stokes", "I"))
538
+ if c_header:
539
+ c_wcs = WCS(c_header, naxis=2)
540
+
541
+ elif contour_processor.mode == 'B':
542
+ # Mode B: Fixed contours, evolving colormap
543
+ c_data = contour_processor.fixed_contour_data
544
+ c_wcs = contour_processor.fixed_contour_wcs
545
+ c_levels = contour_processor.fixed_contour_levels
546
+
547
+ elif contour_processor.mode == 'C':
548
+ # Mode C: Both evolve
549
+ contour_files = options.get("contour_files", [])
550
+ if frame_idx < len(contour_files):
551
+ c_data, c_header = load_fits_data(contour_files[frame_idx], stokes=options.get("stokes", "I"))
552
+ if c_header:
553
+ c_wcs = WCS(c_header, naxis=2)
554
+
555
+ if c_data is not None:
556
+ # Get target WCS for reprojection
557
+ target_wcs = None
558
+ if contour_processor.mode == 'A' and contour_processor.base_image_wcs:
559
+ target_wcs = contour_processor.base_image_wcs
560
+ elif coord_info and coord_info.get("wcs"):
561
+ target_wcs = coord_info["wcs"]
562
+ elif header:
563
+ try:
564
+ target_wcs = WCS(header, naxis=2)
565
+ except:
566
+ pass
567
+
568
+ # Build region_info if region was applied
569
+ region_info = None
570
+ if options.get("region_enabled", False):
571
+ region_info = {
572
+ 'x_min': options.get('x_min', 0),
573
+ 'y_min': options.get('y_min', 0),
574
+ 'x_max': options.get('x_max', data.shape[1] - 1),
575
+ 'y_max': options.get('y_max', data.shape[0] - 1),
576
+ 'full_shape': options.get('full_shape', data.shape)
577
+ }
578
+
579
+ contour_processor.draw_contours(
580
+ ax,
581
+ c_data,
582
+ levels=c_levels,
583
+ target_wcs=target_wcs,
584
+ contour_wcs=c_wcs,
585
+ target_shape=data.shape,
586
+ region_info=region_info
587
+ )
588
+ else:
589
+ logger.warning(f"Frame {frame_idx}: No contour data to draw")
590
+ except Exception as e:
591
+ logger.error(f"Error drawing contours: {e}")
592
+
593
+ # Handle axis labels based on WCS mode
594
+ if wcs_enabled and coord_info:
595
+ xlabel, ylabel = get_wcs_axis_labels(coord_info)
596
+ ax.set_xlabel(xlabel, fontsize=12)
597
+ ax.set_ylabel(ylabel, fontsize=12)
598
+ ax.tick_params(labelsize=12)
599
+ else:
600
+ # Turn off axis labels and ticks for pixel mode
601
+ ax.set_xticks([])
602
+ ax.set_yticks([])
603
+
604
+
605
+ # Add overlays if requested
606
+ overlay_text = []
607
+
608
+ # Add timestamp if requested
609
+ if options.get("timestamp", False) and header:
610
+ # Extract date from header if available
611
+ timestamp = None
612
+ for key in ["DATE-OBS", "DATE_OBS", "DATE"]:
613
+ if key in header:
614
+ try:
615
+ date_str = header[key]
616
+ # Parse the date string
617
+ if "T" in date_str:
618
+ timestamp = datetime.strptime(
619
+ date_str, "%Y-%m-%dT%H:%M:%S.%f"
620
+ )
621
+ else:
622
+ timestamp = datetime.strptime(
623
+ date_str, "%Y-%m-%d %H:%M:%S.%f"
624
+ )
625
+ break
626
+ except (ValueError, TypeError):
627
+ try:
628
+ # Try alternative format
629
+ timestamp = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
630
+ break
631
+ except (ValueError, TypeError):
632
+ pass
633
+
634
+ if timestamp:
635
+ # Use "Colormap:" prefix in contour mode
636
+ if options.get("contour_video_enabled", False):
637
+ overlay_text.append(f"Colormap: {timestamp.strftime('%Y-%m-%d %H:%M:%S')}")
638
+ else:
639
+ overlay_text.append(f"Time: {timestamp.strftime('%Y-%m-%d %H:%M:%S')}")
640
+ else:
641
+ # Use file modification time as fallback
642
+ mtime = os.path.getmtime(file_path)
643
+ if options.get("contour_video_enabled", False):
644
+ overlay_text.append(
645
+ f"Colormap: {datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S')}"
646
+ )
647
+ else:
648
+ overlay_text.append(
649
+ f"Time: {datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S')}"
650
+ )
651
+
652
+ # Add contour timestamp if in contour mode
653
+ if options.get("timestamp", False) and options.get("contour_video_enabled", False):
654
+ contour_files = options.get("contour_files", [])
655
+ frame_idx = options.get("current_frame", 0)
656
+ if frame_idx < len(contour_files):
657
+ contour_file = contour_files[frame_idx]
658
+ try:
659
+ with fits.open(contour_file) as hdul:
660
+ c_header = hdul[0].header
661
+ for key in ["DATE-OBS", "DATE_OBS", "DATE"]:
662
+ if key in c_header:
663
+ c_date_str = c_header[key]
664
+ if "T" in c_date_str:
665
+ c_ts = datetime.strptime(c_date_str, "%Y-%m-%dT%H:%M:%S.%f")
666
+ else:
667
+ c_ts = datetime.strptime(c_date_str, "%Y-%m-%d %H:%M:%S.%f")
668
+ overlay_text.append(f"Contour: {c_ts.strftime('%Y-%m-%d %H:%M:%S')}")
669
+ break
670
+ except Exception:
671
+ # Use contour filename as fallback
672
+ overlay_text.append(f"Contour: {os.path.basename(contour_file)}")
673
+
674
+ # Add frame number if requested
675
+ if options.get("frame_number", False):
676
+ frame_num = options.get("current_frame", 0) + 1
677
+ total_frames = options.get("total_frames", 0)
678
+ overlay_text.append(f"Frame: {frame_num}/{total_frames}")
679
+
680
+ # Add filename if requested
681
+ if options.get("filename", False):
682
+ # Use "Colormap:" prefix in contour mode
683
+ if options.get("contour_video_enabled", False):
684
+ overlay_text.append(f"Colormap: {filename}")
685
+ else:
686
+ overlay_text.append(f"File: {filename}")
687
+
688
+ # Add contour filename if in contour mode
689
+ if options.get("contour_video_enabled", False):
690
+ contour_files = options.get("contour_files", [])
691
+ frame_idx = options.get("current_frame", 0)
692
+ if frame_idx < len(contour_files):
693
+ contour_filename = os.path.basename(contour_files[frame_idx])
694
+ overlay_text.append(f"Contour: {contour_filename}")
695
+
696
+ # Add text overlay
697
+ if overlay_text:
698
+ # ax.text(
699
+ # 0.98,
700
+ # 0.98,
701
+ # "\n".join(overlay_text),
702
+ # transform=ax.transAxes,
703
+ # fontsize=4,
704
+ # verticalalignment="bottom",
705
+ # bbox=dict(boxstyle="round", facecolor="white", alpha=0.5),
706
+ # horizontalalignment="right",
707
+ # )
708
+ ax.set_title("\n".join(overlay_text))
709
+
710
+ # Add colorbar if requested
711
+ if options.get("colorbar", False):
712
+ import matplotlib.ticker as mticker
713
+
714
+ # Create colorbar with proper sizing that works with GridSpec
715
+ cbar = fig.colorbar(img, ax=ax, shrink=0.9, aspect=30, pad=0.05)
716
+
717
+ # Format the colorbar nicely
718
+ cbar.ax.tick_params(labelsize=12)
719
+ formatter = mticker.ScalarFormatter(useMathText=True)
720
+ formatter.set_scientific(True)
721
+ formatter.set_powerlimits((-2, 3))
722
+ cbar.ax.yaxis.set_major_formatter(formatter)
723
+ cbar.ax.yaxis.get_offset_text().set_fontsize(12)
724
+
725
+ # Add units if available
726
+ if header and "BUNIT" in header:
727
+ cbar.set_label(header["BUNIT"], fontsize=12)
728
+
729
+ # Draw min/max timeline if enabled (using the pre-created timeline_ax from GridSpec)
730
+ timeline = options.get("minmax_timeline", None)
731
+ if timeline and options.get("minmax_timeline_enabled", False) and timeline_ax is not None:
732
+ current_frame = options.get("current_frame", 0)
733
+ timeline.draw_timeline(fig, current_frame, ax=timeline_ax)
734
+
735
+ # Adjust layout and render
736
+ # fig.tight_layout(pad=0.01)
737
+ canvas.draw()
738
+
739
+ # Convert to numpy array
740
+ try:
741
+ img_data = np.frombuffer(canvas.tostring_rgb(), dtype=np.uint8)
742
+ img_data = img_data.reshape(canvas.get_width_height()[::-1] + (3,))
743
+ except AttributeError:
744
+ # Compatibility issue with newer versions of Matplotlib
745
+ img_data = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8)
746
+ img_data = img_data.reshape(canvas.get_width_height()[::-1] + (4,))
747
+ # Convert RGBA to RGB by discarding the alpha channel
748
+ img_data = img_data[:, :, :3]
749
+
750
+ # Clean up
751
+ plt.close(fig)
752
+
753
+ return img_data
754
+
755
+ except Exception as e:
756
+ logger.error(f"Error processing image {file_path}: {e}")
757
+ return create_error_frame(options, f"Error: {str(e)[:30]}", frame_idx, os.path.basename(file_path))
758
+
759
+
760
+ def calculate_global_stats(files, options):
761
+ """Calculate global statistics for all frames (for Global Auto mode)"""
762
+ # Check if we need global stats
763
+ if options.get("range_mode", 1) != 2: # Not Global Auto
764
+ return None
765
+
766
+ # Only log the message if we're actually calculating global stats
767
+ logger.info("Calculating global statistics...")
768
+
769
+ all_mins = []
770
+ all_maxs = []
771
+
772
+ # Sample frames to calculate global stats (using every 10th frame or at least 10 frames)
773
+ # sample_step = max(1, len(files) // 10)
774
+ sample_step = 1 # Sample every frame
775
+ sample_files = files[::sample_step]
776
+
777
+ # Ensure we have at least some files
778
+ if len(sample_files) == 0:
779
+ sample_files = files[:1]
780
+
781
+ for file_path in tqdm(sample_files, desc="Calculating global stats"):
782
+ try:
783
+ data, _ = load_fits_data(file_path, stokes=options.get("stokes", "I"))
784
+
785
+ if data is None:
786
+ continue
787
+
788
+ # Apply region selection if enabled
789
+ if options.get("region_enabled", False):
790
+ # Get region coordinates
791
+ x_min = options.get("x_min", 0)
792
+ x_max = options.get("x_max", data.shape[1] - 1)
793
+ y_min = options.get("y_min", 0)
794
+ y_max = options.get("y_max", data.shape[0] - 1)
795
+
796
+ # Ensure proper order and boundaries
797
+ x_min, x_max = min(x_min, x_max), max(x_min, x_max)
798
+ y_min, y_max = min(y_min, y_max), max(y_min, y_max)
799
+ x_min = max(0, min(x_min, data.shape[1] - 1))
800
+ x_max = max(0, min(x_max, data.shape[1] - 1))
801
+ y_min = max(0, min(y_min, data.shape[0] - 1))
802
+ y_max = max(0, min(y_max, data.shape[0] - 1))
803
+
804
+ # Extract the region
805
+ data = data[y_min : y_max + 1, x_min : x_max + 1]
806
+
807
+ # Calculate percentiles
808
+ lower_percentile = options.get("lower_percentile", 0)
809
+ upper_percentile = options.get("upper_percentile", 100)
810
+ vmin = np.nanpercentile(data, lower_percentile)
811
+ vmax = np.nanpercentile(data, upper_percentile)
812
+
813
+ all_mins.append(vmin)
814
+ all_maxs.append(vmax)
815
+
816
+ except Exception as e:
817
+ logger.error(f"Error calculating stats for {file_path}: {e}")
818
+
819
+ # Calculate overall min/max from the sampled frames
820
+ if all_mins and all_maxs:
821
+ global_vmin = np.min(all_mins)
822
+ global_vmax = np.max(all_maxs)
823
+ return (global_vmin, global_vmax)
824
+
825
+ return None
826
+
827
+
828
+ def process_image_wrapper(args):
829
+ """
830
+ Wrapper function for process_image to use with multiprocessing.
831
+ Takes a tuple of (file_path, index) and returns (index, processed_frame)
832
+ """
833
+ # Configure matplotlib for non-interactive backend to avoid thread safety issues
834
+ import matplotlib
835
+
836
+ matplotlib.use("Agg") # Use non-interactive backend
837
+
838
+ file_path, idx, options, global_stats = args
839
+ try:
840
+ # Update current frame index in options
841
+ options_copy = options.copy() # Make a copy to avoid thread safety issues
842
+ options_copy["current_frame"] = idx
843
+
844
+ # Reconstruct ContourVideoProcessor if contour mode is enabled
845
+ # (ContourVideoProcessor objects can't be pickled for multiprocessing)
846
+ contour_processor = None
847
+ if options_copy.get("contour_video_enabled", False):
848
+ mode_map = {0: 'A', 1: 'B', 2: 'C'}
849
+ mode = mode_map.get(options_copy.get("contour_mode", 0), 'A')
850
+
851
+ contour_settings = {
852
+ 'level_type': options_copy.get('level_type', 'fraction'),
853
+ 'pos_levels': options_copy.get('pos_levels', [0.1, 0.3, 0.5, 0.7, 0.9]),
854
+ 'neg_levels': options_copy.get('neg_levels', [0.1, 0.3, 0.5, 0.7, 0.9]),
855
+ 'pos_color': options_copy.get('pos_color', 'white'),
856
+ 'neg_color': options_copy.get('neg_color', 'cyan'),
857
+ 'linewidth': options_copy.get('linewidth', 1.0),
858
+ 'pos_linestyle': '-',
859
+ 'neg_linestyle': '--',
860
+ }
861
+
862
+ contour_processor = ContourVideoProcessor(mode=mode, contour_settings=contour_settings)
863
+
864
+ # Setup based on mode
865
+ if mode == 'A':
866
+ base_file = options_copy.get("base_file", "")
867
+ if base_file and os.path.exists(base_file):
868
+ contour_processor.load_base_image(base_file,
869
+ stokes=options_copy.get("stokes", "I"),
870
+ load_func=load_fits_data)
871
+ elif mode == 'B':
872
+ fixed_contour_file = options_copy.get("fixed_contour_file", "")
873
+ if fixed_contour_file and os.path.exists(fixed_contour_file):
874
+ contour_processor.load_fixed_contour(fixed_contour_file,
875
+ stokes=options_copy.get("stokes", "I"),
876
+ load_func=load_fits_data)
877
+
878
+ # Process the frame with contour processor
879
+ result = process_image(file_path, options_copy, global_stats,
880
+ contour_processor=contour_processor, frame_idx=idx)
881
+ return idx, result
882
+ except Exception as e:
883
+ logger.error(f"Error processing frame {idx} ({file_path}): {e}")
884
+ return idx, None
885
+
886
+
887
+ def create_video(files, output_file, options, progress_callback=None):
888
+ """
889
+ Create a video from a list of FITS files.
890
+
891
+ Parameters
892
+ ----------
893
+ files : list
894
+ List of file paths to FITS files.
895
+ output_file : str
896
+ Output file path for the video.
897
+ options : dict
898
+ Dictionary of options:
899
+ - stokes : Stokes parameter to use (I, Q, U, V)
900
+ - colormap : Matplotlib colormap name
901
+ - stretch : Normalization stretch (linear, log, sqrt, power)
902
+ - gamma : Gamma value for power stretch
903
+ - range_mode : Range scaling mode (0:Fixed, 1:Auto Per Frame, 2:Global Auto)
904
+ - vmin, vmax : Fixed min/max values (for Fixed Range mode)
905
+ - lower_percentile, upper_percentile : Percentile values for auto scaling
906
+ - region_enabled : Whether to use region selection
907
+ - x_min, x_max, y_min, y_max : Region selection coordinates
908
+ - timestamp, frame_number, filename : Whether to show these overlays
909
+ - fps : Frames per second for the video
910
+ - quality : Video quality (0-10)
911
+ - width, height : Output frame size (0 for original size)
912
+ - colorbar : Whether to show a colorbar
913
+ - use_multiprocessing : Whether to use multiprocessing for frame generation
914
+ - cpu_count : Number of CPU cores to use (default: number of cores - 1)
915
+ progress_callback : callable, optional
916
+ Callback function for progress updates. Takes two parameters:
917
+ - current_frame : int, Current frame number
918
+ - total_frames : int, Total number of frames
919
+ Returns:
920
+ - continue_processing : bool, Whether to continue processing
921
+
922
+ Returns
923
+ -------
924
+ output_file : str
925
+ Path to the created video file.
926
+ """
927
+ start_time = time.time()
928
+ try:
929
+ # Call progress callback once at the beginning to indicate initialization
930
+ if progress_callback:
931
+ progress_callback(0, 1) # This will trigger the UI to show initial progress
932
+
933
+ if not files:
934
+ raise ValueError("No input files provided")
935
+
936
+ if not output_file:
937
+ raise ValueError("No output file specified")
938
+
939
+ # Ensure output directory exists
940
+ output_dir = os.path.dirname(output_file)
941
+ if output_dir and not os.path.exists(output_dir):
942
+ os.makedirs(output_dir)
943
+
944
+ # Ensure output file has extension
945
+ if not os.path.splitext(output_file)[1]:
946
+ output_file += ".mp4" # Default to MP4 if no extension
947
+
948
+ # Handle case-insensitive file extensions
949
+ output_ext = os.path.splitext(output_file)[1].lower()
950
+
951
+ # Determine the video writer based on the file extension
952
+ if output_ext == ".mp4":
953
+ # MP4 format with H.264 codec
954
+ writer_kwargs = {
955
+ "format": "FFMPEG",
956
+ "fps": options.get("fps", 15),
957
+ "codec": "libx264",
958
+ "quality": None,
959
+ "bitrate": str(options.get("quality", 8) * 100000),
960
+ "pixelformat": "yuv420p",
961
+ "macro_block_size": 1, # Important: Use 1 to prevent resizing issues
962
+ }
963
+ elif output_ext == ".gif":
964
+ # GIF format
965
+ writer_kwargs = {
966
+ "format": "GIF",
967
+ "fps": options.get("fps", 15),
968
+ "subrectangles": True,
969
+ }
970
+ elif output_ext == ".avi":
971
+ # AVI format
972
+ writer_kwargs = {
973
+ "format": "FFMPEG",
974
+ "fps": options.get("fps", 15),
975
+ "codec": "libx264",
976
+ "pixelformat": "yuv420p",
977
+ "quality": None,
978
+ "bitrate": str(options.get("quality", 8) * 100000),
979
+ "macro_block_size": 1, # Important: Use 1 to prevent resizing issues
980
+ }
981
+ else:
982
+ # Default to MP4 for other extensions
983
+ logger.warning(f"Unrecognized extension {output_ext}, using MP4 settings")
984
+ writer_kwargs = {
985
+ "format": "FFMPEG",
986
+ "fps": options.get("fps", 15),
987
+ "codec": "libx264",
988
+ "quality": None,
989
+ "bitrate": str(options.get("quality", 8) * 100000),
990
+ "pixelformat": "yuv420p",
991
+ "macro_block_size": 1, # Important: Use 1 to prevent resizing issues
992
+ }
993
+
994
+ # Add fps and quality info to the options for reference
995
+ options["fps"] = writer_kwargs.get("fps", 15)
996
+
997
+ # Calculate total frames
998
+ total_frames = len(files)
999
+ options["total_frames"] = total_frames
1000
+
1001
+ logger.info(f"Creating video from {total_frames} files...")
1002
+
1003
+ # If region selection is enabled, pre-scan the first file to determine dimensions
1004
+ if options.get("region_enabled", False):
1005
+ logger.info("Determining region dimensions...")
1006
+
1007
+ # Load the first file
1008
+ first_data, _ = load_fits_data(files[0], stokes=options.get("stokes", "I"))
1009
+
1010
+ if first_data is not None:
1011
+ # Get region coordinates
1012
+ x_min = options.get("x_min", 0)
1013
+ x_max = options.get("x_max", first_data.shape[1] - 1)
1014
+ y_min = options.get("y_min", 0)
1015
+ y_max = options.get("y_max", first_data.shape[0] - 1)
1016
+
1017
+ # Ensure proper order and boundaries
1018
+ x_min, x_max = min(x_min, x_max), max(x_min, x_max)
1019
+ y_min, y_max = min(y_min, y_max), max(y_min, y_max)
1020
+ x_min = max(0, min(x_min, first_data.shape[1] - 1))
1021
+ x_max = max(0, min(x_max, first_data.shape[1] - 1))
1022
+ y_min = max(0, min(y_min, first_data.shape[0] - 1))
1023
+ y_max = max(0, min(y_max, first_data.shape[0] - 1))
1024
+
1025
+ # Calculate region dimensions
1026
+ region_width = x_max - x_min + 1
1027
+ region_height = y_max - y_min + 1
1028
+
1029
+ # Ensure dimensions are even (required for H.264 codec)
1030
+ if region_width % 2 != 0:
1031
+ # If width is odd, increase by 1 if possible
1032
+ if x_max < first_data.shape[1] - 1:
1033
+ x_max += 1
1034
+ region_width += 1
1035
+ elif x_min > 0:
1036
+ x_min -= 1
1037
+ region_width += 1
1038
+
1039
+ if region_height % 2 != 0:
1040
+ # If height is odd, increase by 1 if possible
1041
+ if y_max < first_data.shape[0] - 1:
1042
+ y_max += 1
1043
+ region_height += 1
1044
+ elif y_min > 0:
1045
+ y_min -= 1
1046
+ region_height += 1
1047
+
1048
+ # Update the region coordinates in options
1049
+ options["x_min"] = x_min
1050
+ options["x_max"] = x_max
1051
+ options["y_min"] = y_min
1052
+ options["y_max"] = y_max
1053
+
1054
+ # Store dimensions for consistent region extraction
1055
+ options["region_width"] = region_width
1056
+ options["region_height"] = region_height
1057
+
1058
+ logger.info(
1059
+ f"Region dimensions: {region_width}x{region_height} (adjusted to ensure even dimensions)"
1060
+ )
1061
+ else:
1062
+ logger.warning("Could not determine region dimensions from first file")
1063
+
1064
+ # Initialize WCS coordinate detection if enabled
1065
+ if options.get("wcs_enabled", False):
1066
+ logger.info("Detecting coordinate system from first file...")
1067
+ coord_info = detect_coordinate_system(files[0])
1068
+ options["coord_info"] = coord_info
1069
+ logger.info(f"Coordinate system: {coord_info.get('type', 'unknown')}")
1070
+
1071
+ # Initialize MinMax timeline if enabled
1072
+ if options.get("minmax_timeline_enabled", False):
1073
+ logger.info("Precomputing min/max statistics for timeline...")
1074
+ position_map = {
1075
+ 0: "bottom-left",
1076
+ 1: "bottom-right",
1077
+ 2: "top-left",
1078
+ 3: "top-right"
1079
+ }
1080
+ position = position_map.get(
1081
+ options.get("timeline_position", 0), "bottom-left"
1082
+ )
1083
+
1084
+ # Determine which files to use for timeline stats
1085
+ timeline_source = options.get("timeline_source", 0) # 0=Colormap, 1=Contours
1086
+ timeline_files = files # Default to colormap files
1087
+
1088
+ # In contour mode with source=1, use contour files instead
1089
+ if options.get("contour_video_enabled", False) and timeline_source == 1:
1090
+ contour_files = options.get("contour_files", [])
1091
+ if contour_files:
1092
+ timeline_files = contour_files
1093
+ logger.info(f"Timeline will use contour files ({len(timeline_files)} files)")
1094
+
1095
+ log_scale = options.get("timeline_log_scale", False)
1096
+ timeline = MinMaxTimeline(total_frames=len(files), position=position, log_scale=log_scale)
1097
+ timeline.precompute_stats(timeline_files, options, load_fits_data)
1098
+ options["minmax_timeline"] = timeline
1099
+ logger.info("Timeline statistics computed")
1100
+
1101
+ # Initialize ContourVideoProcessor if enabled
1102
+ contour_processor = None
1103
+ if options.get("contour_video_enabled", False):
1104
+ logger.info("Initializing Contour Video Processor...")
1105
+ mode_map = {0: 'A', 1: 'B', 2: 'C'}
1106
+ mode = mode_map.get(options.get("contour_mode", 0), 'A')
1107
+
1108
+ # Construct contour settings using keys from video_dialog.py
1109
+ contour_settings = {
1110
+ 'level_type': options.get('level_type', 'fraction'),
1111
+ 'pos_levels': options.get('pos_levels', [0.1, 0.3, 0.5, 0.7, 0.9]),
1112
+ 'neg_levels': options.get('neg_levels', [0.1, 0.3, 0.5, 0.7, 0.9]),
1113
+ 'pos_color': options.get('pos_color', 'white'),
1114
+ 'neg_color': options.get('neg_color', 'cyan'),
1115
+ 'linewidth': options.get('linewidth', 1.0),
1116
+ 'pos_linestyle': '-',
1117
+ 'neg_linestyle': '--',
1118
+ }
1119
+
1120
+ contour_processor = ContourVideoProcessor(mode=mode, contour_settings=contour_settings)
1121
+
1122
+ # Setup based on mode
1123
+ if mode == 'A':
1124
+ # Mode A: Fixed base image, evolving contours
1125
+ base_file = options.get("base_file", "")
1126
+ if base_file and os.path.exists(base_file):
1127
+ contour_processor.load_base_image(base_file, load_func=load_fits_data)
1128
+ logger.info(f"Mode A: Using base image: {base_file}")
1129
+ elif mode == 'B':
1130
+ # Mode B: Fixed contours, evolving colormap
1131
+ fixed_contour_file = options.get("fixed_contour_file", "")
1132
+ if fixed_contour_file and os.path.exists(fixed_contour_file):
1133
+ contour_processor.load_fixed_contour(fixed_contour_file, load_func=load_fits_data)
1134
+ logger.info(f"Mode B: Using fixed contour file: {fixed_contour_file}")
1135
+ # Mode C uses the same contour files as the base files list
1136
+
1137
+ options["contour_processor"] = contour_processor
1138
+ logger.info(f"Contour processor initialized in mode {mode}")
1139
+
1140
+ # Calculate global stats if needed
1141
+ global_stats = calculate_global_stats(files, options)
1142
+
1143
+ # Determine whether to use multiprocessing
1144
+ use_multiprocessing = options.get("use_multiprocessing", False)
1145
+ num_cores = options.get("cpu_count", max(1, cpu_count() - 1))
1146
+ # Use a larger chunk size for fewer, larger batches
1147
+ chunk_size = options.get(
1148
+ "chunk_size", max(1, min(50, len(files) // (num_cores * 2)))
1149
+ )
1150
+
1151
+ if use_multiprocessing and num_cores > 1 and len(files) > num_cores:
1152
+ logger.info(
1153
+ f"Using multiprocessing with {num_cores} cores and chunk size {chunk_size}"
1154
+ )
1155
+ print(
1156
+ f"Multiprocessing enabled: {num_cores} cores, {chunk_size} frames per batch"
1157
+ )
1158
+
1159
+ # Process frames in parallel
1160
+ processed_frames = [None] * len(files)
1161
+ processed_count = 0
1162
+ failed_count = 0
1163
+
1164
+ # Prepare arguments for each frame
1165
+ process_args = [
1166
+ (file_path, i, options, global_stats)
1167
+ for i, file_path in enumerate(files)
1168
+ ]
1169
+
1170
+ # Use ProcessPoolExecutor for parallel processing in batches
1171
+ with ProcessPoolExecutor(max_workers=num_cores) as executor:
1172
+ # Process in chunks to reduce overhead
1173
+ batch_start_time = time.time()
1174
+ results = list(
1175
+ tqdm(
1176
+ executor.map(
1177
+ process_image_wrapper, process_args, chunksize=chunk_size
1178
+ ),
1179
+ total=len(files),
1180
+ desc="Processing frames",
1181
+ unit="frame",
1182
+ )
1183
+ )
1184
+ batch_end_time = time.time()
1185
+ batch_duration = batch_end_time - batch_start_time
1186
+ frames_per_second = (
1187
+ len(files) / batch_duration if batch_duration > 0 else 0
1188
+ )
1189
+
1190
+ logger.info(
1191
+ f"Parallel processing complete: {len(results)} frames in {batch_duration:.2f}s ({frames_per_second:.2f} frames/sec)"
1192
+ )
1193
+ print(
1194
+ f"Processed {len(results)} frames in {batch_duration:.2f}s ({frames_per_second:.2f} frames/sec)"
1195
+ )
1196
+
1197
+ # Store results in the processed_frames list
1198
+ for idx, frame in results:
1199
+ processed_frames[idx] = frame
1200
+ if frame is not None:
1201
+ processed_count += 1
1202
+ else:
1203
+ failed_count += 1
1204
+
1205
+ # Call progress callback
1206
+ if (
1207
+ progress_callback and idx % 5 == 0
1208
+ ): # Update every 5 frames to reduce overhead
1209
+ continue_processing = progress_callback(idx + 1, len(files))
1210
+ if not continue_processing:
1211
+ logger.info("Video creation cancelled by user")
1212
+ return None
1213
+
1214
+ logger.info(f"Processed {processed_count} frames ({failed_count} failed)")
1215
+
1216
+ # Create video writer
1217
+ with imageio.get_writer(output_file, **writer_kwargs) as writer:
1218
+ # Add frames to video in order
1219
+ for i, frame in enumerate(processed_frames):
1220
+ if frame is not None:
1221
+ # Ensure image has even dimensions
1222
+ frame = ensure_even_dimensions(frame)
1223
+ # Add the frame to the video
1224
+ writer.append_data(frame)
1225
+ else:
1226
+ logger.warning(f"Could not process frame {i}")
1227
+
1228
+ else:
1229
+ if use_multiprocessing:
1230
+ if num_cores <= 1:
1231
+ logger.info("Multiprocessing disabled: only 1 core available")
1232
+ elif len(files) <= num_cores:
1233
+ logger.info(
1234
+ "Multiprocessing disabled: too few files for parallel processing"
1235
+ )
1236
+ print("Using sequential processing")
1237
+
1238
+ # Sequential processing (original method)
1239
+ seq_start_time = time.time()
1240
+ # Create video writer
1241
+ with imageio.get_writer(output_file, **writer_kwargs) as writer:
1242
+ # Process each frame
1243
+ for i, file_path in enumerate(files):
1244
+ # Call progress callback if provided
1245
+ if progress_callback:
1246
+ continue_processing = progress_callback(i, total_frames)
1247
+ if not continue_processing:
1248
+ logger.info("Video creation cancelled by user")
1249
+ break
1250
+
1251
+ # Update current frame index in options
1252
+ options["current_frame"] = i
1253
+
1254
+ # Process the image
1255
+ frame = process_image(file_path, options, global_stats,
1256
+ contour_processor=contour_processor, frame_idx=i)
1257
+
1258
+ if frame is not None:
1259
+ # Ensure image has even dimensions
1260
+ frame = ensure_even_dimensions(frame)
1261
+ # Add the frame to the video
1262
+ writer.append_data(frame)
1263
+ else:
1264
+ logger.warning(f"Could not process frame {i} ({file_path})")
1265
+
1266
+ seq_end_time = time.time()
1267
+ seq_duration = seq_end_time - seq_start_time
1268
+ frames_per_second = len(files) / seq_duration if seq_duration > 0 else 0
1269
+ logger.info(
1270
+ f"Sequential processing complete: {len(files)} frames in {seq_duration:.2f}s ({frames_per_second:.2f} frames/sec)"
1271
+ )
1272
+
1273
+ total_time = time.time() - start_time
1274
+ logger.info(f"Video created successfully: {output_file} in {total_time:.2f}s")
1275
+ return output_file
1276
+
1277
+ except Exception as e:
1278
+ logger.error(f"Error creating video: {e}")
1279
+ raise
1280
+
1281
+
1282
+ class VideoProgress:
1283
+ """
1284
+ Class to manage progress updates for the video creation process
1285
+ """
1286
+
1287
+ def __init__(self, callback=None):
1288
+ self.callback = callback
1289
+ self.progress = 0
1290
+ self._cancel = False
1291
+ self.thread = None
1292
+
1293
+ def update(self, progress):
1294
+ self.progress = progress
1295
+ if self.callback:
1296
+ self.callback(progress)
1297
+
1298
+ def cancel(self):
1299
+ self._cancel = True
1300
+
1301
+ def is_cancelled(self):
1302
+ return self._cancel
1303
+
1304
+ def start_thread(self, func, *args, **kwargs):
1305
+ def run():
1306
+ func(*args, **kwargs)
1307
+ if self.callback:
1308
+ self.callback(100) # Ensure we end at 100%
1309
+
1310
+ self.thread = threading.Thread(target=run)
1311
+ self.thread.daemon = True
1312
+ self.thread.start()
1313
+
1314
+ return self.thread
1315
+
1316
+
1317
+ if __name__ == "__main__":
1318
+ # Example usage
1319
+ create_video(
1320
+ input_pattern="/path/to/*.fits",
1321
+ output_file="/path/to/output.mp4",
1322
+ fps=10,
1323
+ sort_by="datetime",
1324
+ stokes="I",
1325
+ colormap="viridis",
1326
+ stretch="linear",
1327
+ vmin=None,
1328
+ vmax=None,
1329
+ gamma=1.0,
1330
+ width=None,
1331
+ height=None,
1332
+ add_timestamp=True,
1333
+ add_frame_number=True,
1334
+ add_filename=True,
1335
+ use_multiprocessing=True,
1336
+ region_enabled=False,
1337
+ x_min=0,
1338
+ x_max=0,
1339
+ y_min=0,
1340
+ y_max=0,
1341
+ add_colorbar=False,
1342
+ range_mode=1, # 0=Fixed, 1=Auto Per Frame, 2=Global Auto
1343
+ lower_percentile=0.0,
1344
+ upper_percentile=100.0,
1345
+ )