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,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
|
+
)
|