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,1514 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Helioviewer Browser - Time-series viewer for solar images from Helioviewer API.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from PyQt5.QtWidgets import (
|
|
7
|
+
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
|
8
|
+
QLabel, QPushButton, QDateTimeEdit, QSpinBox, QListWidget,
|
|
9
|
+
QScrollArea, QFrame, QProgressBar, QComboBox, QFileDialog,
|
|
10
|
+
QMessageBox, QDialog, QListWidgetItem, QApplication, QGroupBox
|
|
11
|
+
)
|
|
12
|
+
from PyQt5.QtCore import Qt, QDateTime, QThread, pyqtSignal, QTimer, QSize
|
|
13
|
+
from PyQt5.QtGui import QPixmap, QImage, QIcon
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
from collections import OrderedDict
|
|
16
|
+
import requests
|
|
17
|
+
from typing import List, Dict, Optional, Tuple
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
# Global list to keep threads alive if window is closed while they are running
|
|
21
|
+
# This prevents "QThread: Destroyed while thread is still running"
|
|
22
|
+
_active_threads = []
|
|
23
|
+
|
|
24
|
+
# Known instrument cadences (in seconds) - Based on official mission documentation
|
|
25
|
+
INSTRUMENT_CADENCES = {
|
|
26
|
+
# SDO/AIA - 12s for EUV, 24s for UV, 3600s for visible
|
|
27
|
+
'AIA 94': 12,
|
|
28
|
+
'AIA 131': 12,
|
|
29
|
+
'AIA 171': 12,
|
|
30
|
+
'AIA 193': 12,
|
|
31
|
+
'AIA 211': 12,
|
|
32
|
+
'AIA 304': 12,
|
|
33
|
+
'AIA 335': 12,
|
|
34
|
+
'AIA 1600': 24,
|
|
35
|
+
'AIA 1700': 24,
|
|
36
|
+
'AIA 4500': 3600,
|
|
37
|
+
|
|
38
|
+
# SDO/HMI - 45s for both magnetogram and continuum
|
|
39
|
+
'HMI magnetogram': 45,
|
|
40
|
+
'HMI continuum': 45,
|
|
41
|
+
|
|
42
|
+
# SOHO/EIT - 12 minutes (720s) for all wavelengths
|
|
43
|
+
'EIT 171': 720,
|
|
44
|
+
'EIT 195': 720,
|
|
45
|
+
'EIT 284': 720,
|
|
46
|
+
'EIT 304': 720,
|
|
47
|
+
|
|
48
|
+
# SOHO/MDI - 96 minutes
|
|
49
|
+
'MDI magnetogram': 96,
|
|
50
|
+
'MDI continuum': 96,
|
|
51
|
+
|
|
52
|
+
# SOHO/LASCO - 30 minutes for both C2 and C3
|
|
53
|
+
'LASCO': 1800,
|
|
54
|
+
'LASCO C2': 1800,
|
|
55
|
+
'LASCO C3': 1800,
|
|
56
|
+
|
|
57
|
+
# STEREO/SECCHI EUVI - 2.5 minutes (150s)
|
|
58
|
+
'EUVI': 150,
|
|
59
|
+
'EUVI 171': 150,
|
|
60
|
+
'EUVI 195': 150,
|
|
61
|
+
'EUVI 284': 150,
|
|
62
|
+
'EUVI 304': 150,
|
|
63
|
+
|
|
64
|
+
# STEREO/SECCHI Coronagraphs
|
|
65
|
+
'COR1': 300, # 5 minutes
|
|
66
|
+
'COR2': 900, # 15 minutes
|
|
67
|
+
|
|
68
|
+
# GOES/SUVI - 10s for all channels
|
|
69
|
+
'SUVI': 10,
|
|
70
|
+
'SUVI 94': 10,
|
|
71
|
+
'SUVI 131': 10,
|
|
72
|
+
'SUVI 171': 10,
|
|
73
|
+
'SUVI 195': 10,
|
|
74
|
+
'SUVI 284': 10,
|
|
75
|
+
'SUVI 304': 10,
|
|
76
|
+
|
|
77
|
+
# Solar Orbiter/EUI
|
|
78
|
+
'EUI': 600,
|
|
79
|
+
'FSI': 600, # Full Sun Imager - 10 minutes
|
|
80
|
+
'HRI': 300, # High Resolution Imager - 5 minutes
|
|
81
|
+
|
|
82
|
+
# Legacy/Other
|
|
83
|
+
'SXT': 60, # Yohkoh SXT
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Whitelist: Only instruments with verified cadence AND FOV parameters
|
|
87
|
+
# Using patterns that match API-returned names
|
|
88
|
+
VERIFIED_INSTRUMENTS = [
|
|
89
|
+
# SDO - 100% verified
|
|
90
|
+
'AIA 94', 'AIA 131', 'AIA 171', 'AIA 193', 'AIA 211', 'AIA 304', 'AIA 335',
|
|
91
|
+
'AIA 1600', 'AIA 1700', 'AIA 4500',
|
|
92
|
+
'HMI magnetogram', 'HMI continuum',
|
|
93
|
+
|
|
94
|
+
# SOHO - 100% verified
|
|
95
|
+
'EIT 171', 'EIT 195', 'EIT 284', 'EIT 304',
|
|
96
|
+
'LASCO/C2 white-light', 'LASCO/C3 white-light', # API format
|
|
97
|
+
|
|
98
|
+
# STEREO - 100% verified (both A and B)
|
|
99
|
+
'SECCHI/EUVI 171', 'SECCHI/EUVI 195', 'SECCHI/EUVI 284', 'SECCHI/EUVI 304',
|
|
100
|
+
'SECCHI/COR1 white-light', 'SECCHI/COR2 white-light',
|
|
101
|
+
|
|
102
|
+
# GOES/SUVI - 100% verified
|
|
103
|
+
'SUVI 94', 'SUVI 131', 'SUVI 171', 'SUVI 195', 'SUVI 284', 'SUVI 304',
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fetch_all_instruments() -> List[Tuple[str, str, str, str]]:
|
|
108
|
+
"""Fetch all available instruments from Helioviewer API."""
|
|
109
|
+
try:
|
|
110
|
+
url = 'https://api.helioviewer.org/v2/getDataSources/'
|
|
111
|
+
response = requests.get(url, timeout=10)
|
|
112
|
+
response.raise_for_status()
|
|
113
|
+
data = response.json()
|
|
114
|
+
|
|
115
|
+
instruments = []
|
|
116
|
+
|
|
117
|
+
for observatory, obs_data in sorted(data.items()):
|
|
118
|
+
if not isinstance(obs_data, dict):
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
for instrument, inst_data in sorted(obs_data.items()):
|
|
122
|
+
if not isinstance(inst_data, dict):
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# At this level, keys are either measurements (dict with metadata) or detectors (dict of measurements)
|
|
126
|
+
for key, value in sorted(inst_data.items()):
|
|
127
|
+
if not isinstance(value, dict):
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
# Check if this is a measurement (has 'sourceId') or a detector level
|
|
131
|
+
if 'sourceId' in value:
|
|
132
|
+
# This is a measurement directly under instrument
|
|
133
|
+
measurement = key
|
|
134
|
+
source_id = value['sourceId']
|
|
135
|
+
# Layer format is [sourceId,visible,opacity]
|
|
136
|
+
layer = f'[{source_id},1,100]'
|
|
137
|
+
name = f'{instrument} {measurement}'
|
|
138
|
+
|
|
139
|
+
# Determine cadence - try exact match first, then instrument family
|
|
140
|
+
cadence = INSTRUMENT_CADENCES.get(
|
|
141
|
+
name, # Try exact: "AIA 171"
|
|
142
|
+
INSTRUMENT_CADENCES.get(
|
|
143
|
+
instrument, # Try instrument: "AIA"
|
|
144
|
+
60 # Default: 60s
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
description = f'{observatory} - {name}'
|
|
149
|
+
instruments.append((name, layer, observatory, cadence))
|
|
150
|
+
else:
|
|
151
|
+
# This is a detector level, iterate measurements
|
|
152
|
+
detector = key
|
|
153
|
+
for measurement, meas_data in sorted(value.items()):
|
|
154
|
+
if not isinstance(meas_data, dict):
|
|
155
|
+
continue
|
|
156
|
+
if 'sourceId' not in meas_data:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
source_id = meas_data['sourceId']
|
|
160
|
+
# Layer format is [sourceId,visible,opacity]
|
|
161
|
+
layer = f'[{source_id},1,100]'
|
|
162
|
+
name = f'{instrument}/{detector} {measurement}'
|
|
163
|
+
|
|
164
|
+
# Determine cadence - try exact match first, then detector, then instrument
|
|
165
|
+
cadence = INSTRUMENT_CADENCES.get(
|
|
166
|
+
name, # Try exact: "SECCHI/EUVI 171"
|
|
167
|
+
INSTRUMENT_CADENCES.get(
|
|
168
|
+
f'{detector} {measurement}', # Try: "EUVI 171"
|
|
169
|
+
INSTRUMENT_CADENCES.get(
|
|
170
|
+
detector, # Try: "EUVI"
|
|
171
|
+
INSTRUMENT_CADENCES.get(instrument, 60) # Default: 60s
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
description = f'{observatory} - {name}'
|
|
177
|
+
instruments.append((name, layer, observatory, cadence))
|
|
178
|
+
|
|
179
|
+
print(f"Loaded {len(instruments)} instruments from Helioviewer")
|
|
180
|
+
|
|
181
|
+
# Filter to only verified instruments
|
|
182
|
+
verified = []
|
|
183
|
+
for name, layer, obs, cad in instruments:
|
|
184
|
+
# Check if instrument name is in verified list
|
|
185
|
+
# Also check without observatory prefix for STEREO instruments
|
|
186
|
+
if name in VERIFIED_INSTRUMENTS:
|
|
187
|
+
verified.append((name, layer, obs, cad))
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
# For STEREO, also accept if base name matches (e.g., "EUVI 171" from "SECCHI/EUVI 171")
|
|
191
|
+
if '/' in name:
|
|
192
|
+
base_name = name.split('/')[-1] # Get "EUVI 171" from "SECCHI/EUVI 171"
|
|
193
|
+
if base_name in ['EUVI 171', 'EUVI 195', 'EUVI 284', 'EUVI 304', 'COR1 white-light', 'COR2 white-light']:
|
|
194
|
+
verified.append((name, layer, obs, cad))
|
|
195
|
+
|
|
196
|
+
print(f"Filtered to {len(verified)} verified instruments (with known cadence & FOV)")
|
|
197
|
+
return verified
|
|
198
|
+
except Exception as e:
|
|
199
|
+
print(f"Error fetching instruments: {e}")
|
|
200
|
+
import traceback
|
|
201
|
+
traceback.print_exc()
|
|
202
|
+
# Fallback to essential instruments
|
|
203
|
+
from .solar_context.context_images import ESSENTIAL_INSTRUMENTS
|
|
204
|
+
return ESSENTIAL_INSTRUMENTS
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class ImageDownloader(QThread):
|
|
208
|
+
"""Thread to download a single image."""
|
|
209
|
+
finished = pyqtSignal(str, QPixmap) # instrument, pixmap
|
|
210
|
+
error = pyqtSignal(str, str) # instrument, error_msg
|
|
211
|
+
|
|
212
|
+
def __init__(self, instrument_name, layer_path, timestamp, width=1000, height=1000):
|
|
213
|
+
super().__init__()
|
|
214
|
+
self.instrument_name = instrument_name
|
|
215
|
+
self.layer_path = layer_path
|
|
216
|
+
self.timestamp = timestamp
|
|
217
|
+
self.width = width
|
|
218
|
+
self.height = height
|
|
219
|
+
self.running = True
|
|
220
|
+
|
|
221
|
+
def stop(self):
|
|
222
|
+
"""Signal the thread to stop."""
|
|
223
|
+
self.running = False
|
|
224
|
+
|
|
225
|
+
def run(self):
|
|
226
|
+
try:
|
|
227
|
+
# Determine imageScale based on instrument
|
|
228
|
+
layer_lower = self.layer_path.lower()
|
|
229
|
+
|
|
230
|
+
# Parse instrument from layer (format: [sourceId,1,100])
|
|
231
|
+
# Need to determine instrument type from name passed in
|
|
232
|
+
inst_lower = self.instrument_name.lower()
|
|
233
|
+
|
|
234
|
+
# Base imageScale for 1000x1000 images
|
|
235
|
+
# Solar disk instruments - tight fit (2.0-4.0)
|
|
236
|
+
if any(x in inst_lower for x in ['aia', 'hmi', 'eit', 'euvi', 'suvi']):
|
|
237
|
+
if 'hmi continuum' in inst_lower:
|
|
238
|
+
base_image_scale = 3.0 # HMI continuum - slightly wider
|
|
239
|
+
elif 'hmi' in inst_lower:
|
|
240
|
+
base_image_scale = 2.5 # HMI magnetogram
|
|
241
|
+
else:
|
|
242
|
+
base_image_scale = 2.5 # AIA, EIT, EUVI, SUVI - fits solar disk in 1000px
|
|
243
|
+
|
|
244
|
+
# Coronagraphs - wide field
|
|
245
|
+
elif 'lasco' in inst_lower and 'c2' in inst_lower:
|
|
246
|
+
base_image_scale = 12.0 # LASCO C2: fits up to 6 Rsun in 1000px
|
|
247
|
+
elif 'lasco' in inst_lower and 'c3' in inst_lower:
|
|
248
|
+
base_image_scale = 58.0 # LASCO C3: fits up to 30 Rsun in 1000px
|
|
249
|
+
elif any(x in inst_lower for x in ['cor1', 'cor2']):
|
|
250
|
+
if 'cor1' in inst_lower:
|
|
251
|
+
base_image_scale = 6.0 # COR1: ~1.4-4 Rsun
|
|
252
|
+
else:
|
|
253
|
+
base_image_scale = 15.0 # COR2: ~2.5-15 Rsun
|
|
254
|
+
|
|
255
|
+
# Other instruments
|
|
256
|
+
else:
|
|
257
|
+
base_image_scale = 4.0 # Default moderate zoom
|
|
258
|
+
|
|
259
|
+
# Adjust imageScale to maintain FOV for different image sizes
|
|
260
|
+
# Formula: new_scale = base_scale * (1000 / actual_size)
|
|
261
|
+
size_factor = 1000.0 / self.width
|
|
262
|
+
image_scale = str(base_image_scale * size_factor)
|
|
263
|
+
|
|
264
|
+
# Build Helioviewer URL
|
|
265
|
+
from urllib.parse import urlencode
|
|
266
|
+
base_url = "https://api.helioviewer.org/v2/takeScreenshot/"
|
|
267
|
+
params = {
|
|
268
|
+
'date': self.timestamp.toString("yyyy-MM-ddTHH:mm:ss") + "Z",
|
|
269
|
+
'imageScale': image_scale,
|
|
270
|
+
'layers': self.layer_path,
|
|
271
|
+
'x0': '0',
|
|
272
|
+
'y0': '0',
|
|
273
|
+
'width': str(self.width),
|
|
274
|
+
'height': str(self.height),
|
|
275
|
+
'display': 'true',
|
|
276
|
+
'watermark': 'false'
|
|
277
|
+
}
|
|
278
|
+
url = f"{base_url}?{urlencode(params)}"
|
|
279
|
+
|
|
280
|
+
if not self.running:
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
# Download image
|
|
284
|
+
response = requests.get(url, timeout=60)
|
|
285
|
+
response.raise_for_status()
|
|
286
|
+
|
|
287
|
+
if not self.running:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
# Convert to QPixmap
|
|
291
|
+
qimage = QImage()
|
|
292
|
+
if qimage.loadFromData(response.content):
|
|
293
|
+
pixmap = QPixmap.fromImage(qimage)
|
|
294
|
+
if self.running:
|
|
295
|
+
self.finished.emit(self.instrument_name, pixmap)
|
|
296
|
+
else:
|
|
297
|
+
if self.running:
|
|
298
|
+
self.error.emit(self.instrument_name, "Failed to load image data")
|
|
299
|
+
|
|
300
|
+
except Exception as e:
|
|
301
|
+
if self.running:
|
|
302
|
+
self.error.emit(self.instrument_name, str(e))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class FrameLoader(QThread):
|
|
306
|
+
"""Thread to load all instruments for multiple frames in background."""
|
|
307
|
+
frame_loaded = pyqtSignal(int, dict) # frame_index, {instrument: pixmap}
|
|
308
|
+
progress = pyqtSignal(int, int) # current, total
|
|
309
|
+
|
|
310
|
+
def __init__(self, timestamps, instruments_data, width=1000, height=1000):
|
|
311
|
+
super().__init__()
|
|
312
|
+
self.timestamps = timestamps
|
|
313
|
+
self.instruments_data = instruments_data # List of (name, layer_path, observatory, desc)
|
|
314
|
+
self.width = width
|
|
315
|
+
self.height = height
|
|
316
|
+
self.running = True
|
|
317
|
+
|
|
318
|
+
def run(self):
|
|
319
|
+
total_frames = len(self.timestamps)
|
|
320
|
+
for frame_idx, timestamp in enumerate(self.timestamps):
|
|
321
|
+
if not self.running:
|
|
322
|
+
break
|
|
323
|
+
|
|
324
|
+
frame_data = {}
|
|
325
|
+
for instrument_name, layer_path, observatory, description in self.instruments_data:
|
|
326
|
+
if not self.running:
|
|
327
|
+
break
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
# Determine base imageScale for 1000x1000
|
|
331
|
+
inst_lower = instrument_name.lower()
|
|
332
|
+
|
|
333
|
+
# Solar disk instruments - tight fit
|
|
334
|
+
if any(x in inst_lower for x in ['aia', 'hmi', 'eit', 'euvi', 'suvi']):
|
|
335
|
+
if 'hmi continuum' in inst_lower:
|
|
336
|
+
base_image_scale = 3.0
|
|
337
|
+
elif 'hmi' in inst_lower:
|
|
338
|
+
base_image_scale = 2.5
|
|
339
|
+
else:
|
|
340
|
+
base_image_scale = 2.5
|
|
341
|
+
|
|
342
|
+
# Coronagraphs
|
|
343
|
+
elif 'lasco' in inst_lower and 'c2' in inst_lower:
|
|
344
|
+
base_image_scale = 12.0
|
|
345
|
+
elif 'lasco' in inst_lower and 'c3' in inst_lower:
|
|
346
|
+
base_image_scale = 58.0
|
|
347
|
+
elif any(x in inst_lower for x in ['cor1', 'cor2']):
|
|
348
|
+
base_image_scale = 6.0 if 'cor1' in inst_lower else 15.0
|
|
349
|
+
else:
|
|
350
|
+
base_image_scale = 4.0
|
|
351
|
+
|
|
352
|
+
# Adjust imageScale to maintain FOV
|
|
353
|
+
size_factor = 1000.0 / self.width
|
|
354
|
+
image_scale = str(base_image_scale * size_factor)
|
|
355
|
+
|
|
356
|
+
# Build URL
|
|
357
|
+
from urllib.parse import urlencode
|
|
358
|
+
base_url = "https://api.helioviewer.org/v2/takeScreenshot/"
|
|
359
|
+
params = {
|
|
360
|
+
'date': timestamp.toString("yyyy-MM-ddTHH:mm:ss") + "Z",
|
|
361
|
+
'imageScale': image_scale,
|
|
362
|
+
'layers': layer_path,
|
|
363
|
+
'x0': '0',
|
|
364
|
+
'y0': '0',
|
|
365
|
+
'width': str(self.width),
|
|
366
|
+
'height': str(self.height),
|
|
367
|
+
'display': 'true',
|
|
368
|
+
'watermark': 'false'
|
|
369
|
+
}
|
|
370
|
+
url = f"{base_url}?{urlencode(params)}"
|
|
371
|
+
|
|
372
|
+
# Download
|
|
373
|
+
response = requests.get(url, timeout=60)
|
|
374
|
+
response.raise_for_status()
|
|
375
|
+
|
|
376
|
+
# Convert to pixmap
|
|
377
|
+
qimage = QImage()
|
|
378
|
+
if qimage.loadFromData(response.content):
|
|
379
|
+
frame_data[instrument_name] = QPixmap.fromImage(qimage)
|
|
380
|
+
|
|
381
|
+
except Exception as e:
|
|
382
|
+
print(f"Error loading {instrument_name} for frame {frame_idx}: {e}")
|
|
383
|
+
|
|
384
|
+
self.frame_loaded.emit(frame_idx, frame_data)
|
|
385
|
+
self.progress.emit(frame_idx + 1, total_frames)
|
|
386
|
+
|
|
387
|
+
def stop(self):
|
|
388
|
+
self.running = False
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class FullImageDialog(QDialog):
|
|
392
|
+
"""Dialog to show full resolution image."""
|
|
393
|
+
def __init__(self, parent, pixmap, title):
|
|
394
|
+
super().__init__(parent)
|
|
395
|
+
self.setWindowTitle(title)
|
|
396
|
+
self.resize(1200, 1200)
|
|
397
|
+
|
|
398
|
+
layout = QVBoxLayout(self)
|
|
399
|
+
|
|
400
|
+
# Scroll area for large image
|
|
401
|
+
scroll = QScrollArea()
|
|
402
|
+
scroll.setWidgetResizable(True)
|
|
403
|
+
|
|
404
|
+
label = QLabel()
|
|
405
|
+
label.setPixmap(pixmap)
|
|
406
|
+
label.setAlignment(Qt.AlignCenter)
|
|
407
|
+
|
|
408
|
+
scroll.setWidget(label)
|
|
409
|
+
layout.addWidget(scroll)
|
|
410
|
+
|
|
411
|
+
# Close button
|
|
412
|
+
close_btn = QPushButton("Close")
|
|
413
|
+
close_btn.clicked.connect(self.accept)
|
|
414
|
+
layout.addWidget(close_btn)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class HelioviewerBrowser(QMainWindow):
|
|
418
|
+
"""Main browser window for Helioviewer time-series."""
|
|
419
|
+
|
|
420
|
+
def __init__(self, parent=None, initial_start=None, initial_end=None):
|
|
421
|
+
super().__init__(parent)
|
|
422
|
+
self.setWindowTitle("🌐 Helioviewer Browser")
|
|
423
|
+
self.resize(1600, 1000)
|
|
424
|
+
|
|
425
|
+
# Data storage
|
|
426
|
+
self.frames = OrderedDict() # {frame_index: {instrument: pixmap}}
|
|
427
|
+
self.timestamps = []
|
|
428
|
+
self.current_frame = 0
|
|
429
|
+
self.playing = False
|
|
430
|
+
self.frame_loaders = []
|
|
431
|
+
|
|
432
|
+
# Download queue management for parallel loading
|
|
433
|
+
self.download_queue = [] # Queue of (frame_idx, instrument_data)
|
|
434
|
+
self.active_downloads = 0
|
|
435
|
+
self.max_concurrent_downloads = 4
|
|
436
|
+
|
|
437
|
+
# Store initial times
|
|
438
|
+
self.initial_start = initial_start
|
|
439
|
+
self.initial_end = initial_end
|
|
440
|
+
|
|
441
|
+
# Animation timer
|
|
442
|
+
self.animation_timer = QTimer()
|
|
443
|
+
self.animation_timer.timeout.connect(self.next_frame)
|
|
444
|
+
|
|
445
|
+
# Flag to prevent updates during close
|
|
446
|
+
self._closing = False
|
|
447
|
+
|
|
448
|
+
self.init_ui()
|
|
449
|
+
|
|
450
|
+
def init_ui(self):
|
|
451
|
+
"""Initialize the user interface."""
|
|
452
|
+
central = QWidget()
|
|
453
|
+
self.setCentralWidget(central)
|
|
454
|
+
main_layout = QVBoxLayout(central)
|
|
455
|
+
main_layout.setContentsMargins(10, 10, 10, 10)
|
|
456
|
+
|
|
457
|
+
# Top panel - Time range controls
|
|
458
|
+
self.create_time_controls(main_layout)
|
|
459
|
+
|
|
460
|
+
# Progress bar
|
|
461
|
+
self.progress_bar = QProgressBar()
|
|
462
|
+
self.progress_bar.setVisible(False)
|
|
463
|
+
main_layout.addWidget(self.progress_bar)
|
|
464
|
+
|
|
465
|
+
# Splitter for left/right panels
|
|
466
|
+
splitter = QSplitter(Qt.Horizontal)
|
|
467
|
+
|
|
468
|
+
# Left panel - Instrument filter
|
|
469
|
+
self.create_instrument_panel(splitter)
|
|
470
|
+
|
|
471
|
+
# Right panel - Image grid
|
|
472
|
+
self.create_image_panel(splitter)
|
|
473
|
+
|
|
474
|
+
splitter.setSizes([300, 1300])
|
|
475
|
+
main_layout.addWidget(splitter, 1)
|
|
476
|
+
|
|
477
|
+
# Bottom panel - Animation controls
|
|
478
|
+
self.create_animation_controls(main_layout)
|
|
479
|
+
|
|
480
|
+
def create_time_controls(self, parent_layout):
|
|
481
|
+
"""Create time range input controls."""
|
|
482
|
+
group = QGroupBox("Time Range")
|
|
483
|
+
layout = QHBoxLayout(group)
|
|
484
|
+
|
|
485
|
+
# Start time
|
|
486
|
+
layout.addWidget(QLabel("Start:"))
|
|
487
|
+
self.start_datetime = QDateTimeEdit()
|
|
488
|
+
self.start_datetime.setCalendarPopup(True)
|
|
489
|
+
self.start_datetime.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
|
|
490
|
+
# Default to yesterday 10:00 UTC or initial_start if provided
|
|
491
|
+
if hasattr(self, 'initial_start') and self.initial_start:
|
|
492
|
+
self.start_datetime.setDateTime(self.initial_start)
|
|
493
|
+
else:
|
|
494
|
+
default_start = QDateTime.currentDateTimeUtc().addDays(-1)
|
|
495
|
+
default_start.setTime(default_start.time().fromString("10:00:00", "HH:mm:ss"))
|
|
496
|
+
self.start_datetime.setDateTime(default_start)
|
|
497
|
+
layout.addWidget(self.start_datetime)
|
|
498
|
+
|
|
499
|
+
# End time
|
|
500
|
+
layout.addWidget(QLabel("End:"))
|
|
501
|
+
self.end_datetime = QDateTimeEdit()
|
|
502
|
+
self.end_datetime.setCalendarPopup(True)
|
|
503
|
+
self.end_datetime.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
|
|
504
|
+
if hasattr(self, 'initial_end') and self.initial_end:
|
|
505
|
+
self.end_datetime.setDateTime(self.initial_end)
|
|
506
|
+
else:
|
|
507
|
+
default_end = self.start_datetime.dateTime().addSecs(3600) # 1 hour later
|
|
508
|
+
self.end_datetime.setDateTime(default_end)
|
|
509
|
+
layout.addWidget(self.end_datetime)
|
|
510
|
+
|
|
511
|
+
# Interval
|
|
512
|
+
layout.addWidget(QLabel("Interval (seconds):"))
|
|
513
|
+
self.interval_spin = QSpinBox()
|
|
514
|
+
self.interval_spin.setRange(10, 3600)
|
|
515
|
+
self.interval_spin.setValue(30) # Default 30 seconds
|
|
516
|
+
self.interval_spin.setSuffix(" s")
|
|
517
|
+
layout.addWidget(self.interval_spin)
|
|
518
|
+
|
|
519
|
+
layout.addStretch()
|
|
520
|
+
|
|
521
|
+
# Load button
|
|
522
|
+
# Add load emoji
|
|
523
|
+
self.load_button = QPushButton("📥 Load")
|
|
524
|
+
self.load_button.clicked.connect(self.load_time_series)
|
|
525
|
+
self.load_button.setStyleSheet("""
|
|
526
|
+
QPushButton {
|
|
527
|
+
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
|
528
|
+
stop:0 #2196F3, stop:1 #1976D2);
|
|
529
|
+
color: white;
|
|
530
|
+
padding: 8px 16px;
|
|
531
|
+
border: none;
|
|
532
|
+
border-radius: 4px;
|
|
533
|
+
font-weight: bold;
|
|
534
|
+
}
|
|
535
|
+
QPushButton:hover {
|
|
536
|
+
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
|
537
|
+
stop:0 #42A5F5, stop:1 #2196F3);
|
|
538
|
+
}
|
|
539
|
+
""")
|
|
540
|
+
layout.addWidget(self.load_button)
|
|
541
|
+
|
|
542
|
+
parent_layout.addWidget(group)
|
|
543
|
+
|
|
544
|
+
def create_instrument_panel(self, parent_splitter):
|
|
545
|
+
"""Create left panel with instrument filter (single selection)."""
|
|
546
|
+
panel = QWidget()
|
|
547
|
+
layout = QVBoxLayout(panel)
|
|
548
|
+
|
|
549
|
+
label = QLabel("<b>Select Instrument</b>")
|
|
550
|
+
layout.addWidget(label)
|
|
551
|
+
|
|
552
|
+
# Load all instruments button
|
|
553
|
+
refresh_btn = QPushButton("🔄 Refresh Instruments")
|
|
554
|
+
refresh_btn.clicked.connect(self.load_all_instruments)
|
|
555
|
+
layout.addWidget(refresh_btn)
|
|
556
|
+
|
|
557
|
+
# Instrument list - single selection
|
|
558
|
+
self.instrument_list = QListWidget()
|
|
559
|
+
self.instrument_list.setSelectionMode(QListWidget.SingleSelection)
|
|
560
|
+
|
|
561
|
+
# Load instruments
|
|
562
|
+
self.load_all_instruments()
|
|
563
|
+
|
|
564
|
+
# Connect selection change
|
|
565
|
+
self.instrument_list.itemSelectionChanged.connect(self.on_instrument_selected)
|
|
566
|
+
layout.addWidget(self.instrument_list)
|
|
567
|
+
|
|
568
|
+
# Image size control
|
|
569
|
+
layout.addSpacing(10)
|
|
570
|
+
size_label = QLabel("<b>Image Size (px)</b>")
|
|
571
|
+
layout.addWidget(size_label)
|
|
572
|
+
|
|
573
|
+
size_layout = QHBoxLayout()
|
|
574
|
+
size_layout.addWidget(QLabel("Size:"))
|
|
575
|
+
self.image_size_spin = QSpinBox()
|
|
576
|
+
self.image_size_spin.setRange(100, 4000)
|
|
577
|
+
self.image_size_spin.setValue(800)
|
|
578
|
+
self.image_size_spin.setSuffix(" px")
|
|
579
|
+
self.image_size_spin.setToolTip("Size for both width and height (100-4000 pixels)")
|
|
580
|
+
size_layout.addWidget(self.image_size_spin)
|
|
581
|
+
layout.addLayout(size_layout)
|
|
582
|
+
|
|
583
|
+
#size_info = QLabel("Note: Higher values take longer to download")
|
|
584
|
+
#size_info.setStyleSheet("color: #888;")
|
|
585
|
+
#size_info.setWordWrap(True)
|
|
586
|
+
#layout.addWidget(size_info)
|
|
587
|
+
|
|
588
|
+
parent_splitter.addWidget(panel)
|
|
589
|
+
|
|
590
|
+
def load_all_instruments(self):
|
|
591
|
+
"""Load all available instruments from Helioviewer."""
|
|
592
|
+
self.instrument_list.clear()
|
|
593
|
+
self.all_instruments = fetch_all_instruments()
|
|
594
|
+
|
|
595
|
+
for nickname, layer_path, observatory, cadence in self.all_instruments:
|
|
596
|
+
# Format cadence for display
|
|
597
|
+
if cadence < 60:
|
|
598
|
+
cad_str = f"{cadence}s"
|
|
599
|
+
elif cadence < 3600:
|
|
600
|
+
cad_str = f"{cadence//60}min"
|
|
601
|
+
else:
|
|
602
|
+
cad_str = f"{cadence//3600}hr"
|
|
603
|
+
|
|
604
|
+
item = QListWidgetItem(f"{nickname} ({observatory}) [{cad_str}]")
|
|
605
|
+
item.setData(Qt.UserRole, (nickname, layer_path, observatory, cadence))
|
|
606
|
+
self.instrument_list.addItem(item)
|
|
607
|
+
|
|
608
|
+
# Select first item by default
|
|
609
|
+
#if self.instrument_list.count() > 0:
|
|
610
|
+
# self.instrument_list.setCurrentRow(0)
|
|
611
|
+
# self.update_interval_for_instrument()
|
|
612
|
+
|
|
613
|
+
def set_all_instruments(self, checked):
|
|
614
|
+
"""Not used in single-selection mode."""
|
|
615
|
+
pass
|
|
616
|
+
|
|
617
|
+
def on_instrument_selected(self):
|
|
618
|
+
"""Handle instrument selection change."""
|
|
619
|
+
self.update_interval_for_instrument()
|
|
620
|
+
if self.frames:
|
|
621
|
+
self.display_current_frame()
|
|
622
|
+
|
|
623
|
+
def update_interval_for_instrument(self):
|
|
624
|
+
"""Update interval spinner based on selected instrument's cadence."""
|
|
625
|
+
selected = self.get_selected_instruments()
|
|
626
|
+
if selected:
|
|
627
|
+
nickname, layer_path, observatory, cadence = selected[0]
|
|
628
|
+
# Set interval to instrument cadence
|
|
629
|
+
self.interval_spin.setValue(int(cadence))
|
|
630
|
+
|
|
631
|
+
def create_image_panel(self, parent_splitter):
|
|
632
|
+
"""Create right panel with image grid."""
|
|
633
|
+
# Scroll area
|
|
634
|
+
scroll = QScrollArea()
|
|
635
|
+
scroll.setWidgetResizable(True)
|
|
636
|
+
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
637
|
+
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
638
|
+
|
|
639
|
+
# Container widget
|
|
640
|
+
self.image_container = QWidget()
|
|
641
|
+
self.image_grid = QVBoxLayout(self.image_container)
|
|
642
|
+
self.image_grid.setAlignment(Qt.AlignTop | Qt.AlignHCenter) # Center horizontally
|
|
643
|
+
|
|
644
|
+
# Placeholder
|
|
645
|
+
placeholder = QLabel("Load a time series to display images")
|
|
646
|
+
placeholder.setStyleSheet("color: #888; padding: 50px;")
|
|
647
|
+
placeholder.setAlignment(Qt.AlignCenter)
|
|
648
|
+
self.image_grid.addWidget(placeholder)
|
|
649
|
+
|
|
650
|
+
scroll.setWidget(self.image_container)
|
|
651
|
+
parent_splitter.addWidget(scroll)
|
|
652
|
+
|
|
653
|
+
def create_animation_controls(self, parent_layout):
|
|
654
|
+
"""Create bottom panel with animation controls."""
|
|
655
|
+
group = QGroupBox("Animation Controls")
|
|
656
|
+
layout = QHBoxLayout(group)
|
|
657
|
+
|
|
658
|
+
# First button
|
|
659
|
+
self.first_btn = QPushButton("|◄")
|
|
660
|
+
self.first_btn.setToolTip("First Frame")
|
|
661
|
+
self.first_btn.clicked.connect(self.first_frame)
|
|
662
|
+
self.first_btn.setEnabled(False)
|
|
663
|
+
layout.addWidget(self.first_btn)
|
|
664
|
+
|
|
665
|
+
# Previous button
|
|
666
|
+
self.prev_btn = QPushButton("◄")
|
|
667
|
+
self.prev_btn.setToolTip("Previous Frame")
|
|
668
|
+
self.prev_btn.clicked.connect(self.prev_frame)
|
|
669
|
+
self.prev_btn.setEnabled(False)
|
|
670
|
+
layout.addWidget(self.prev_btn)
|
|
671
|
+
|
|
672
|
+
# Play/Pause button
|
|
673
|
+
self.play_btn = QPushButton("▶")
|
|
674
|
+
self.play_btn.setToolTip("Play")
|
|
675
|
+
self.play_btn.clicked.connect(self.toggle_play)
|
|
676
|
+
self.play_btn.setEnabled(False)
|
|
677
|
+
layout.addWidget(self.play_btn)
|
|
678
|
+
|
|
679
|
+
# Next button
|
|
680
|
+
self.next_btn = QPushButton("►")
|
|
681
|
+
self.next_btn.setToolTip("Next Frame")
|
|
682
|
+
self.next_btn.clicked.connect(self.next_frame)
|
|
683
|
+
self.next_btn.setEnabled(False)
|
|
684
|
+
layout.addWidget(self.next_btn)
|
|
685
|
+
|
|
686
|
+
# Last button
|
|
687
|
+
self.last_btn = QPushButton("►|")
|
|
688
|
+
self.last_btn.setToolTip("Last Frame")
|
|
689
|
+
self.last_btn.clicked.connect(self.last_frame)
|
|
690
|
+
self.last_btn.setEnabled(False)
|
|
691
|
+
layout.addWidget(self.last_btn)
|
|
692
|
+
|
|
693
|
+
layout.addSpacing(20)
|
|
694
|
+
|
|
695
|
+
# Frame indicator
|
|
696
|
+
self.frame_label = QLabel("Frame: 0/0")
|
|
697
|
+
layout.addWidget(self.frame_label)
|
|
698
|
+
|
|
699
|
+
layout.addSpacing(20)
|
|
700
|
+
|
|
701
|
+
# Speed control
|
|
702
|
+
layout.addWidget(QLabel("Speed:"))
|
|
703
|
+
self.speed_combo = QComboBox()
|
|
704
|
+
self.speed_combo.addItems(["0.25x", "0.5x", "1x", "2x", "4x", "8x", "16x"])
|
|
705
|
+
self.speed_combo.setCurrentText("1x")
|
|
706
|
+
self.speed_combo.currentIndexChanged.connect(self.on_speed_changed)
|
|
707
|
+
layout.addWidget(self.speed_combo)
|
|
708
|
+
|
|
709
|
+
layout.addStretch()
|
|
710
|
+
|
|
711
|
+
# Save current frame
|
|
712
|
+
save_frame_btn = QPushButton("💾 Save Frame")
|
|
713
|
+
save_frame_btn.clicked.connect(self.save_current_frame)
|
|
714
|
+
layout.addWidget(save_frame_btn)
|
|
715
|
+
|
|
716
|
+
# Export animation
|
|
717
|
+
export_btn = QPushButton("🎬 Export Animation")
|
|
718
|
+
export_btn.clicked.connect(self.export_animation)
|
|
719
|
+
layout.addWidget(export_btn)
|
|
720
|
+
|
|
721
|
+
# Batch download
|
|
722
|
+
batch_btn = QPushButton("⬇️ Batch Download")
|
|
723
|
+
batch_btn.clicked.connect(self.batch_download)
|
|
724
|
+
layout.addWidget(batch_btn)
|
|
725
|
+
|
|
726
|
+
parent_layout.addWidget(group)
|
|
727
|
+
|
|
728
|
+
def on_speed_changed(self):
|
|
729
|
+
"""Handle speed change - update animation timer if playing."""
|
|
730
|
+
if self.playing:
|
|
731
|
+
# Recalculate interval and restart timer
|
|
732
|
+
speed_text = self.speed_combo.currentText()
|
|
733
|
+
speed = float(speed_text.replace('x', ''))
|
|
734
|
+
interval_ms = int(0.5 * 1000 / speed) # Base: 2 fps
|
|
735
|
+
self.animation_timer.setInterval(interval_ms)
|
|
736
|
+
|
|
737
|
+
def on_instrument_filter_changed(self):
|
|
738
|
+
"""Handle instrument filter change."""
|
|
739
|
+
if self.frames:
|
|
740
|
+
self.display_current_frame()
|
|
741
|
+
|
|
742
|
+
def load_time_series(self):
|
|
743
|
+
"""Load time series based on user inputs."""
|
|
744
|
+
start = self.start_datetime.dateTime()
|
|
745
|
+
end = self.end_datetime.dateTime()
|
|
746
|
+
interval_sec = self.interval_spin.value()
|
|
747
|
+
|
|
748
|
+
# Validate
|
|
749
|
+
if start >= end:
|
|
750
|
+
QMessageBox.warning(self, "Invalid Range", "Start time must be before end time.")
|
|
751
|
+
return
|
|
752
|
+
|
|
753
|
+
# Generate timestamps
|
|
754
|
+
self.timestamps = []
|
|
755
|
+
current = start
|
|
756
|
+
while current <= end:
|
|
757
|
+
self.timestamps.append(current)
|
|
758
|
+
current = current.addSecs(interval_sec)
|
|
759
|
+
|
|
760
|
+
# Limit to 4000 frames
|
|
761
|
+
if len(self.timestamps) > 4000:
|
|
762
|
+
QMessageBox.warning(
|
|
763
|
+
self, "Too Many Frames",
|
|
764
|
+
f"Time range generates {len(self.timestamps)} frames. Limiting to first 4000."
|
|
765
|
+
)
|
|
766
|
+
self.timestamps = self.timestamps[:4000]
|
|
767
|
+
|
|
768
|
+
# Clear old data
|
|
769
|
+
self.frames.clear()
|
|
770
|
+
self.current_frame = 0
|
|
771
|
+
|
|
772
|
+
# Get selected instruments
|
|
773
|
+
selected_instruments = self.get_selected_instruments()
|
|
774
|
+
if not selected_instruments:
|
|
775
|
+
QMessageBox.warning(self, "No Instruments", "Please select an instrument to proceed.")
|
|
776
|
+
return
|
|
777
|
+
|
|
778
|
+
# Show progress
|
|
779
|
+
self.progress_bar.setVisible(True)
|
|
780
|
+
self.progress_bar.setRange(0, len(self.timestamps))
|
|
781
|
+
self.progress_bar.setValue(0)
|
|
782
|
+
self.load_button.setEnabled(False)
|
|
783
|
+
|
|
784
|
+
# Load first frame immediately (synchronously)
|
|
785
|
+
self.load_frame_sync(0, selected_instruments)
|
|
786
|
+
|
|
787
|
+
# Queue remaining frames for parallel download
|
|
788
|
+
if len(self.timestamps) > 1:
|
|
789
|
+
for frame_idx in range(1, len(self.timestamps)):
|
|
790
|
+
for inst_data in selected_instruments:
|
|
791
|
+
self.download_queue.append((frame_idx, inst_data))
|
|
792
|
+
|
|
793
|
+
# Start initial batch of downloads
|
|
794
|
+
self._process_frame_download_queue()
|
|
795
|
+
else:
|
|
796
|
+
self.on_loading_finished()
|
|
797
|
+
|
|
798
|
+
def get_selected_instruments(self):
|
|
799
|
+
"""Get currently selected instrument (single selection)."""
|
|
800
|
+
selected_items = self.instrument_list.selectedItems()
|
|
801
|
+
if selected_items:
|
|
802
|
+
return [selected_items[0].data(Qt.UserRole)]
|
|
803
|
+
return []
|
|
804
|
+
|
|
805
|
+
def load_frame_sync(self, frame_idx, instruments):
|
|
806
|
+
"""Load a frame synchronously (for first frame)."""
|
|
807
|
+
frame_data = {}
|
|
808
|
+
timestamp = self.timestamps[frame_idx]
|
|
809
|
+
|
|
810
|
+
for nickname, layer_path, observatory, description in instruments:
|
|
811
|
+
downloader = ImageDownloader(nickname, layer_path, timestamp)
|
|
812
|
+
downloader.run() # Run synchronously
|
|
813
|
+
# Note: This won't emit signals, need to handle differently
|
|
814
|
+
|
|
815
|
+
# For now, start async download for first frame too
|
|
816
|
+
self.frames[frame_idx] = {}
|
|
817
|
+
downloaders = []
|
|
818
|
+
|
|
819
|
+
for nickname, layer_path, observatory, description in instruments:
|
|
820
|
+
downloader = ImageDownloader(nickname, layer_path, timestamp,
|
|
821
|
+
width=self.image_size_spin.value(),
|
|
822
|
+
height=self.image_size_spin.value())
|
|
823
|
+
downloader.finished.connect(
|
|
824
|
+
lambda inst, pix, idx=frame_idx: self.on_image_downloaded(idx, inst, pix)
|
|
825
|
+
)
|
|
826
|
+
downloader.error.connect(
|
|
827
|
+
lambda inst, err, idx=frame_idx: self.on_parallel_download_error(idx, inst, err)
|
|
828
|
+
)
|
|
829
|
+
downloaders.append(downloader)
|
|
830
|
+
self.frame_loaders.append(downloader) # Track for cleanup on close
|
|
831
|
+
downloader.start()
|
|
832
|
+
|
|
833
|
+
# Enable controls
|
|
834
|
+
self.enable_controls()
|
|
835
|
+
|
|
836
|
+
def _process_frame_download_queue(self):
|
|
837
|
+
"""Start next frame downloads if under concurrent limit."""
|
|
838
|
+
while self.active_downloads < self.max_concurrent_downloads and self.download_queue:
|
|
839
|
+
frame_idx, inst_data = self.download_queue.pop(0)
|
|
840
|
+
nickname, layer_path, observatory, description = inst_data
|
|
841
|
+
timestamp = self.timestamps[frame_idx]
|
|
842
|
+
|
|
843
|
+
self.active_downloads += 1
|
|
844
|
+
downloader = ImageDownloader(
|
|
845
|
+
nickname, layer_path, timestamp,
|
|
846
|
+
width=self.image_size_spin.value(),
|
|
847
|
+
height=self.image_size_spin.value()
|
|
848
|
+
)
|
|
849
|
+
downloader.finished.connect(
|
|
850
|
+
lambda inst, pix, idx=frame_idx: self.on_parallel_image_downloaded(idx, inst, pix)
|
|
851
|
+
)
|
|
852
|
+
downloader.error.connect(
|
|
853
|
+
lambda inst, err, idx=frame_idx: self.on_parallel_download_error(idx, inst, err)
|
|
854
|
+
)
|
|
855
|
+
downloader.finished.connect(self._on_frame_download_finished)
|
|
856
|
+
self.frame_loaders.append(downloader)
|
|
857
|
+
downloader.start()
|
|
858
|
+
|
|
859
|
+
def on_parallel_image_downloaded(self, frame_idx, instrument, pixmap):
|
|
860
|
+
"""Handle parallel image download completion."""
|
|
861
|
+
if self._closing:
|
|
862
|
+
return
|
|
863
|
+
|
|
864
|
+
if frame_idx not in self.frames:
|
|
865
|
+
self.frames[frame_idx] = {}
|
|
866
|
+
|
|
867
|
+
self.frames[frame_idx][instrument] = pixmap
|
|
868
|
+
|
|
869
|
+
# Update progress (count completed frames)
|
|
870
|
+
completed_frames = len(self.frames)
|
|
871
|
+
self.progress_bar.setValue(completed_frames)
|
|
872
|
+
|
|
873
|
+
# If this is the current frame, update display
|
|
874
|
+
if frame_idx == self.current_frame:
|
|
875
|
+
self.display_current_frame()
|
|
876
|
+
|
|
877
|
+
def on_parallel_download_error(self, frame_idx, instrument, error):
|
|
878
|
+
"""Handle parallel download error."""
|
|
879
|
+
if self._closing:
|
|
880
|
+
return
|
|
881
|
+
print(f"Error downloading {instrument} for frame {frame_idx}: {error}")
|
|
882
|
+
|
|
883
|
+
def _on_frame_download_finished(self):
|
|
884
|
+
"""Handle download thread finish and start next in queue."""
|
|
885
|
+
if self._closing:
|
|
886
|
+
return
|
|
887
|
+
|
|
888
|
+
self.active_downloads -= 1
|
|
889
|
+
if self.active_downloads < 0:
|
|
890
|
+
self.active_downloads = 0
|
|
891
|
+
|
|
892
|
+
# Process next in queue
|
|
893
|
+
self._process_frame_download_queue()
|
|
894
|
+
|
|
895
|
+
# Check if all downloads are complete
|
|
896
|
+
if self.active_downloads == 0 and len(self.download_queue) == 0:
|
|
897
|
+
self.on_loading_finished()
|
|
898
|
+
|
|
899
|
+
def on_image_downloaded(self, frame_idx, instrument, pixmap):
|
|
900
|
+
"""Handle single image download completion."""
|
|
901
|
+
if self._closing:
|
|
902
|
+
return
|
|
903
|
+
|
|
904
|
+
if frame_idx not in self.frames:
|
|
905
|
+
self.frames[frame_idx] = {}
|
|
906
|
+
|
|
907
|
+
self.frames[frame_idx][instrument] = pixmap
|
|
908
|
+
|
|
909
|
+
# If this is the current frame, update display
|
|
910
|
+
if frame_idx == self.current_frame:
|
|
911
|
+
self.display_current_frame()
|
|
912
|
+
|
|
913
|
+
def on_background_frame_loaded(self, frame_idx, frame_data):
|
|
914
|
+
"""Handle background frame loading."""
|
|
915
|
+
# Adjust index (background loader starts from index 1)
|
|
916
|
+
actual_idx = frame_idx + 1
|
|
917
|
+
self.frames[actual_idx] = frame_data
|
|
918
|
+
|
|
919
|
+
def on_loading_progress(self, current, total):
|
|
920
|
+
"""Update progress bar."""
|
|
921
|
+
self.progress_bar.setValue(current + 1) # +1 for the first frame loaded synchronously
|
|
922
|
+
|
|
923
|
+
def on_loading_finished(self):
|
|
924
|
+
"""Handle loading completion."""
|
|
925
|
+
self.progress_bar.setVisible(False)
|
|
926
|
+
self.load_button.setEnabled(True)
|
|
927
|
+
QMessageBox.information(
|
|
928
|
+
self, "Loading Complete",
|
|
929
|
+
f"Loaded {len(self.frames)} frames with selected instruments."
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
def enable_controls(self):
|
|
933
|
+
"""Enable animation controls."""
|
|
934
|
+
has_frames = len(self.timestamps) > 0
|
|
935
|
+
self.first_btn.setEnabled(has_frames)
|
|
936
|
+
self.prev_btn.setEnabled(has_frames)
|
|
937
|
+
self.play_btn.setEnabled(has_frames)
|
|
938
|
+
self.next_btn.setEnabled(has_frames)
|
|
939
|
+
self.last_btn.setEnabled(has_frames)
|
|
940
|
+
|
|
941
|
+
if has_frames:
|
|
942
|
+
self.display_current_frame()
|
|
943
|
+
|
|
944
|
+
def display_current_frame(self):
|
|
945
|
+
"""Display the current frame's image (single instrument, fullscreen)."""
|
|
946
|
+
# Clear grid
|
|
947
|
+
while self.image_grid.count():
|
|
948
|
+
child = self.image_grid.takeAt(0)
|
|
949
|
+
if child.widget():
|
|
950
|
+
child.widget().deleteLater()
|
|
951
|
+
|
|
952
|
+
if self.current_frame >= len(self.timestamps):
|
|
953
|
+
return
|
|
954
|
+
|
|
955
|
+
timestamp = self.timestamps[self.current_frame]
|
|
956
|
+
frame_data = self.frames.get(self.current_frame, {})
|
|
957
|
+
|
|
958
|
+
# Get selected instrument (single selection)
|
|
959
|
+
selected = self.get_selected_instruments()
|
|
960
|
+
if not selected:
|
|
961
|
+
placeholder = QLabel("Please select an instrument from the list")
|
|
962
|
+
placeholder.setStyleSheet("color: #888; padding: 50px;")
|
|
963
|
+
placeholder.setAlignment(Qt.AlignCenter)
|
|
964
|
+
self.image_grid.addWidget(placeholder)
|
|
965
|
+
return
|
|
966
|
+
|
|
967
|
+
nickname, layer_path, observatory, cadence = selected[0]
|
|
968
|
+
|
|
969
|
+
# Title with timestamp and instrument
|
|
970
|
+
title = QLabel(f"<h2>{nickname} ({observatory})</h2>")
|
|
971
|
+
title.setAlignment(Qt.AlignCenter)
|
|
972
|
+
title.setStyleSheet("color: #2196F3; padding: 5px;")
|
|
973
|
+
self.image_grid.addWidget(title)
|
|
974
|
+
|
|
975
|
+
time_label = QLabel(f"<h3>{timestamp.toString('yyyy-MM-dd HH:mm:ss')} UTC</h3>")
|
|
976
|
+
time_label.setAlignment(Qt.AlignCenter)
|
|
977
|
+
time_label.setStyleSheet("color: #666; padding: 5px;")
|
|
978
|
+
self.image_grid.addWidget(time_label)
|
|
979
|
+
|
|
980
|
+
# Fullscreen image
|
|
981
|
+
pixmap = frame_data.get(nickname)
|
|
982
|
+
|
|
983
|
+
img_label = QLabel()
|
|
984
|
+
img_label.setAlignment(Qt.AlignCenter)
|
|
985
|
+
|
|
986
|
+
if pixmap:
|
|
987
|
+
# Display at original size (1000x1000)
|
|
988
|
+
img_label.setPixmap(pixmap)
|
|
989
|
+
img_label.setCursor(Qt.PointingHandCursor)
|
|
990
|
+
img_label.mousePressEvent = lambda e, p=pixmap, n=nickname: self.show_full_image(p, n)
|
|
991
|
+
else:
|
|
992
|
+
img_label.setText("Loading...")
|
|
993
|
+
img_label.setStyleSheet("color: #666; padding: 100px;")
|
|
994
|
+
|
|
995
|
+
# Add with stretch to center vertically
|
|
996
|
+
self.image_grid.addWidget(img_label, 1)
|
|
997
|
+
|
|
998
|
+
# Update frame label
|
|
999
|
+
self.frame_label.setText(f"Frame: {self.current_frame + 1}/{len(self.timestamps)}")
|
|
1000
|
+
|
|
1001
|
+
def create_image_card(self, instrument_name, pixmap, timestamp):
|
|
1002
|
+
"""Create a card widget for an image."""
|
|
1003
|
+
card = QFrame()
|
|
1004
|
+
card.setFrameStyle(QFrame.Box | QFrame.Raised)
|
|
1005
|
+
card.setLineWidth(1)
|
|
1006
|
+
card.setStyleSheet("background: #2b2b2b; border-radius: 8px;")
|
|
1007
|
+
|
|
1008
|
+
layout = QVBoxLayout(card)
|
|
1009
|
+
layout.setContentsMargins(8, 8, 8, 8)
|
|
1010
|
+
|
|
1011
|
+
# Instrument name
|
|
1012
|
+
name_label = QLabel(f"<b>{instrument_name}</b>")
|
|
1013
|
+
name_label.setStyleSheet("color: #2196F3;")
|
|
1014
|
+
name_label.setAlignment(Qt.AlignCenter)
|
|
1015
|
+
layout.addWidget(name_label)
|
|
1016
|
+
|
|
1017
|
+
# Time
|
|
1018
|
+
time_label = QLabel(timestamp.toString("HH:mm:ss") + " UTC")
|
|
1019
|
+
time_label.setStyleSheet("color: #888;")
|
|
1020
|
+
time_label.setAlignment(Qt.AlignCenter)
|
|
1021
|
+
layout.addWidget(time_label)
|
|
1022
|
+
|
|
1023
|
+
# Image
|
|
1024
|
+
img_label = QLabel()
|
|
1025
|
+
img_label.setFixedSize(250, 250)
|
|
1026
|
+
img_label.setAlignment(Qt.AlignCenter)
|
|
1027
|
+
img_label.setStyleSheet("background: #000; border: 1px solid #555;")
|
|
1028
|
+
|
|
1029
|
+
if pixmap:
|
|
1030
|
+
scaled = pixmap.scaled(250, 250, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
|
1031
|
+
img_label.setPixmap(scaled)
|
|
1032
|
+
img_label.setCursor(Qt.PointingHandCursor)
|
|
1033
|
+
img_label.mousePressEvent = lambda e, p=pixmap, n=instrument_name: self.show_full_image(p, n)
|
|
1034
|
+
else:
|
|
1035
|
+
img_label.setText("Loading...")
|
|
1036
|
+
img_label.setStyleSheet("background: #000; color: #666;")
|
|
1037
|
+
|
|
1038
|
+
layout.addWidget(img_label)
|
|
1039
|
+
|
|
1040
|
+
return card
|
|
1041
|
+
|
|
1042
|
+
def show_full_image(self, pixmap, title):
|
|
1043
|
+
"""Show full resolution image in dialog."""
|
|
1044
|
+
dialog = FullImageDialog(self, pixmap, title)
|
|
1045
|
+
dialog.exec_()
|
|
1046
|
+
|
|
1047
|
+
# Animation controls
|
|
1048
|
+
def first_frame(self):
|
|
1049
|
+
"""Jump to first frame."""
|
|
1050
|
+
self.current_frame = 0
|
|
1051
|
+
self.display_current_frame()
|
|
1052
|
+
|
|
1053
|
+
def prev_frame(self):
|
|
1054
|
+
"""Go to previous frame."""
|
|
1055
|
+
if self.current_frame > 0:
|
|
1056
|
+
self.current_frame -= 1
|
|
1057
|
+
self.display_current_frame()
|
|
1058
|
+
|
|
1059
|
+
def next_frame(self):
|
|
1060
|
+
"""Go to next frame."""
|
|
1061
|
+
if self.current_frame < len(self.timestamps) - 1:
|
|
1062
|
+
self.current_frame += 1
|
|
1063
|
+
self.display_current_frame()
|
|
1064
|
+
else:
|
|
1065
|
+
# Loop back to start
|
|
1066
|
+
self.current_frame = 0
|
|
1067
|
+
self.display_current_frame()
|
|
1068
|
+
|
|
1069
|
+
def last_frame(self):
|
|
1070
|
+
"""Jump to last frame."""
|
|
1071
|
+
self.current_frame = len(self.timestamps) - 1
|
|
1072
|
+
self.display_current_frame()
|
|
1073
|
+
|
|
1074
|
+
def toggle_play(self):
|
|
1075
|
+
"""Toggle animation playback."""
|
|
1076
|
+
if not self.playing:
|
|
1077
|
+
self.playing = True
|
|
1078
|
+
self.play_btn.setText("❚❚")
|
|
1079
|
+
self.play_btn.setToolTip("Pause")
|
|
1080
|
+
|
|
1081
|
+
# Get speed
|
|
1082
|
+
speed_text = self.speed_combo.currentText()
|
|
1083
|
+
speed = float(speed_text.replace('x', ''))
|
|
1084
|
+
interval_ms = int(0.5 * 1000 / speed) # Base: 2 fps
|
|
1085
|
+
|
|
1086
|
+
self.animation_timer.start(interval_ms)
|
|
1087
|
+
else:
|
|
1088
|
+
self.pause_animation()
|
|
1089
|
+
|
|
1090
|
+
def pause_animation(self):
|
|
1091
|
+
"""Pause animation."""
|
|
1092
|
+
self.playing = False
|
|
1093
|
+
self.play_btn.setText("▶")
|
|
1094
|
+
self.play_btn.setToolTip("Play")
|
|
1095
|
+
self.animation_timer.stop()
|
|
1096
|
+
|
|
1097
|
+
# Export/Save functions
|
|
1098
|
+
def save_current_frame(self):
|
|
1099
|
+
"""Save current frame as PNG."""
|
|
1100
|
+
if not self.frames or self.current_frame >= len(self.timestamps):
|
|
1101
|
+
QMessageBox.warning(self, "No Frame", "No frame to save.")
|
|
1102
|
+
return
|
|
1103
|
+
|
|
1104
|
+
# Get selected instrument
|
|
1105
|
+
selected = self.get_selected_instruments()
|
|
1106
|
+
if not selected:
|
|
1107
|
+
QMessageBox.warning(self, "No Instrument", "Please select an instrument.")
|
|
1108
|
+
return
|
|
1109
|
+
|
|
1110
|
+
nickname, _, _, _ = selected[0]
|
|
1111
|
+
timestamp = self.timestamps[self.current_frame]
|
|
1112
|
+
|
|
1113
|
+
# Get pixmap for current frame
|
|
1114
|
+
frame_data = self.frames.get(self.current_frame, {})
|
|
1115
|
+
pixmap = frame_data.get(nickname)
|
|
1116
|
+
|
|
1117
|
+
if not pixmap:
|
|
1118
|
+
QMessageBox.warning(self, "No Image", "Current frame image not loaded yet.")
|
|
1119
|
+
return
|
|
1120
|
+
|
|
1121
|
+
# Create default filename
|
|
1122
|
+
safe_name = nickname.replace('/', '_').replace(' ', '_')
|
|
1123
|
+
default_name = f"helioviewer_{safe_name}_{timestamp.toString('yyyyMMdd_HHmmss')}.png"
|
|
1124
|
+
|
|
1125
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
1126
|
+
self, "Save Frame", default_name, "PNG Image (*.png)"
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
if file_path:
|
|
1130
|
+
try:
|
|
1131
|
+
if pixmap.save(file_path, "PNG"):
|
|
1132
|
+
QMessageBox.information(
|
|
1133
|
+
self, "Save Successful",
|
|
1134
|
+
f"Frame saved to:\n{file_path}"
|
|
1135
|
+
)
|
|
1136
|
+
else:
|
|
1137
|
+
QMessageBox.warning(
|
|
1138
|
+
self, "Save Failed",
|
|
1139
|
+
"Failed to save image. Please check file path and permissions."
|
|
1140
|
+
)
|
|
1141
|
+
except Exception as e:
|
|
1142
|
+
QMessageBox.critical(
|
|
1143
|
+
self, "Error",
|
|
1144
|
+
f"Error saving frame:\n{str(e)}"
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
def export_animation(self):
|
|
1148
|
+
"""Export animation as GIF or MP4."""
|
|
1149
|
+
if len(self.frames) < 2:
|
|
1150
|
+
QMessageBox.warning(self, "Insufficient Frames", "Need at least 2 frames for animation.")
|
|
1151
|
+
return
|
|
1152
|
+
|
|
1153
|
+
# Get selected instrument
|
|
1154
|
+
selected = self.get_selected_instruments()
|
|
1155
|
+
if not selected:
|
|
1156
|
+
QMessageBox.warning(self, "No Instrument", "Please select an instrument.")
|
|
1157
|
+
return
|
|
1158
|
+
|
|
1159
|
+
nickname, _, _, _ = selected[0]
|
|
1160
|
+
|
|
1161
|
+
# Ask for format
|
|
1162
|
+
from PyQt5.QtWidgets import QInputDialog, QProgressDialog, QCheckBox
|
|
1163
|
+
formats = ["GIF", "MP4"]
|
|
1164
|
+
format_choice, ok = QInputDialog.getItem(
|
|
1165
|
+
self, "Export Format", "Choose export format:", formats, 0, False
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
if not ok:
|
|
1169
|
+
return
|
|
1170
|
+
|
|
1171
|
+
# Ask about timestamp overlay
|
|
1172
|
+
timestamp_dialog = QMessageBox(self)
|
|
1173
|
+
timestamp_dialog.setWindowTitle("Timestamp Overlay")
|
|
1174
|
+
timestamp_dialog.setText("Include timestamp on each frame?")
|
|
1175
|
+
timestamp_dialog.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
|
|
1176
|
+
timestamp_dialog.setDefaultButton(QMessageBox.Yes)
|
|
1177
|
+
include_timestamp = (timestamp_dialog.exec_() == QMessageBox.Yes)
|
|
1178
|
+
|
|
1179
|
+
# Get save path
|
|
1180
|
+
safe_name = nickname.replace('/', '_').replace(' ', '_')
|
|
1181
|
+
start_time = self.timestamps[0].toString('yyyyMMdd_HHmmss')
|
|
1182
|
+
ext = "gif" if format_choice == "GIF" else "mp4"
|
|
1183
|
+
default_name = f"helioviewer_{safe_name}_{start_time}.{ext}"
|
|
1184
|
+
|
|
1185
|
+
file_filter = "GIF Image (*.gif)" if format_choice == "GIF" else "MP4 Video (*.mp4)"
|
|
1186
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
1187
|
+
self, "Export Animation", default_name, file_filter
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
if not file_path:
|
|
1191
|
+
return
|
|
1192
|
+
|
|
1193
|
+
# Collect frames
|
|
1194
|
+
frames_to_export = []
|
|
1195
|
+
for idx in sorted(self.frames.keys()):
|
|
1196
|
+
frame_data = self.frames[idx]
|
|
1197
|
+
pixmap = frame_data.get(nickname)
|
|
1198
|
+
if pixmap:
|
|
1199
|
+
frames_to_export.append(pixmap)
|
|
1200
|
+
|
|
1201
|
+
if len(frames_to_export) < 2:
|
|
1202
|
+
QMessageBox.warning(
|
|
1203
|
+
self, "Insufficient Frames",
|
|
1204
|
+
f"Only {len(frames_to_export)} frame(s) loaded for {nickname}. Need at least 2."
|
|
1205
|
+
)
|
|
1206
|
+
return
|
|
1207
|
+
|
|
1208
|
+
# Show progress dialog
|
|
1209
|
+
progress = QProgressDialog("Exporting animation...", "Cancel", 0, len(frames_to_export), self)
|
|
1210
|
+
progress.setWindowModality(Qt.WindowModal)
|
|
1211
|
+
progress.setMinimumDuration(0)
|
|
1212
|
+
|
|
1213
|
+
# Get animation speed from UI
|
|
1214
|
+
speed_text = self.speed_combo.currentText()
|
|
1215
|
+
speed = float(speed_text.replace('x', ''))
|
|
1216
|
+
# Calculate frame duration in ms (base: 2 fps at 1x speed = 500ms)
|
|
1217
|
+
frame_duration_ms = int(500 / speed)
|
|
1218
|
+
|
|
1219
|
+
try:
|
|
1220
|
+
if format_choice == "GIF":
|
|
1221
|
+
self._export_gif(frames_to_export, file_path, progress, frame_duration_ms, include_timestamp)
|
|
1222
|
+
else:
|
|
1223
|
+
# For MP4, convert to FPS
|
|
1224
|
+
fps = 1000.0 / frame_duration_ms
|
|
1225
|
+
self._export_mp4(frames_to_export, file_path, progress, fps, include_timestamp)
|
|
1226
|
+
|
|
1227
|
+
if not progress.wasCanceled():
|
|
1228
|
+
QMessageBox.information(
|
|
1229
|
+
self, "Export Successful",
|
|
1230
|
+
f"Animation exported to:\n{file_path}\n\n"
|
|
1231
|
+
f"Frames: {len(frames_to_export)}"
|
|
1232
|
+
)
|
|
1233
|
+
except Exception as e:
|
|
1234
|
+
progress.close()
|
|
1235
|
+
QMessageBox.critical(
|
|
1236
|
+
self, "Export Failed",
|
|
1237
|
+
f"Error exporting animation:\n{str(e)}"
|
|
1238
|
+
)
|
|
1239
|
+
finally:
|
|
1240
|
+
progress.close()
|
|
1241
|
+
|
|
1242
|
+
def _add_timestamp_to_pixmap(self, pixmap, timestamp_text):
|
|
1243
|
+
"""Add timestamp overlay to a pixmap."""
|
|
1244
|
+
from PyQt5.QtGui import QPainter, QFont, QColor, QPen
|
|
1245
|
+
from PyQt5.QtCore import Qt, QRect
|
|
1246
|
+
|
|
1247
|
+
# Create a copy to avoid modifying original
|
|
1248
|
+
result = QPixmap(pixmap)
|
|
1249
|
+
painter = QPainter(result)
|
|
1250
|
+
|
|
1251
|
+
# Set up font
|
|
1252
|
+
font = QFont("Arial", 12, QFont.Bold)
|
|
1253
|
+
painter.setFont(font)
|
|
1254
|
+
|
|
1255
|
+
# Draw background rectangle for text
|
|
1256
|
+
text_rect = painter.fontMetrics().boundingRect(timestamp_text)
|
|
1257
|
+
padding = 15
|
|
1258
|
+
bg_rect = QRect(
|
|
1259
|
+
10,
|
|
1260
|
+
result.height() - text_rect.height() - padding * 2 - 10,
|
|
1261
|
+
text_rect.width() + padding * 2,
|
|
1262
|
+
text_rect.height() + padding * 2
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
# Semi-transparent black background
|
|
1266
|
+
painter.fillRect(bg_rect, QColor(0, 0, 0, 180))
|
|
1267
|
+
|
|
1268
|
+
# Draw white text
|
|
1269
|
+
painter.setPen(QPen(QColor(255, 255, 255)))
|
|
1270
|
+
painter.drawText(
|
|
1271
|
+
bg_rect.adjusted(padding, padding, -padding, -padding),
|
|
1272
|
+
Qt.AlignLeft | Qt.AlignVCenter,
|
|
1273
|
+
timestamp_text
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
painter.end()
|
|
1277
|
+
return result
|
|
1278
|
+
|
|
1279
|
+
def _export_gif(self, frames, file_path, progress, duration_ms, include_timestamp=False):
|
|
1280
|
+
"""Export frames as animated GIF."""
|
|
1281
|
+
try:
|
|
1282
|
+
from PIL import Image
|
|
1283
|
+
except ImportError:
|
|
1284
|
+
raise Exception("PIL/Pillow is required for GIF export. Install with: pip install Pillow")
|
|
1285
|
+
|
|
1286
|
+
# Convert QPixmaps to PIL Images
|
|
1287
|
+
pil_images = []
|
|
1288
|
+
for i, pixmap in enumerate(frames):
|
|
1289
|
+
if progress.wasCanceled():
|
|
1290
|
+
return
|
|
1291
|
+
progress.setValue(i)
|
|
1292
|
+
|
|
1293
|
+
# Add timestamp if requested
|
|
1294
|
+
if include_timestamp and i < len(self.timestamps):
|
|
1295
|
+
timestamp_text = self.timestamps[i].toString('yyyy-MM-dd HH:mm:ss') + ' UTC'
|
|
1296
|
+
pixmap = self._add_timestamp_to_pixmap(pixmap, timestamp_text)
|
|
1297
|
+
|
|
1298
|
+
# Convert QPixmap to PIL Image via QImage
|
|
1299
|
+
qimage = pixmap.toImage()
|
|
1300
|
+
qimage = qimage.convertToFormat(QImage.Format_RGB888)
|
|
1301
|
+
|
|
1302
|
+
width = qimage.width()
|
|
1303
|
+
height = qimage.height()
|
|
1304
|
+
ptr = qimage.bits()
|
|
1305
|
+
ptr.setsize(height * width * 3)
|
|
1306
|
+
|
|
1307
|
+
import numpy as np
|
|
1308
|
+
arr = np.frombuffer(ptr, np.uint8).reshape((height, width, 3))
|
|
1309
|
+
pil_img = Image.fromarray(arr, 'RGB')
|
|
1310
|
+
pil_images.append(pil_img)
|
|
1311
|
+
|
|
1312
|
+
# Save as animated GIF with user-selected speed
|
|
1313
|
+
progress.setLabelText("Saving GIF...")
|
|
1314
|
+
pil_images[0].save(
|
|
1315
|
+
file_path,
|
|
1316
|
+
save_all=True,
|
|
1317
|
+
append_images=pil_images[1:],
|
|
1318
|
+
duration=duration_ms,
|
|
1319
|
+
loop=0
|
|
1320
|
+
)
|
|
1321
|
+
progress.setValue(len(frames))
|
|
1322
|
+
|
|
1323
|
+
def _export_mp4(self, frames, file_path, progress, fps, include_timestamp=False):
|
|
1324
|
+
"""Export frames as MP4 video."""
|
|
1325
|
+
try:
|
|
1326
|
+
import cv2
|
|
1327
|
+
import numpy as np
|
|
1328
|
+
except ImportError:
|
|
1329
|
+
raise Exception("OpenCV is required for MP4 export. Install with: pip install opencv-python")
|
|
1330
|
+
|
|
1331
|
+
# Get frame dimensions from first frame
|
|
1332
|
+
first_pixmap = frames[0]
|
|
1333
|
+
width = first_pixmap.width()
|
|
1334
|
+
height = first_pixmap.height()
|
|
1335
|
+
|
|
1336
|
+
# Create video writer with user-selected FPS
|
|
1337
|
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
|
1338
|
+
out = cv2.VideoWriter(file_path, fourcc, fps, (width, height))
|
|
1339
|
+
|
|
1340
|
+
try:
|
|
1341
|
+
for i, pixmap in enumerate(frames):
|
|
1342
|
+
if progress.wasCanceled():
|
|
1343
|
+
return
|
|
1344
|
+
progress.setValue(i)
|
|
1345
|
+
|
|
1346
|
+
# Add timestamp if requested
|
|
1347
|
+
if include_timestamp and i < len(self.timestamps):
|
|
1348
|
+
timestamp_text = self.timestamps[i].toString('yyyy-MM-dd HH:mm:ss') + ' UTC'
|
|
1349
|
+
pixmap = self._add_timestamp_to_pixmap(pixmap, timestamp_text)
|
|
1350
|
+
|
|
1351
|
+
# Convert QPixmap to numpy array
|
|
1352
|
+
qimage = pixmap.toImage()
|
|
1353
|
+
qimage = qimage.convertToFormat(QImage.Format_RGB888)
|
|
1354
|
+
|
|
1355
|
+
width = qimage.width()
|
|
1356
|
+
height = qimage.height()
|
|
1357
|
+
ptr = qimage.bits()
|
|
1358
|
+
ptr.setsize(height * width * 3)
|
|
1359
|
+
arr = np.frombuffer(ptr, np.uint8).reshape((height, width, 3))
|
|
1360
|
+
|
|
1361
|
+
# OpenCV uses BGR, Qt uses RGB
|
|
1362
|
+
arr = cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)
|
|
1363
|
+
out.write(arr)
|
|
1364
|
+
|
|
1365
|
+
progress.setValue(len(frames))
|
|
1366
|
+
finally:
|
|
1367
|
+
out.release()
|
|
1368
|
+
|
|
1369
|
+
def batch_download(self):
|
|
1370
|
+
"""Batch download all frames."""
|
|
1371
|
+
if not self.frames:
|
|
1372
|
+
QMessageBox.warning(self, "No Data", "No frames to download.")
|
|
1373
|
+
return
|
|
1374
|
+
|
|
1375
|
+
# Get selected instrument
|
|
1376
|
+
selected = self.get_selected_instruments()
|
|
1377
|
+
if not selected:
|
|
1378
|
+
QMessageBox.warning(self, "No Instrument", "Please select an instrument.")
|
|
1379
|
+
return
|
|
1380
|
+
|
|
1381
|
+
nickname, _, _, _ = selected[0]
|
|
1382
|
+
|
|
1383
|
+
dir_path = QFileDialog.getExistingDirectory(self, "Select Download Directory")
|
|
1384
|
+
if not dir_path:
|
|
1385
|
+
return
|
|
1386
|
+
|
|
1387
|
+
# Create subdirectory with timestamp
|
|
1388
|
+
from datetime import datetime
|
|
1389
|
+
safe_name = nickname.replace('/', '_').replace(' ', '_')
|
|
1390
|
+
timestamp_str = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
1391
|
+
batch_dir = os.path.join(dir_path, f"helioviewer_{safe_name}_{timestamp_str}")
|
|
1392
|
+
|
|
1393
|
+
try:
|
|
1394
|
+
os.makedirs(batch_dir, exist_ok=True)
|
|
1395
|
+
except Exception as e:
|
|
1396
|
+
QMessageBox.critical(
|
|
1397
|
+
self, "Error",
|
|
1398
|
+
f"Failed to create directory:\n{str(e)}"
|
|
1399
|
+
)
|
|
1400
|
+
return
|
|
1401
|
+
|
|
1402
|
+
# Show progress
|
|
1403
|
+
from PyQt5.QtWidgets import QProgressDialog
|
|
1404
|
+
progress = QProgressDialog(
|
|
1405
|
+
"Downloading frames...", "Cancel", 0, len(self.frames), self
|
|
1406
|
+
)
|
|
1407
|
+
progress.setWindowModality(Qt.WindowModal)
|
|
1408
|
+
progress.setMinimumDuration(0)
|
|
1409
|
+
|
|
1410
|
+
saved_count = 0
|
|
1411
|
+
metadata_lines = []
|
|
1412
|
+
metadata_lines.append(f"Helioviewer Batch Download")
|
|
1413
|
+
metadata_lines.append(f"Instrument: {nickname}")
|
|
1414
|
+
metadata_lines.append(f"Download Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
1415
|
+
metadata_lines.append(f"Total Frames: {len(self.frames)}")
|
|
1416
|
+
metadata_lines.append("")
|
|
1417
|
+
metadata_lines.append("Frame Index | Timestamp | Filename | Status")
|
|
1418
|
+
metadata_lines.append("-" * 80)
|
|
1419
|
+
|
|
1420
|
+
try:
|
|
1421
|
+
for idx in sorted(self.frames.keys()):
|
|
1422
|
+
if progress.wasCanceled():
|
|
1423
|
+
break
|
|
1424
|
+
|
|
1425
|
+
progress.setValue(saved_count)
|
|
1426
|
+
|
|
1427
|
+
frame_data = self.frames[idx]
|
|
1428
|
+
pixmap = frame_data.get(nickname)
|
|
1429
|
+
|
|
1430
|
+
if pixmap:
|
|
1431
|
+
timestamp = self.timestamps[idx]
|
|
1432
|
+
filename = f"frame_{idx:04d}_{timestamp.toString('yyyyMMdd_HHmmss')}.png"
|
|
1433
|
+
file_path = os.path.join(batch_dir, filename)
|
|
1434
|
+
|
|
1435
|
+
if pixmap.save(file_path, "PNG"):
|
|
1436
|
+
saved_count += 1
|
|
1437
|
+
status = "OK"
|
|
1438
|
+
else:
|
|
1439
|
+
status = "FAILED"
|
|
1440
|
+
else:
|
|
1441
|
+
timestamp = self.timestamps[idx] if idx < len(self.timestamps) else "Unknown"
|
|
1442
|
+
filename = f"frame_{idx:04d}_missing.png"
|
|
1443
|
+
status = "MISSING"
|
|
1444
|
+
|
|
1445
|
+
metadata_lines.append(
|
|
1446
|
+
f"{idx:4d} | {timestamp.toString('yyyy-MM-dd HH:mm:ss') if hasattr(timestamp, 'toString') else timestamp} | {filename} | {status}"
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
# Save metadata file
|
|
1450
|
+
metadata_path = os.path.join(batch_dir, "_metadata.txt")
|
|
1451
|
+
with open(metadata_path, 'w') as f:
|
|
1452
|
+
f.write('\n'.join(metadata_lines))
|
|
1453
|
+
|
|
1454
|
+
progress.setValue(len(self.frames))
|
|
1455
|
+
|
|
1456
|
+
if not progress.wasCanceled():
|
|
1457
|
+
QMessageBox.information(
|
|
1458
|
+
self, "Batch Download Complete",
|
|
1459
|
+
f"Successfully saved {saved_count}/{len(self.frames)} frames to:\n{batch_dir}\n\n"
|
|
1460
|
+
f"Metadata saved to: _metadata.txt"
|
|
1461
|
+
)
|
|
1462
|
+
except Exception as e:
|
|
1463
|
+
progress.close()
|
|
1464
|
+
QMessageBox.critical(
|
|
1465
|
+
self, "Error",
|
|
1466
|
+
f"Error during batch download:\n{str(e)}"
|
|
1467
|
+
)
|
|
1468
|
+
finally:
|
|
1469
|
+
progress.close()
|
|
1470
|
+
|
|
1471
|
+
def closeEvent(self, event):
|
|
1472
|
+
"""Handle window close."""
|
|
1473
|
+
# Set closing flag first to prevent signal handlers from updating UI
|
|
1474
|
+
self._closing = True
|
|
1475
|
+
|
|
1476
|
+
# Clear download queue first to prevent new downloads from starting
|
|
1477
|
+
self.download_queue.clear()
|
|
1478
|
+
|
|
1479
|
+
# Stop all loaders (both ImageDownloader and FrameLoader)
|
|
1480
|
+
for loader in self.frame_loaders:
|
|
1481
|
+
if hasattr(loader, 'stop'):
|
|
1482
|
+
loader.stop()
|
|
1483
|
+
|
|
1484
|
+
# Wait for all threads to finish with a timeout
|
|
1485
|
+
remaining_threads = []
|
|
1486
|
+
for loader in self.frame_loaders:
|
|
1487
|
+
if loader.isRunning():
|
|
1488
|
+
# Wait up to 1 second per thread (fast cleanup)
|
|
1489
|
+
if not loader.wait(1000):
|
|
1490
|
+
print(f"[WARNING] Thread {loader} did not stop in time, moving to background")
|
|
1491
|
+
remaining_threads.append(loader)
|
|
1492
|
+
|
|
1493
|
+
# Move stuck threads to global list to prevent GC crash
|
|
1494
|
+
if remaining_threads:
|
|
1495
|
+
global _active_threads
|
|
1496
|
+
_active_threads.extend(remaining_threads)
|
|
1497
|
+
|
|
1498
|
+
# Clean up global list of finished threads
|
|
1499
|
+
_active_threads = [t for t in _active_threads if t.isRunning()]
|
|
1500
|
+
|
|
1501
|
+
self.frame_loaders.clear()
|
|
1502
|
+
self.pause_animation()
|
|
1503
|
+
event.accept()
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
def main():
|
|
1507
|
+
import sys
|
|
1508
|
+
app = QApplication(sys.argv)
|
|
1509
|
+
window = HelioviewerBrowser()
|
|
1510
|
+
window.show()
|
|
1511
|
+
sys.exit(app.exec_())
|
|
1512
|
+
|
|
1513
|
+
if __name__ == "__main__":
|
|
1514
|
+
main()
|