diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3f847f6
Binary files /dev/null and b/.gitignore differ
diff --git a/.idea/HelioParser.iml b/.idea/HelioParser.iml
new file mode 100644
index 0000000..6414205
--- /dev/null
+++ b/.idea/HelioParser.iml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..6388de0
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..e575720
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
deleted file mode 100644
index 3f8d9bf..0000000
--- a/.idea/workspace.xml
+++ /dev/null
@@ -1,90 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- "associatedIndex": 1
-}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1781083136733
-
-
- 1781083136733
-
-
-
-
-
-
-
-
- 1781245747321
-
-
-
- 1781245747321
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/controllers/__init__.py b/controllers/__init__.py
new file mode 100644
index 0000000..55172a7
--- /dev/null
+++ b/controllers/__init__.py
@@ -0,0 +1,10 @@
+# controllers/__init__.py
+from controllers.app_controller import AppController
+from controllers.layer_controller import LayerController
+from controllers.timelapse_controller import TimelapseController
+
+__all__ = [
+ 'AppController',
+ 'LayerController',
+ 'TimelapseController'
+]
\ No newline at end of file
diff --git a/controllers/__pycache__/__init__.cpython-312.pyc b/controllers/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..86b9f07
Binary files /dev/null and b/controllers/__pycache__/__init__.cpython-312.pyc differ
diff --git a/controllers/__pycache__/app_controller.cpython-312.pyc b/controllers/__pycache__/app_controller.cpython-312.pyc
new file mode 100644
index 0000000..cf6f514
Binary files /dev/null and b/controllers/__pycache__/app_controller.cpython-312.pyc differ
diff --git a/controllers/__pycache__/layer_controller.cpython-312.pyc b/controllers/__pycache__/layer_controller.cpython-312.pyc
new file mode 100644
index 0000000..900c16c
Binary files /dev/null and b/controllers/__pycache__/layer_controller.cpython-312.pyc differ
diff --git a/controllers/__pycache__/timelapse_controller.cpython-312.pyc b/controllers/__pycache__/timelapse_controller.cpython-312.pyc
new file mode 100644
index 0000000..762bcca
Binary files /dev/null and b/controllers/__pycache__/timelapse_controller.cpython-312.pyc differ
diff --git a/controllers/app_controller.py b/controllers/app_controller.py
new file mode 100644
index 0000000..e182ac3
--- /dev/null
+++ b/controllers/app_controller.py
@@ -0,0 +1,191 @@
+"""
+Главный контроллер приложения - связывает модель и представление
+"""
+
+from PySide6.QtCore import QObject, Signal
+from datetime import datetime
+from pathlib import Path
+import numpy as np
+
+from models.api_model import HelioviewerAPI
+from models.image_model import ImageModel
+from views.main_window import MainWindow
+from utils.image_processor import ImageProcessor
+from utils.metadata_parser import MetadataParser
+
+
+class AppController(QObject):
+ """Главный контроллер приложения"""
+
+ def __init__(self):
+ super().__init__()
+ self.image_model = ImageModel()
+ self.api = HelioviewerAPI()
+ self.main_window = None
+ self.timelapse_controller = None
+
+ # Подключаем сигналы модели
+ self.image_model.layer_added.connect(self.on_layer_added)
+ self.image_model.layer_removed.connect(self.on_layer_removed)
+ self.image_model.layer_visibility_changed.connect(self.on_layer_visibility_changed)
+ self.image_model.layer_opacity_changed.connect(self.on_layer_opacity_changed)
+ self.image_model.layer_selected.connect(self.on_layer_selected)
+
+ # Папка для сохранения по умолчанию
+ self.download_folder = Path.home() / "SolarImages"
+ self.download_folder.mkdir(exist_ok=True)
+
+ def show_main_window(self):
+ """Показывает главное окно"""
+ self.main_window = MainWindow(self)
+ self.main_window.show()
+
+ # Подключаем сигналы от панели управления
+ control_panel = self.main_window.get_control_panel()
+ control_panel.load_image_requested.connect(self.load_image_from_api)
+
+ def load_image_from_api(self, source_id: int, date: datetime):
+ """Загружает изображение из API"""
+ self.main_window.update_status(f"Загрузка изображения: {date.strftime('%Y-%m-%d %H:%M')} UTC")
+
+ # Скачиваем изображение
+ filepath = self.api.download_image(source_id, date, self.download_folder)
+
+ if filepath:
+ # Загружаем данные изображения
+ img_data = ImageProcessor.load_jp2(str(filepath))
+
+ if img_data is not None:
+ # Извлекаем метаданные
+ metadata = MetadataParser.extract_metadata(str(filepath))
+
+ # Получаем информацию о спектре
+ source_info = self.api.SOURCES.get(source_id, {})
+ wavelength = source_info.get("wavelength", "Unknown")
+
+ # Добавляем слой в модель
+ layer_id = self.image_model.add_layer(
+ filepath, source_id, date, wavelength, img_data, metadata
+ )
+
+ self.main_window.update_status(f"✓ Загружено: {filepath.name}")
+ else:
+ self.main_window.update_status("✗ Ошибка обработки изображения")
+ else:
+ self.main_window.update_status("✗ Ошибка загрузки изображения")
+
+ def on_layer_added(self, layer):
+ """Обработчик добавления слоя"""
+ self.main_window.update_layer_list(self.image_model.get_all_layers())
+ self.main_window.canvas.set_image(layer.id, layer.image_data)
+
+ def on_layer_removed(self, layer_id):
+ """Обработчик удаления слоя"""
+ self.main_window.layer_widget.remove_layer(layer_id)
+ self.main_window.canvas.remove_layer(layer_id)
+
+ if not self.image_model.get_all_layers():
+ self.main_window.metadata_viewer.clear()
+
+ def on_layer_visibility_changed(self, layer_id, visible):
+ """Обработчик изменения видимости слоя"""
+ self.main_window.canvas.set_layer_visibility(layer_id, visible)
+
+ def on_layer_opacity_changed(self, layer_id, opacity):
+ """Обработчик изменения прозрачности слоя"""
+ self.main_window.canvas.set_layer_opacity(layer_id, opacity)
+
+ def on_layer_selected(self, layer_id):
+ """Обработчик выбора слоя"""
+ for layer in self.image_model.get_all_layers():
+ if layer.id == layer_id:
+ if layer.metadata:
+ self.main_window.metadata_viewer.display_metadata(layer.metadata)
+ break
+
+ def set_layer_visibility(self, layer_id, visible):
+ self.image_model.set_layer_visibility(layer_id, visible)
+
+ def set_layer_opacity(self, layer_id, opacity):
+ self.image_model.set_layer_opacity(layer_id, opacity)
+
+ def clear_all_layers(self):
+ self.image_model.clear()
+ self.main_window.canvas.clear_all_layers()
+ self.main_window.metadata_viewer.clear()
+
+ # Добавьте эти методы в класс AppController:
+
+ def create_timelapse(self, source_id: int, start_date: datetime,
+ end_date: datetime, output_path: Path,
+ fps: int = 10, output_format: str = "mp4"):
+ """Создает таймлапс"""
+ from controllers.timelapse_controller import TimelapseController
+ self.timelapse_controller = TimelapseController()
+
+ # Подключаем сигналы
+ self.timelapse_controller.progress_updated.connect(self.on_timelapse_progress)
+ self.timelapse_controller.log_message.connect(self.on_timelapse_log)
+ self.timelapse_controller.finished.connect(self.on_timelapse_finished)
+
+ # Запускаем
+ self.timelapse_controller.create_timelapse(
+ source_id, start_date, end_date, output_path, fps, output_format
+ )
+
+ def cancel_timelapse(self):
+ """Отменяет создание таймлапса"""
+ if hasattr(self, 'timelapse_controller'):
+ self.timelapse_controller.cancel()
+
+ def on_timelapse_progress(self, current, total, message):
+ """Прогресс таймлапса"""
+ if self.main_window:
+ self.main_window.update_status(f"Таймлапс: {message}")
+
+ def on_timelapse_log(self, message):
+ """Лог таймлапса"""
+ print(f"[Timelapse] {message}")
+
+ def on_timelapse_finished(self, success, message):
+ """Завершение таймлапса"""
+ if self.main_window:
+ if success:
+ self.main_window.update_status(f"✅ Таймлапс создан")
+ from PySide6.QtWidgets import QMessageBox
+ QMessageBox.information(self.main_window, "Готово", f"Таймлапс сохранен:\n{message}")
+ else:
+ self.main_window.update_status(f"❌ Ошибка: {message}")
+
+ # Добавьте метод:
+ def create_timelapse(self, source_id, start_date, end_date, output_path, fps=10, output_format="mp4"):
+ """Создает таймлапс"""
+ print(f"[DEBUG] AppController.create_timelapse вызван")
+ from controllers.timelapse_controller import TimelapseController
+
+ self.timelapse_controller = TimelapseController()
+
+ # Подключаем сигналы
+ self.timelapse_controller.progress_updated.connect(self.on_timelapse_progress)
+ self.timelapse_controller.log_message.connect(self.on_timelapse_log)
+ self.timelapse_controller.finished.connect(self.on_timelapse_finished)
+
+ # Запускаем
+ self.timelapse_controller.create_timelapse(
+ source_id, start_date, end_date, output_path, fps, output_format
+ )
+ print(f"[DEBUG] TimelapseController создан и запущен")
+
+ def on_timelapse_log(self, message):
+ """Лог таймлапса"""
+ print(f"[TIMELAPSE] {message}")
+ if self.main_window:
+ self.main_window.update_status(f"Таймлапс: {message}")
+
+ def create_timelapse(self, source_id, start_date, end_date, output_path, fps=10, output_format="mp4"):
+ """Создает таймлапс - заглушка, реальный вызов из диалога"""
+ # Реальная реализация в диалоге
+ pass
+
+ def cancel_timelapse(self):
+ pass
\ No newline at end of file
diff --git a/controllers/layer_controller.py b/controllers/layer_controller.py
new file mode 100644
index 0000000..b39e920
--- /dev/null
+++ b/controllers/layer_controller.py
@@ -0,0 +1,384 @@
+"""
+Контроллер для управления слоями изображений
+Отвечает за бизнес-логику работы со слоями: композитинг, обработку, трансформации
+"""
+
+from PySide6.QtCore import QObject, Signal, Qt
+from typing import List, Dict, Optional
+from datetime import datetime # ← ДОБАВИТЬ ЭТУ СТРОКУ
+import numpy as np
+from pathlib import Path
+
+from models.image_model import ImageModel, ImageLayer
+from utils.image_processor import ImageProcessor
+from utils.metadata_parser import MetadataParser
+
+
+class LayerController(QObject):
+ """
+ Контроллер для управления слоями изображений
+ Single Responsibility: только логика работы со слоями
+ """
+
+ # Сигналы для оповещения View
+ layer_loaded = Signal(object) # ImageLayer
+ layer_updated = Signal(int) # layer_id
+ composite_updated = Signal(np.ndarray) # скомпозированное изображение
+ processing_started = Signal(str) # сообщение
+ processing_finished = Signal(bool) # успех/неудача
+
+ def __init__(self, image_model: ImageModel):
+ super().__init__()
+ self.image_model = image_model
+ self._processing_thread = None
+
+ # Подключаемся к сигналам модели
+ self.image_model.layer_added.connect(self.on_layer_added)
+ self.image_model.layer_removed.connect(self.on_layer_removed)
+ self.image_model.layer_visibility_changed.connect(self.on_visibility_changed)
+ self.image_model.layer_opacity_changed.connect(self.on_opacity_changed)
+
+ def load_layer_from_file(self, filepath: Path, source_id: int = None,
+ wavelength: str = None) -> Optional[int]:
+ """
+ Загружает слой из файла
+
+ Args:
+ filepath: Путь к JP2 файлу
+ source_id: ID источника (опционально)
+ wavelength: Длина волны (опционально)
+
+ Returns:
+ ID созданного слоя или None
+ """
+ self.processing_started.emit(f"Загрузка: {filepath.name}")
+
+ try:
+ # Загружаем изображение
+ img_data = ImageProcessor.load_jp2(str(filepath))
+ if img_data is None:
+ self.processing_finished.emit(False)
+ return None
+
+ # Извлекаем метаданные
+ metadata = MetadataParser.extract_metadata(str(filepath))
+
+ # Извлекаем информацию из метаданных если не передана
+ if source_id is None and metadata:
+ # Пытаемся определить инструмент из метаданных
+ instrument = metadata.get('INSTRUME', '')
+ wavelength_val = metadata.get('WAVELNTH', '')
+
+ # Можно добавить логику определения source_id по инструменту
+ source_id = self._guess_source_id(instrument, wavelength_val)
+
+ if wavelength is None and metadata:
+ wavelength = metadata.get('WAVELNTH', 'Unknown')
+
+ # Получаем дату из метаданных или из имени файла
+ date = self._extract_date_from_metadata(metadata)
+ if date is None:
+ date = datetime.now()
+
+ # Добавляем слой в модель
+ layer_id = self.image_model.add_layer(
+ filepath, source_id or 0, date, wavelength or "Unknown",
+ img_data, metadata
+ )
+
+ self.processing_finished.emit(True)
+ return layer_id
+
+ except Exception as e:
+ print(f"Ошибка загрузки слоя: {e}")
+ self.processing_finished.emit(False)
+ return None
+
+ def apply_color_map(self, layer_id: int, color_map: str):
+ """
+ Применяет цветовую карту к слою
+
+ Args:
+ layer_id: ID слоя
+ color_map: Название цветовой карты
+ """
+ layer = self._get_layer_by_id(layer_id)
+ if layer and layer.image_data is not None:
+ # Применяем цветовую карту к черно-белому изображению
+ if len(layer.image_data.shape) == 2 or layer.image_data.shape[2] == 1:
+ colored = ImageProcessor.apply_color_map(
+ layer.image_data if len(layer.image_data.shape) == 2 else layer.image_data[:, :, 0],
+ color_map
+ )
+ layer.image_data = colored
+ self.layer_updated.emit(layer_id)
+ self._update_composite()
+
+ def adjust_brightness_contrast(self, layer_id: int, brightness: float = 0,
+ contrast: float = 1.0):
+ """
+ Регулирует яркость и контраст слоя
+
+ Args:
+ layer_id: ID слоя
+ brightness: Смещение яркости (-127 до 127)
+ contrast: Коэффициент контраста (0.5 до 2.0)
+ """
+ layer = self._get_layer_by_id(layer_id)
+ if layer and layer.image_data is not None:
+ img = layer.image_data.astype(float)
+
+ # Применяем контраст и яркость
+ img = img * contrast + brightness
+
+ # Клиппируем и конвертируем обратно в uint8
+ img = np.clip(img, 0, 255).astype(np.uint8)
+ layer.image_data = img
+
+ self.layer_updated.emit(layer_id)
+ self._update_composite()
+
+ def auto_adjust_levels(self, layer_id: int, low_percent: float = 0.5,
+ high_percent: float = 99.5):
+ """
+ Автоматическая настройка уровней (перцентильная нормализация)
+
+ Args:
+ layer_id: ID слоя
+ low_percent: Нижний процент отсечения
+ high_percent: Верхний процент отсечения
+ """
+ layer = self._get_layer_by_id(layer_id)
+ if layer and layer.image_data is not None:
+ # Для черно-белых изображений
+ if len(layer.image_data.shape) == 2:
+ img = layer.image_data
+ normalized = ImageProcessor.percent_normalize(img, low_percent, high_percent)
+ layer.image_data = normalized
+ else:
+ # Для цветных - обрабатываем каждый канал отдельно
+ for c in range(layer.image_data.shape[2]):
+ channel = layer.image_data[:, :, c]
+ normalized = ImageProcessor.percent_normalize(channel, low_percent, high_percent)
+ layer.image_data[:, :, c] = normalized
+
+ self.layer_updated.emit(layer_id)
+ self._update_composite()
+
+ def merge_visible_layers(self) -> Optional[np.ndarray]:
+ """
+ Объединяет все видимые слои в одно изображение
+
+ Returns:
+ Скомпозированное изображение или None
+ """
+ visible_layers = self.image_model.get_visible_layers()
+
+ if not visible_layers:
+ return None
+
+ # Собираем данные и прозрачности
+ layers_data = []
+ opacities = []
+
+ for layer in visible_layers:
+ if layer.image_data is not None:
+ layers_data.append(layer.image_data)
+ opacities.append(layer.opacity)
+
+ if layers_data:
+ # Приводим все слои к одному размеру (берем минимальный)
+ min_height = min(img.shape[0] for img in layers_data)
+ min_width = min(img.shape[1] for img in layers_data)
+
+ # Обрезаем или ресайзим слои до минимального размера
+ resized_layers = []
+ for img in layers_data:
+ if img.shape[0] != min_height or img.shape[1] != min_width:
+ from PIL import Image
+ pil_img = Image.fromarray(img)
+ pil_img = pil_img.resize((min_width, min_height), Image.Resampling.LANCZOS)
+ resized_layers.append(np.array(pil_img))
+ else:
+ resized_layers.append(img)
+
+ # Композитим слои
+ composite = ImageProcessor.composite_layers(resized_layers, opacities)
+ self.composite_updated.emit(composite)
+ return composite
+
+ return None
+
+ def export_composite(self, filepath: Path, format: str = "PNG"):
+ """
+ Экспортирует скомпозированное изображение в файл
+
+ Args:
+ filepath: Путь для сохранения
+ format: Формат (PNG, JPEG, TIFF)
+ """
+ composite = self.merge_visible_layers()
+ if composite is not None:
+ from PIL import Image
+ pil_img = Image.fromarray(composite)
+ pil_img.save(filepath, format=format)
+ return True
+ return False
+
+ def align_layers(self, reference_layer_id: int = None):
+ """
+ Выравнивает все слои относительно опорного слоя
+ Использует фазовую корреляцию для сдвига изображений
+
+ Args:
+ reference_layer_id: ID опорного слоя (если None - используется первый видимый)
+ """
+ visible_layers = self.image_model.get_visible_layers()
+
+ if len(visible_layers) < 2:
+ return
+
+ # Определяем опорный слой
+ if reference_layer_id:
+ reference = self._get_layer_by_id(reference_layer_id)
+ else:
+ reference = visible_layers[0]
+
+ if not reference or reference.image_data is None:
+ return
+
+ self.processing_started.emit("Выравнивание слоев...")
+
+ # Для каждого слоя вычисляем сдвиг относительно опорного
+ for layer in visible_layers:
+ if layer.id == reference.id or layer.image_data is None:
+ continue
+
+ # Вычисляем сдвиг с помощью фазовой корреляции
+ shift = self._calculate_shift(reference.image_data, layer.image_data)
+
+ if shift != (0, 0):
+ # Сдвигаем изображение
+ layer.image_data = self._shift_image(layer.image_data, shift)
+ self.layer_updated.emit(layer.id)
+
+ self.processing_finished.emit(True)
+ self._update_composite()
+
+ def _calculate_shift(self, ref_img: np.ndarray, target_img: np.ndarray) -> tuple:
+ """
+ Вычисляет сдвиг между двумя изображениями с помощью фазовой корреляции
+
+ Args:
+ ref_img: Опорное изображение
+ target_img: Целевое изображение
+
+ Returns:
+ Сдвиг в пикселях (dy, dx)
+ """
+ # Преобразуем в grayscale если цветные
+ if len(ref_img.shape) == 3:
+ ref_gray = np.mean(ref_img, axis=2)
+ else:
+ ref_gray = ref_img
+
+ if len(target_img.shape) == 3:
+ target_gray = np.mean(target_img, axis=2)
+ else:
+ target_gray = target_img
+
+ # Вычисляем кросс-корреляцию через FFT
+ from scipy import signal
+ correlation = signal.correlate2d(ref_gray, target_gray, mode='same')
+
+ # Находим пик корреляции
+ y, x = np.unravel_index(np.argmax(correlation), correlation.shape)
+
+ # Центр изображения
+ center_y, center_x = np.array(ref_gray.shape) // 2
+
+ # Сдвиг
+ shift_y = center_y - y
+ shift_x = center_x - x
+
+ return (shift_y, shift_x)
+
+ def _shift_image(self, img: np.ndarray, shift: tuple) -> np.ndarray:
+ """
+ Сдвигает изображение на заданное количество пикселей
+
+ Args:
+ img: Исходное изображение
+ shift: Сдвиг (dy, dx)
+
+ Returns:
+ Сдвинутое изображение
+ """
+ from scipy.ndimage import shift as shift_image
+
+ shifted = shift_image(img, shift, mode='constant', cval=0)
+ return shifted.astype(img.dtype)
+
+ def _get_layer_by_id(self, layer_id: int) -> Optional[ImageLayer]:
+ """Возвращает слой по ID"""
+ for layer in self.image_model.get_all_layers():
+ if layer.id == layer_id:
+ return layer
+ return None
+
+ def _guess_source_id(self, instrument: str, wavelength: str) -> int:
+ """Пытается определить source_id по инструменту и длине волны"""
+ from models.api_model import HelioviewerAPI
+
+ instrument = instrument.upper()
+ wavelength = wavelength.upper()
+
+ for source_id, info in HelioviewerAPI.SOURCES.items():
+ if info['instrument'].upper() in instrument:
+ if wavelength and info['wavelength'].upper() in wavelength:
+ return source_id
+
+ return 0
+
+ def _extract_date_from_metadata(self, metadata: Dict) -> Optional[datetime]:
+ """Извлекает дату из метаданных"""
+ date_str = metadata.get('DATE-OBS') or metadata.get('DATE-BEG')
+ if date_str:
+ try:
+ # Пробуем разные форматы дат
+ formats = [
+ "%Y-%m-%dT%H:%M:%S.%f",
+ "%Y-%m-%dT%H:%M:%S",
+ "%Y-%m-%d %H:%M:%S",
+ "%Y-%m-%d"
+ ]
+ for fmt in formats:
+ try:
+ return datetime.strptime(date_str, fmt)
+ except ValueError:
+ continue
+ except:
+ pass
+ return None
+
+ def _update_composite(self):
+ """Обновляет композитное изображение"""
+ composite = self.merge_visible_layers()
+ if composite is not None:
+ self.composite_updated.emit(composite)
+
+ def on_layer_added(self, layer: ImageLayer):
+ """Обработчик добавления слоя"""
+ self._update_composite()
+
+ def on_layer_removed(self, layer_id: int):
+ """Обработчик удаления слоя"""
+ self._update_composite()
+
+ def on_visibility_changed(self, layer_id: int, visible: bool):
+ """Обработчик изменения видимости"""
+ self._update_composite()
+
+ def on_opacity_changed(self, layer_id: int, opacity: float):
+ """Обработчик изменения прозрачности"""
+ self._update_composite()
\ No newline at end of file
diff --git a/controllers/timelapse_controller.py b/controllers/timelapse_controller.py
new file mode 100644
index 0000000..3751a2c
--- /dev/null
+++ b/controllers/timelapse_controller.py
@@ -0,0 +1,117 @@
+from PySide6.QtCore import QObject, QThread, Signal
+from datetime import datetime, timedelta
+from pathlib import Path
+import shutil
+import tempfile
+from models.api_model import HelioviewerAPI
+from utils.video_creator import VideoCreator
+
+
+class TimelapseWorker(QThread):
+ log = Signal(str)
+ progress = Signal(int, int)
+ finished = Signal(bool, str)
+
+ def __init__(self, source_id, start_date, end_date, output_path, fps=10):
+ super().__init__()
+ self.source_id = source_id
+ self.start_date = start_date
+ self.end_date = end_date
+ self.output_path = output_path
+ self.fps = fps
+ self.cancelled = False
+ self.temp_dir = None
+
+ def cancel(self):
+ self.cancelled = True
+ self.log.emit("⏹️ Отмена процесса...")
+
+ def run(self):
+ try:
+ # Создаем временную папку
+ self.temp_dir = Path(tempfile.mkdtemp(prefix="timelapse_"))
+ self.log.emit(f"📁 Временная папка: {self.temp_dir}")
+
+ # Генерируем даты
+ dates = []
+ current = self.start_date.replace(hour=12, minute=0, second=0)
+ while current <= self.end_date:
+ dates.append(current)
+ current += timedelta(days=1)
+
+ total = len(dates)
+ self.log.emit(f"📊 Всего файлов: {total}")
+
+ downloaded = []
+
+ # Скачиваем
+ for i, date in enumerate(dates):
+ if self.cancelled:
+ self.cleanup()
+ self.finished.emit(False, "Отменено")
+ return
+
+ percent = int((i + 1) / total * 100)
+ self.progress.emit(i + 1, total)
+ self.log.emit(f"📥 [{i + 1}/{total}] {percent}% - {date.strftime('%Y-%m-%d')}")
+
+ filepath = HelioviewerAPI.download_image(self.source_id, date, self.temp_dir)
+ if filepath:
+ downloaded.append(filepath)
+ self.log.emit(f"✅ [{i + 1}/{total}] Успешно")
+ else:
+ self.log.emit(f"❌ [{i + 1}/{total}] Ошибка")
+
+ if self.cancelled:
+ self.cleanup()
+ self.finished.emit(False, "Отменено")
+ return
+
+ if not downloaded:
+ self.cleanup()
+ self.finished.emit(False, "Нет файлов")
+ return
+
+ # Создаем видео
+ self.log.emit("🎬 Создание видео...")
+ self.progress.emit(total, total)
+
+ video_path = VideoCreator.create_timelapse(downloaded, self.output_path, self.fps)
+
+ # Очистка
+ self.cleanup()
+
+ if video_path and video_path.exists():
+ self.finished.emit(True, str(video_path))
+ else:
+ self.finished.emit(False, "Ошибка создания видео")
+
+ except Exception as e:
+ self.cleanup()
+ self.finished.emit(False, str(e))
+
+ def cleanup(self):
+ if self.temp_dir and self.temp_dir.exists():
+ shutil.rmtree(self.temp_dir)
+ self.log.emit("🗑️ Временные файлы удалены")
+
+
+class TimelapseController(QObject):
+ def __init__(self):
+ super().__init__()
+ self.worker = None
+ self.log = Signal(str)
+ self.progress = Signal(int, int)
+ self.finished = Signal(bool, str)
+
+ def create(self, source_id, start_date, end_date, output_path, fps=10):
+ self.worker = TimelapseWorker(source_id, start_date, end_date, output_path, fps)
+ self.worker.log.connect(self.log)
+ self.worker.progress.connect(self.progress)
+ self.worker.finished.connect(self.finished)
+ self.worker.start()
+ return self.worker
+
+ def cancel(self):
+ if self.worker:
+ self.worker.cancel()
\ No newline at end of file
diff --git a/fix_imports.py b/fix_imports.py
new file mode 100644
index 0000000..7089352
--- /dev/null
+++ b/fix_imports.py
@@ -0,0 +1,64 @@
+import os
+import re
+
+
+def fix_pyside6_syntax(filepath):
+ """Исправляет устаревший синтаксис PySide6"""
+ with open(filepath, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ # Замены
+ replacements = [
+ (r'Qt\.Horizontal', 'Qt.Orientation.Horizontal'),
+ (r'Qt\.Vertical', 'Qt.Orientation.Vertical'),
+ (r'Qt\.LeftButton', 'Qt.MouseButton.LeftButton'),
+ (r'Qt\.RightButton', 'Qt.MouseButton.RightButton'),
+ (r'Qt\.MiddleButton', 'Qt.MouseButton.MiddleButton'),
+ (r'self\.RenderHint\.', 'QPainter.RenderHint.'),
+ (r'Qt\.KeepAspectRatio', 'Qt.AspectRatioMode.KeepAspectRatio'),
+ (r'Qt\.IgnoreAspectRatio', 'Qt.AspectRatioMode.IgnoreAspectRatio'),
+ (r'Qt\.ScrollBarAsNeeded', 'Qt.ScrollBarPolicy.ScrollBarAsNeeded'),
+ (r'Qt\.ScrollBarAlwaysOff', 'Qt.ScrollBarPolicy.ScrollBarAlwaysOff'),
+ (r'Qt\.ScrollBarAlwaysOn', 'Qt.ScrollBarPolicy.ScrollBarAlwaysOn'),
+ (r'Qt\.black', 'Qt.GlobalColor.black'),
+ (r'Qt\.white', 'Qt.GlobalColor.white'),
+ (r'Qt\.red', 'Qt.GlobalColor.red'),
+ (r'Qt\.green', 'Qt.GlobalColor.green'),
+ (r'Qt\.blue', 'Qt.GlobalColor.blue'),
+ (r'Qt\.yellow', 'Qt.GlobalColor.yellow'),
+ (r'Qt\.gray', 'Qt.GlobalColor.gray'),
+ (r'Qt\.darkGray', 'Qt.GlobalColor.darkGray'),
+ (r'Qt\.lightGray', 'Qt.GlobalColor.lightGray'),
+ (r'Qt\.transparent', 'Qt.GlobalColor.transparent'),
+ ]
+
+ for old, new in replacements:
+ content = re.sub(old, new, content)
+
+ # Добавляем импорт QPainter если нужно
+ if 'QPainter' not in content and any('RenderHint' in content for _ in []):
+ if 'from PySide6.QtGui import' in content:
+ content = content.replace(
+ 'from PySide6.QtGui import',
+ 'from PySide6.QtGui import QPainter, '
+ )
+ else:
+ content = 'from PySide6.QtGui import QPainter\n' + content
+
+ with open(filepath, 'w', encoding='utf-8') as f:
+ f.write(content)
+
+ print(f"Fixed: {filepath}")
+
+
+# Проходим по всем файлам
+for root, dirs, files in os.walk('.'):
+ for file in files:
+ if file.endswith('.py'):
+ filepath = os.path.join(root, file)
+ try:
+ fix_pyside6_syntax(filepath)
+ except Exception as e:
+ print(f"Error fixing {filepath}: {e}")
+
+print("Done!")
\ No newline at end of file
diff --git a/info/nuitka compile.txt b/info/nuitka compile.txt
new file mode 100644
index 0000000..a337f04
--- /dev/null
+++ b/info/nuitka compile.txt
@@ -0,0 +1,44 @@
+pip install nuitka
+python -m nuitka --standalone --onefile --enable-plugin=pyside6 --windows-console-mode=disable --windows-icon-from-ico=sun.ico main.py
+
+ Что дают метаданные FITS?
+Метаданные FITS (Flexible Image Transport System) — это стандарт в астрономии. Они дают вам:
+
+Точную научную информацию:
+
+Точное время съемки с микросекундной точностью
+
+Длину волны и спектральный диапазон
+
+Температуру плазмы, которую вы наблюдаете
+
+Астрометрические данные:
+
+Координаты центра Солнца на снимке
+
+Масштаб (сколько угловых секунд в пикселе)
+
+Угол поворота изображения
+
+Контроль качества:
+
+Информацию о том, был ли снимок испорчен помехами
+
+Уровень обработки данных
+
+Практическое использование:
+
+Вы можете измерить реальные размеры солнечных пятен в километрах
+
+Точно определить, в какой момент произошла вспышка
+
+Сравнивать снимки с разных инструментов, приводя их к одной системе координат
+
+ SOLID архитектура с четким разделением на Model-View-Controller
+✅ Профессиональный UI на PySide6 с темной темой
+✅ Многослойность с чекбоксами видимости и прозрачностью
+✅ Просмотрщик метаданных FITS
+✅ Таймлапс с прогрессом в фоне и выбором FPS
+✅ Умный Canvas с зумом и панорамированием
+✅ Выбор папки сохранения
+✅ Неблокирующие операции (потоки для таймлапса)
\ No newline at end of file
diff --git a/main.exe b/main.exe
new file mode 100644
index 0000000..2ff3134
Binary files /dev/null and b/main.exe differ
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..e81ba14
--- /dev/null
+++ b/main.py
@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+"""
+Helioviewer Solar Viewer - Профессиональное приложение для просмотра снимков Солнца
+"""
+
+import sys
+import os
+from pathlib import Path
+
+# Добавляем путь к модулям
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from PySide6.QtWidgets import QApplication
+from PySide6.QtCore import Qt
+from controllers.app_controller import AppController
+
+
+def main():
+ """Точка входа в приложение"""
+ # Включаем High DPI поддержку
+ QApplication.setHighDpiScaleFactorRoundingPolicy(
+ Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
+ )
+
+ app = QApplication(sys.argv)
+ app.setApplicationName("Helioviewer Solar Viewer")
+ app.setOrganizationName("SolarViewer")
+
+ # Устанавливаем темную тему через QSS
+ app.setStyle("Fusion")
+
+ # Создаем контроллер (он создаст модель и представление)
+ controller = AppController()
+
+ # Показываем главное окно
+ controller.show_main_window()
+
+ sys.exit(app.exec())
+
+
+# ИСПРАВЛЕНО: было if __name__ "__main__": , правильно:
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/models/__init__.py b/models/__init__.py
new file mode 100644
index 0000000..c5784bf
--- /dev/null
+++ b/models/__init__.py
@@ -0,0 +1,13 @@
+# Модели для Helioviewer приложения
+from models.api_model import HelioviewerAPI
+from models.image_model import ImageModel, ImageLayer
+from models.timelapse_model import TimelapseModel, TimelapseConfig, TimelapseStatus
+
+__all__ = [
+ 'HelioviewerAPI',
+ 'ImageModel',
+ 'ImageLayer',
+ 'TimelapseModel',
+ 'TimelapseConfig',
+ 'TimelapseStatus'
+]
\ No newline at end of file
diff --git a/models/__pycache__/__init__.cpython-312.pyc b/models/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..63e5fcc
Binary files /dev/null and b/models/__pycache__/__init__.cpython-312.pyc differ
diff --git a/models/__pycache__/api_model.cpython-312.pyc b/models/__pycache__/api_model.cpython-312.pyc
new file mode 100644
index 0000000..dd4bc84
Binary files /dev/null and b/models/__pycache__/api_model.cpython-312.pyc differ
diff --git a/models/__pycache__/image_model.cpython-312.pyc b/models/__pycache__/image_model.cpython-312.pyc
new file mode 100644
index 0000000..5cf8ea7
Binary files /dev/null and b/models/__pycache__/image_model.cpython-312.pyc differ
diff --git a/models/__pycache__/timelapse_model.cpython-312.pyc b/models/__pycache__/timelapse_model.cpython-312.pyc
new file mode 100644
index 0000000..a121dad
Binary files /dev/null and b/models/__pycache__/timelapse_model.cpython-312.pyc differ
diff --git a/models/api_model.py b/models/api_model.py
new file mode 100644
index 0000000..6300442
--- /dev/null
+++ b/models/api_model.py
@@ -0,0 +1,122 @@
+"""
+Модель для работы с Helioviewer API
+Single Responsibility: только загрузка данных из API
+"""
+
+import requests
+from datetime import datetime
+from typing import Dict, List, Optional, Tuple
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass
+class SolarImage:
+ """DTO для солнечного снимка"""
+ source_id: int
+ date: datetime
+ wavelength: str
+ observatory: str
+ instrument: str
+ filepath: Optional[Path] = None
+ metadata: Optional[Dict] = None
+
+
+class HelioviewerAPI:
+ """Клиент для работы с Helioviewer API"""
+
+ BASE_URL = "https://api.helioviewer.org/v1/"
+
+ # Предустановленные источники (можно расширять)
+ SOURCES = {
+ 14: {"name": "AIA 335", "wavelength": "335 Å", "observatory": "SDO", "instrument": "AIA", "color": "#FFD700"},
+ 13: {"name": "AIA 304", "wavelength": "304 Å", "observatory": "SDO", "instrument": "AIA", "color": "#FF4500"},
+ 12: {"name": "AIA 211", "wavelength": "211 Å", "observatory": "SDO", "instrument": "AIA", "color": "#00FF00"},
+ 11: {"name": "AIA 193", "wavelength": "193 Å", "observatory": "SDO", "instrument": "AIA", "color": "#00BFFF"},
+ 10: {"name": "AIA 171", "wavelength": "171 Å", "observatory": "SDO", "instrument": "AIA", "color": "#87CEEB"},
+ 9: {"name": "AIA 131", "wavelength": "131 Å", "observatory": "SDO", "instrument": "AIA", "color": "#FF1493"},
+ 8: {"name": "AIA 94", "wavelength": "94 Å", "observatory": "SDO", "instrument": "AIA", "color": "#9400D3"},
+ 4: {"name": "LASCO C2", "wavelength": "White Light", "observatory": "SOHO", "instrument": "LASCO",
+ "color": "#FFFFFF"},
+ 5: {"name": "LASCO C3", "wavelength": "White Light", "observatory": "SOHO", "instrument": "LASCO",
+ "color": "#FFFFFF"},
+ }
+
+ @classmethod
+ def get_available_sources(cls) -> Dict[int, Dict]:
+ """Возвращает список доступных источников"""
+ return cls.SOURCES
+
+ @classmethod
+ def download_image(cls, source_id: int, date: datetime, save_path: Path) -> Optional[Path]:
+ """
+ Скачивает изображение с API Helioviewer
+
+ Args:
+ source_id: ID источника
+ date: Дата и время снимка
+ save_path: Путь для сохранения
+
+ Returns:
+ Path к сохраненному файлу или None при ошибке
+ """
+ formatted_date = date.strftime("%Y-%m-%dT%H:%M:%SZ")
+ url = f"{cls.BASE_URL}getJP2Image/"
+ params = {'sourceId': source_id, 'date': formatted_date}
+
+ try:
+ response = requests.get(url, params=params, timeout=30)
+ response.raise_for_status()
+
+ # Создаем имя файла
+ source_info = cls.SOURCES.get(source_id, {})
+ filename = f"solar_{source_id}_{date.strftime('%Y%m%d_%H%M%S')}.jp2"
+ filepath = save_path / filename
+
+ # Сохраняем
+ with open(filepath, 'wb') as f:
+ f.write(response.content)
+
+ return filepath
+
+ except Exception as e:
+ print(f"Ошибка скачивания: {e}")
+ return None
+
+ @classmethod
+ def download_timelapse_images(cls, source_id: int, start_date: datetime,
+ end_date: datetime, save_path: Path,
+ progress_callback=None) -> List[Path]:
+ """
+ Скачивает серию изображений для таймлапса
+
+ Args:
+ source_id: ID источника
+ start_date: Начальная дата
+ end_date: Конечная дата
+ save_path: Папка для сохранения
+ progress_callback: Функция для обновления прогресса
+
+ Returns:
+ Список путей к скачанным файлам
+ """
+ downloaded_files = []
+
+ # Генерируем даты (каждый день в 12:00 UTC)
+ current_date = start_date.replace(hour=12, minute=0, second=0)
+ delta = end_date - start_date
+ total_days = delta.days + 1
+
+ for i in range(total_days):
+ if progress_callback:
+ progress_callback(i + 1, total_days, current_date)
+
+ filepath = cls.download_image(source_id, current_date, save_path)
+ if filepath:
+ downloaded_files.append(filepath)
+
+ # Переходим к следующему дню
+ from datetime import timedelta
+ current_date += timedelta(days=1)
+
+ return downloaded_files
\ No newline at end of file
diff --git a/models/image_model.py b/models/image_model.py
new file mode 100644
index 0000000..350d91b
--- /dev/null
+++ b/models/image_model.py
@@ -0,0 +1,111 @@
+"""
+Модель для управления слоями изображений
+"""
+
+from PySide6.QtCore import QObject, Signal
+from typing import List, Optional, Dict
+from pathlib import Path
+from dataclasses import dataclass
+from datetime import datetime
+
+
+@dataclass
+class ImageLayer:
+ """Модель слоя изображения"""
+ id: int
+ name: str
+ filepath: Path
+ source_id: int
+ date: datetime
+ wavelength: str
+ visible: bool = True
+ opacity: float = 1.0
+ image_data: Optional[any] = None
+ metadata: Optional[Dict] = None
+
+
+class ImageModel(QObject):
+ """Модель для хранения и управления слоями изображений"""
+
+ # Сигналы для оповещения View
+ layer_added = Signal(object) # ImageLayer
+ layer_removed = Signal(int) # layer_id
+ layer_visibility_changed = Signal(int, bool) # layer_id, visible
+ layer_opacity_changed = Signal(int, float) # layer_id, opacity
+ layer_selected = Signal(int) # layer_id
+
+ def __init__(self):
+ super().__init__()
+ self._layers: List[ImageLayer] = []
+ self._next_id = 1
+ self._selected_layer_id: Optional[int] = None
+
+ def add_layer(self, filepath: Path, source_id: int, date: datetime,
+ wavelength: str, image_data: any, metadata: Dict = None) -> int:
+ """Добавляет новый слой"""
+ layer = ImageLayer(
+ id=self._next_id,
+ name=f"{wavelength} - {date.strftime('%Y-%m-%d %H:%M')}",
+ filepath=filepath,
+ source_id=source_id,
+ date=date,
+ wavelength=wavelength,
+ image_data=image_data,
+ metadata=metadata
+ )
+ self._layers.append(layer)
+ self._next_id += 1
+ self.layer_added.emit(layer)
+ return layer.id
+
+ def remove_layer(self, layer_id: int):
+ """Удаляет слой"""
+ self._layers = [l for l in self._layers if l.id != layer_id]
+ self.layer_removed.emit(layer_id)
+
+ if self._selected_layer_id == layer_id:
+ self._selected_layer_id = None
+
+ def set_layer_visibility(self, layer_id: int, visible: bool):
+ """Изменяет видимость слоя"""
+ for layer in self._layers:
+ if layer.id == layer_id:
+ layer.visible = visible
+ self.layer_visibility_changed.emit(layer_id, visible)
+ break
+
+ def set_layer_opacity(self, layer_id: int, opacity: float):
+ """Изменяет прозрачность слоя"""
+ for layer in self._layers:
+ if layer.id == layer_id:
+ layer.opacity = opacity
+ self.layer_opacity_changed.emit(layer_id, opacity) # ← ЭМИИМ СИГНАЛ
+ break
+
+ def select_layer(self, layer_id: int):
+ """Выбирает слой"""
+ self._selected_layer_id = layer_id
+ self.layer_selected.emit(layer_id)
+
+ def get_selected_layer(self) -> Optional[ImageLayer]:
+ """Возвращает выбранный слой"""
+ if self._selected_layer_id:
+ for layer in self._layers:
+ if layer.id == self._selected_layer_id:
+ return layer
+ return None
+
+ def get_visible_layers(self) -> List[ImageLayer]:
+ """Возвращает все видимые слои"""
+ return [layer for layer in self._layers if layer.visible]
+
+ def get_all_layers(self) -> List[ImageLayer]:
+ """Возвращает все слои"""
+ return self._layers.copy()
+
+ def clear(self):
+ """Очищает все слои"""
+ layer_ids = [layer.id for layer in self._layers]
+ self._layers.clear()
+ for layer_id in layer_ids:
+ self.layer_removed.emit(layer_id)
\ No newline at end of file
diff --git a/models/timelapse_model.py b/models/timelapse_model.py
new file mode 100644
index 0000000..b350cd0
--- /dev/null
+++ b/models/timelapse_model.py
@@ -0,0 +1,227 @@
+"""
+Модель для управления процессом создания таймлапса
+Single Responsibility: только данные и состояние процесса таймлапса
+"""
+
+from PySide6.QtCore import QObject, Signal, QThread
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import List, Optional, Callable
+from dataclasses import dataclass, field
+from enum import Enum
+
+
+class TimelapseStatus(Enum):
+ """Статус процесса создания таймлапса"""
+ IDLE = "idle"
+ PREPARING = "preparing"
+ DOWNLOADING = "downloading"
+ PROCESSING = "processing"
+ CREATING_VIDEO = "creating_video"
+ COMPLETED = "completed"
+ FAILED = "failed"
+ CANCELLED = "cancelled"
+
+
+@dataclass
+class TimelapseConfig:
+ """Конфигурация таймлапса"""
+ source_id: int
+ start_date: datetime
+ end_date: datetime
+ output_path: Path
+ fps: int = 10
+ quality: int = 90 # качество видео (0-100)
+ include_metadata: bool = True
+ output_format: str = "mp4" # mp4, gif, webm
+
+
+@dataclass
+class TimelapseProgress:
+ """Прогресс создания таймлапса"""
+ status: TimelapseStatus
+ current: int = 0
+ total: int = 0
+ message: str = ""
+ current_date: Optional[datetime] = None
+ downloaded_files: List[Path] = field(default_factory=list)
+
+
+class TimelapseModel(QObject):
+ """
+ Модель для управления созданием таймлапса
+ Хранит состояние и предоставляет интерфейс для контроллера
+ """
+
+ # Сигналы для оповещения о изменениях
+ progress_updated = Signal(TimelapseProgress)
+ status_changed = Signal(TimelapseStatus)
+ log_message = Signal(str)
+
+ def __init__(self):
+ super().__init__()
+ self._current_progress = TimelapseProgress(status=TimelapseStatus.IDLE)
+ self._config: Optional[TimelapseConfig] = None
+ self._is_cancelled = False
+
+ def configure(self, config: TimelapseConfig) -> bool:
+ """
+ Настраивает таймлапс
+
+ Args:
+ config: Конфигурация таймлапса
+
+ Returns:
+ True если конфигурация валидна
+ """
+ # Валидация параметров
+ if config.start_date >= config.end_date:
+ self.log_message.emit("Ошибка: Дата начала должна быть раньше даты окончания")
+ return False
+
+ if config.fps < 1 or config.fps > 60:
+ self.log_message.emit("Ошибка: FPS должен быть в диапазоне 1-60")
+ return False
+
+ if not config.output_path.parent.exists():
+ self.log_message.emit(f"Ошибка: Папка {config.output_path.parent} не существует")
+ return False
+
+ self._config = config
+ self._is_cancelled = False
+
+ # Рассчитываем общее количество кадров
+ delta = config.end_date - config.start_date
+ total_frames = delta.days + 1
+ self._current_progress = TimelapseProgress(
+ status=TimelapseStatus.PREPARING,
+ total=total_frames,
+ message=f"Подготовка к созданию таймлапса из {total_frames} кадров"
+ )
+
+ self.log_message.emit(f"Настроен таймлапс: {total_frames} кадров, {config.fps} FPS")
+ return True
+
+ def get_total_frames(self) -> int:
+ """Возвращает общее количество кадров"""
+ if self._config:
+ delta = self._config.end_date - self._config.start_date
+ return delta.days + 1
+ return 0
+
+ def update_progress(self, current_frame: int, total_frames: int,
+ message: str = "", current_date: datetime = None):
+ """
+ Обновляет прогресс создания таймлапса
+
+ Args:
+ current_frame: Текущий кадр
+ total_frames: Всего кадров
+ message: Сообщение о прогрессе
+ current_date: Текущая обрабатываемая дата
+ """
+ self._current_progress.current = current_frame
+ self._current_progress.total = total_frames
+ self._current_progress.message = message or self._current_progress.message
+ self._current_progress.current_date = current_date or self._current_progress.current_date
+
+ # Вычисляем процент
+ if total_frames > 0:
+ percent = (current_frame / total_frames) * 100
+ status_text = f"{percent:.1f}%"
+
+ self.progress_updated.emit(self._current_progress)
+
+ def set_status(self, status: TimelapseStatus, message: str = ""):
+ """
+ Устанавливает статус процесса
+
+ Args:
+ status: Новый статус
+ message: Сообщение (опционально)
+ """
+ self._current_progress.status = status
+ if message:
+ self._current_progress.message = message
+ self.status_changed.emit(status)
+ self.progress_updated.emit(self._current_progress)
+ self.log_message.emit(message or f"Статус: {status.value}")
+
+ def add_downloaded_file(self, filepath: Path):
+ """Добавляет скачанный файл в список"""
+ self._current_progress.downloaded_files.append(filepath)
+
+ def get_downloaded_files(self) -> List[Path]:
+ """Возвращает список скачанных файлов"""
+ return self._current_progress.downloaded_files.copy()
+
+ def cancel(self):
+ """Отменяет создание таймлапса"""
+ self._is_cancelled = True
+ self.set_status(TimelapseStatus.CANCELLED, "Создание таймлапса отменено пользователем")
+
+ def is_cancelled(self) -> bool:
+ """Проверяет, отменен ли процесс"""
+ return self._is_cancelled
+
+ def get_config(self) -> Optional[TimelapseConfig]:
+ """Возвращает конфигурацию таймлапса"""
+ return self._config
+
+ def reset(self):
+ """Сбрасывает состояние модели"""
+ self._config = None
+ self._is_cancelled = False
+ self._current_progress = TimelapseProgress(status=TimelapseStatus.IDLE)
+ self.progress_updated.emit(self._current_progress)
+
+ def generate_date_sequence(self) -> List[datetime]:
+ """
+ Генерирует последовательность дат для таймлапса
+
+ Returns:
+ Список дат для каждого кадра
+ """
+ if not self._config:
+ return []
+
+ dates = []
+ current = self._config.start_date.replace(hour=12, minute=0, second=0)
+
+ while current <= self._config.end_date:
+ dates.append(current)
+ current += timedelta(days=1)
+
+ return dates
+
+ def get_estimated_size(self) -> int:
+ """
+ Оценивает примерный размер видео в байтах
+
+ Returns:
+ Оценочный размер или 0 если невозможно оценить
+ """
+ if not self._config:
+ return 0
+
+ # Приблизительная оценка: ~500KB на кадр для HD видео
+ total_frames = self.get_total_frames()
+ estimated_bytes = total_frames * 500 * 1024
+
+ # Корректируем в зависимости от FPS и качества
+ estimated_bytes = estimated_bytes * (self._config.fps / 30) * (self._config.quality / 100)
+
+ return int(estimated_bytes)
+
+ def get_formatted_estimated_size(self) -> str:
+ """Возвращает отформатированную оценку размера"""
+ size_bytes = self.get_estimated_size()
+
+ if size_bytes < 1024:
+ return f"{size_bytes} B"
+ elif size_bytes < 1024 * 1024:
+ return f"{size_bytes / 1024:.1f} KB"
+ elif size_bytes < 1024 * 1024 * 1024:
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
+ else:
+ return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
\ No newline at end of file
diff --git a/utils/__init__.py b/utils/__init__.py
new file mode 100644
index 0000000..fc8dd8c
--- /dev/null
+++ b/utils/__init__.py
@@ -0,0 +1,10 @@
+# Утилиты для Helioviewer приложения
+from utils.image_processor import ImageProcessor
+from utils.metadata_parser import MetadataParser
+from utils.video_creator import VideoCreator
+
+__all__ = [
+ 'ImageProcessor',
+ 'MetadataParser',
+ 'VideoCreator'
+]
\ No newline at end of file
diff --git a/utils/__pycache__/__init__.cpython-312.pyc b/utils/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..fa7c39e
Binary files /dev/null and b/utils/__pycache__/__init__.cpython-312.pyc differ
diff --git a/utils/__pycache__/image_processor.cpython-312.pyc b/utils/__pycache__/image_processor.cpython-312.pyc
new file mode 100644
index 0000000..4299cb1
Binary files /dev/null and b/utils/__pycache__/image_processor.cpython-312.pyc differ
diff --git a/utils/__pycache__/metadata_parser.cpython-312.pyc b/utils/__pycache__/metadata_parser.cpython-312.pyc
new file mode 100644
index 0000000..20f43a3
Binary files /dev/null and b/utils/__pycache__/metadata_parser.cpython-312.pyc differ
diff --git a/utils/__pycache__/video_creator.cpython-312.pyc b/utils/__pycache__/video_creator.cpython-312.pyc
new file mode 100644
index 0000000..cc1f960
Binary files /dev/null and b/utils/__pycache__/video_creator.cpython-312.pyc differ
diff --git a/utils/image_processor.py b/utils/image_processor.py
new file mode 100644
index 0000000..cf8175a
--- /dev/null
+++ b/utils/image_processor.py
@@ -0,0 +1,267 @@
+"""
+Утилиты для обработки изображений: нормализация, преобразование цветов и т.д.
+"""
+
+import numpy as np
+from typing import Optional
+from pathlib import Path
+
+# Пытаемся импортировать доступные библиотеки для JP2
+JP2_AVAILABLE = False
+JP2_METHOD = None
+JP2_ERROR = None
+
+# Приоритет: imagecodecs > PIL > glymur
+try:
+ import imagecodecs
+ import tifffile
+ JP2_AVAILABLE = True
+ JP2_METHOD = "imagecodecs"
+ print("✓ Используется imagecodecs для JP2 (рекомендуемый метод)")
+except ImportError as e:
+ JP2_ERROR = str(e)
+ try:
+ from PIL import Image
+ JP2_AVAILABLE = True
+ JP2_METHOD = "pil"
+ print("✓ Используется PIL для JP2 (ограниченная поддержка)")
+ except ImportError:
+ try:
+ import glymur
+ from glymur import Jp2k
+ JP2_AVAILABLE = True
+ JP2_METHOD = "glymur"
+ print("✓ Используется Glymur для JP2")
+ except ImportError:
+ print("✗ Нет доступных библиотек для JP2")
+ print(" Установите: pip install imagecodecs")
+
+
+class ImageProcessor:
+ """Обработчик изображений для солнечных снимков"""
+
+ @staticmethod
+ def load_jp2(filepath: str) -> Optional[np.ndarray]:
+ """
+ Загружает JP2 файл и возвращает numpy array
+
+ Args:
+ filepath: Путь к JP2 файлу
+
+ Returns:
+ numpy array с изображением (нормализованный 8-бит)
+ """
+ if not JP2_AVAILABLE:
+ print(f"Ошибка: Нет доступных библиотек для JP2. Файл: {filepath}")
+ print(f" Причина: {JP2_ERROR}")
+ return None
+
+ try:
+ img_data = None
+
+ if JP2_METHOD == "imagecodecs":
+ # Метод через imagecodecs (работает без внешнего OpenJPEG)
+ import imagecodecs
+ with open(filepath, 'rb') as f:
+ data = f.read()
+ img_data = imagecodecs.jpeg2k_decode(data)
+
+ elif JP2_METHOD == "pil":
+ # Метод через PIL (базовая поддержка)
+ from PIL import Image
+ with Image.open(filepath) as img:
+ img_data = np.array(img)
+
+ elif JP2_METHOD == "glymur":
+ # Метод через Glymur
+ from glymur import Jp2k
+ jp2 = Jp2k(filepath)
+ img_data = jp2[:]
+
+ if img_data is None:
+ print(f"Не удалось загрузить: {filepath}")
+ return None
+
+ # Нормализуем 16-битные данные в 8-бит
+ if img_data.dtype == np.uint16:
+ img_data = ImageProcessor.percent_normalize(img_data)
+ elif img_data.dtype == np.uint8:
+ pass # уже 8-бит
+ else:
+ # Конвертируем в uint8
+ if img_data.max() > 0:
+ img_data = ((img_data - img_data.min()) / (img_data.max() - img_data.min()) * 255).astype(np.uint8)
+ else:
+ img_data = np.zeros_like(img_data, dtype=np.uint8)
+
+ # Если изображение черно-белое, дублируем в RGB
+ if len(img_data.shape) == 2:
+ img_data = np.stack([img_data] * 3, axis=2)
+ elif len(img_data.shape) == 3 and img_data.shape[2] == 1:
+ img_data = np.concatenate([img_data] * 3, axis=2)
+
+ return img_data
+
+ except Exception as e:
+ print(f"Ошибка загрузки JP2 ({JP2_METHOD}): {e}")
+ return None
+
+ @staticmethod
+ def percent_normalize(img_16bit: np.ndarray, low_percent: float = 0.5, high_percent: float = 99.5) -> np.ndarray:
+ """
+ Процентная нормализация для астрономических изображений
+
+ Args:
+ img_16bit: 16-битное изображение
+ low_percent: Нижний процент отсечения
+ high_percent: Верхний процент отсечения
+
+ Returns:
+ 8-битное нормализованное изображение
+ """
+ # Вычисляем перцентили
+ low_val = np.percentile(img_16bit, low_percent)
+ high_val = np.percentile(img_16bit, high_percent)
+
+ # Защита от одинаковых значений
+ if high_val <= low_val:
+ high_val = low_val + 1
+
+ # Клиппируем и нормализуем
+ img_clipped = np.clip(img_16bit, low_val, high_val)
+ img_normalized = ((img_clipped - low_val) / (high_val - low_val) * 255).astype(np.uint8)
+
+ return img_normalized
+
+ @staticmethod
+ def apply_color_map(img_8bit: np.ndarray, color_map: str = "default") -> np.ndarray:
+ """
+ Применяет цветовую карту к черно-белому изображению
+
+ Args:
+ img_8bit: 8-битное черно-белое изображение
+ color_map: Название цветовой карты
+
+ Returns:
+ RGB изображение
+ """
+ # Встроенные цветовые карты для разных спектров
+ color_maps = {
+ "AIA 335": "hot", # золотистый
+ "AIA 304": "magma", # красный
+ "AIA 193": "plasma", # пурпурный
+ "AIA 171": "viridis", # зеленый
+ "AIA 211": "inferno", # оранжевый
+ "AIA 131": "cool", # синий
+ "LASCO C2": "gray", # серый
+ "default": "gray"
+ }
+
+ # Выбираем цветовую карту
+ cmap_name = color_maps.get(color_map, color_maps["default"])
+
+ # Если нет matplotlib, используем простую цветовую карту
+ try:
+ import matplotlib.pyplot as plt
+ cmap = plt.get_cmap(cmap_name)
+ colored = (cmap(img_8bit / 255.0)[:, :, :3] * 255).astype(np.uint8)
+ return colored
+ except ImportError:
+ # Упрощенная цветовая карта (красный-зеленый-синий)
+ if cmap_name == "hot":
+ r = img_8bit
+ g = np.clip((img_8bit.astype(int) - 85) * 3, 0, 255).astype(np.uint8)
+ b = np.clip((img_8bit.astype(int) - 170) * 3, 0, 255).astype(np.uint8)
+ return np.stack([r, g, b], axis=2)
+ elif cmap_name == "viridis":
+ r = np.clip(img_8bit * 0.2, 0, 255).astype(np.uint8)
+ g = np.clip(img_8bit * 0.6, 0, 255).astype(np.uint8)
+ b = np.clip(img_8bit * 0.9, 0, 255).astype(np.uint8)
+ return np.stack([r, g, b], axis=2)
+ else:
+ return np.stack([img_8bit] * 3, axis=2)
+
+ @staticmethod
+ def composite_layers(layers_data: list, opacities: list) -> np.ndarray:
+ """
+ Композитинг нескольких слоев изображений
+
+ Args:
+ layers_data: Список numpy массивов изображений
+ opacities: Список прозрачностей (0-1)
+
+ Returns:
+ Скомпозированное изображение
+ """
+ if not layers_data:
+ return None
+
+ # Приводим все к одному размеру
+ min_h = min(img.shape[0] for img in layers_data)
+ min_w = min(img.shape[1] for img in layers_data)
+
+ resized_layers = []
+ for img in layers_data:
+ if img.shape[0] != min_h or img.shape[1] != min_w:
+ from PIL import Image
+ pil_img = Image.fromarray(img)
+ pil_img = pil_img.resize((min_w, min_h), Image.Resampling.LANCZOS)
+ resized_layers.append(np.array(pil_img).astype(float))
+ else:
+ resized_layers.append(img.astype(float))
+
+ # Композитим
+ result = resized_layers[0] * opacities[0]
+ for i in range(1, len(resized_layers)):
+ result += resized_layers[i] * opacities[i]
+
+ # Нормализуем
+ result = np.clip(result, 0, 255).astype(np.uint8)
+
+ return result
+
+ @staticmethod
+ def create_test_image(width: int = 1024, height: int = 1024) -> np.ndarray:
+ """
+ Создает тестовое изображение Солнца для отладки
+
+ Args:
+ width: Ширина изображения
+ height: Высота изображения
+
+ Returns:
+ RGB изображение
+ """
+ # Создаем черный фон
+ img = np.zeros((height, width, 3), dtype=np.uint8)
+
+ # Рисуем круг Солнца
+ y, x = np.ogrid[:height, :width]
+ center_y, center_x = height // 2, width // 2
+ radius = min(height, width) // 3
+
+ mask = (x - center_x)**2 + (y - center_y)**2 <= radius**2
+
+ # Градиент для Солнца
+ distances = np.sqrt((x - center_x)**2 + (y - center_y)**2)
+ gradient = 1 - (distances / radius)
+ gradient = np.clip(gradient, 0, 1)
+
+ # Цвета для разных каналов
+ img[mask, 0] = (200 * gradient[mask]).astype(np.uint8) # Red
+ img[mask, 1] = (150 * gradient[mask]).astype(np.uint8) # Green
+ img[mask, 2] = (80 * gradient[mask]).astype(np.uint8) # Blue
+
+ # Добавляем солнечные пятна
+ spots = [
+ (center_x - 100, center_y - 50, 30),
+ (center_x + 80, center_y + 40, 25),
+ (center_x - 30, center_y + 90, 20),
+ (center_x + 120, center_y - 80, 15),
+ ]
+
+ for sx, sy, r in spots:
+ spot_mask = (x - sx)**2 + (y - sy)**2 <= r**2
+ img[spot_mask] = [50, 30, 20]
+
+ return img
\ No newline at end of file
diff --git a/utils/metadata_parser.py b/utils/metadata_parser.py
new file mode 100644
index 0000000..94d16b1
--- /dev/null
+++ b/utils/metadata_parser.py
@@ -0,0 +1,225 @@
+"""
+Парсер для извлечения FITS-метаданных из JP2 файлов
+"""
+
+import xml.etree.ElementTree as ET
+from typing import Dict, Optional
+import struct
+
+
+class MetadataParser:
+ """Парсер FITS-метаданных из JP2 файлов"""
+
+ @staticmethod
+ def extract_metadata(filepath: str) -> Optional[Dict[str, str]]:
+ """
+ Извлекает FITS-метаданные из JP2 файла
+
+ Args:
+ filepath: Путь к JP2 файлу
+
+ Returns:
+ Словарь с метаданными или None
+ """
+ try:
+ # Пытаемся прочитать JP2 файл как бинарный и найти XML
+ with open(filepath, 'rb') as f:
+ data = f.read()
+
+ # Ищем XML данные в файле (JP2 может содержать XML в специальных боксах)
+ # Простой способ: ищем теги XML
+ xml_start = data.find(b'', xml_start + 100) # Примерный поиск
+ # Находим закрывающий тег
+ if xml_end != -1:
+ # Расширяем поиск до полного XML
+ depth = 1
+ pos = xml_start
+ while depth > 0 and pos < len(data):
+ pos += 1
+ if pos >= len(data):
+ break
+ if data[pos:pos+2] == b'':
+ depth -= 1
+ elif data[pos:pos+1] == b'<':
+ depth += 1
+
+ if pos < len(data):
+ xml_data = data[xml_start:pos+1].decode('utf-8', errors='ignore')
+ return MetadataParser._parse_fits_xml(xml_data)
+
+ # Если не нашли XML, пытаемся извлечь из текстовых блоков
+ # Ищем ASCII текст
+ text = data.decode('utf-8', errors='ignore')
+
+ # Ищем FITS-подобные ключи
+ metadata = {}
+ fits_keys = ['TELESCOP', 'INSTRUME', 'WAVELNTH', 'DATE-OBS', 'EXPTIME',
+ 'CRPIX1', 'CRPIX2', 'CDELT1', 'CDELT2', 'NAXIS1', 'NAXIS2']
+
+ for key in fits_keys:
+ # Ищем ключ в тексте
+ pattern = f'{key}='
+ start = text.find(pattern)
+ if start != -1:
+ # Находим значение
+ value_start = start + len(pattern)
+ # Ищем конец строки или следующий ключ
+ value_end = text.find('/', value_start)
+ if value_end == -1:
+ value_end = text.find('\n', value_start)
+ if value_end == -1:
+ value_end = text.find(' ', value_start + 20)
+
+ value = text[value_start:value_end].strip().strip("'\"")
+ if value:
+ metadata[key] = value
+
+ if metadata:
+ return MetadataParser._format_metadata(metadata)
+
+ return None
+
+ except Exception as e:
+ print(f"Ошибка извлечения метаданных: {e}")
+ return None
+
+ @staticmethod
+ def _parse_fits_xml(xml_content: str) -> Dict[str, str]:
+ """
+ Парсит XML с FITS-заголовком
+
+ Args:
+ xml_content: XML строка
+
+ Returns:
+ Словарь с параметрами FITS
+ """
+ metadata = {}
+
+ try:
+ root = ET.fromstring(xml_content)
+
+ # Ищем секцию fits или FITS
+ fits_section = root.find('.//fits') or root.find('.//FITS')
+
+ if fits_section is not None:
+ for child in fits_section:
+ # Извлекаем ключ и значение
+ key = child.tag
+ value = child.text if child.text else ""
+
+ # Убираем namespace если есть
+ if '}' in key:
+ key = key.split('}')[-1]
+
+ metadata[key.upper()] = value.strip()
+
+ # Если не нашли fits секцию, ищем другие возможные места
+ if not metadata:
+ for child in root.iter():
+ if child.tag.endswith('keyword'):
+ key = child.get('name', '')
+ value = child.text if child.text else ''
+ if key:
+ metadata[key.upper()] = value.strip()
+
+ # Форматируем некоторые ключи для удобства чтения
+ metadata = MetadataParser._format_metadata(metadata)
+
+ except Exception as e:
+ print(f"Ошибка парсинга XML: {e}")
+
+ return metadata
+
+ @staticmethod
+ def _format_metadata(metadata: Dict[str, str]) -> Dict[str, str]:
+ """
+ Форматирует метаданные для удобного отображения
+
+ Args:
+ metadata: Сырые метаданные
+
+ Returns:
+ Отформатированные метаданные
+ """
+ formatted = {}
+
+ # Переименовываем некоторые ключи для понятности
+ key_mapping = {
+ 'TELESCOP': 'Телескоп',
+ 'INSTRUME': 'Инструмент',
+ 'DETECTOR': 'Детектор',
+ 'WAVELNTH': 'Длина волны',
+ 'WAVEUNIT': 'Единица длины волны',
+ 'DATE-OBS': 'Дата наблюдения',
+ 'DATE-BEG': 'Начало экспозиции',
+ 'DATE-END': 'Конец экспозиции',
+ 'EXPTIME': 'Время экспозиции (сек)',
+ 'CRPIX1': 'Центр X (пикс)',
+ 'CRPIX2': 'Центр Y (пикс)',
+ 'CDELT1': 'Шаг пикселя X (arcsec)',
+ 'CDELT2': 'Шаг пикселя Y (arcsec)',
+ 'CROTA2': 'Угол поворота (град)',
+ 'NAXIS1': 'Ширина (пикс)',
+ 'NAXIS2': 'Высота (пикс)',
+ 'BITPIX': 'Бит на пиксель',
+ 'BZERO': 'Смещение данных',
+ 'BSCALE': 'Масштаб данных',
+ 'IMG_TYPE': 'Тип изображения',
+ 'QUALITY': 'Качество',
+ 'LEVEL': 'Уровень обработки',
+ 'STATUS': 'Статус',
+ 'OBSRVTRY': 'Обсерватория'
+ }
+
+ for key, value in metadata.items():
+ # Используем переименованный ключ или оригинал
+ display_key = key_mapping.get(key, key)
+ formatted[display_key] = value
+
+ return formatted
+
+ @staticmethod
+ def extract_from_jp2_box(filepath: str) -> Optional[Dict]:
+ """
+ Альтернативный метод: извлечение метаданных через чтение JP2 боксов
+ Не требует glymur, читает файл напрямую
+ """
+ try:
+ with open(filepath, 'rb') as f:
+ data = f.read()
+
+ # JP2 signature
+ if data[0:4] != b'\x00\x00\x00\x0c':
+ return None
+
+ # Ищем XML бокс (box type 'xml ')
+ offset = 0
+ while offset < len(data) - 8:
+ box_len = struct.unpack('>I', data[offset:offset+4])[0]
+ box_type = data[offset+4:offset+8]
+
+ if box_type == b'xml ' or box_type == b'XML ':
+ # Нашли XML бокс
+ xml_data = data[offset+8:offset+box_len]
+ try:
+ xml_str = xml_data.decode('utf-8', errors='ignore')
+ return MetadataParser._parse_fits_xml(xml_str)
+ except:
+ pass
+
+ if box_len == 0:
+ break
+ offset += box_len
+
+ return None
+
+ except Exception as e:
+ print(f"Ошибка чтения JP2 боксов: {e}")
+ return None
\ No newline at end of file
diff --git a/utils/video_creator.py b/utils/video_creator.py
new file mode 100644
index 0000000..ee654ec
--- /dev/null
+++ b/utils/video_creator.py
@@ -0,0 +1,89 @@
+"""
+Утилиты для создания видео из последовательности изображений
+"""
+
+import cv2
+import numpy as np
+from pathlib import Path
+from typing import List
+from utils.image_processor import ImageProcessor
+
+
+class VideoCreator:
+ """Создатель видео из серии изображений"""
+
+ @staticmethod
+ def create_timelapse(image_paths: List[Path], output_path: Path, fps: int = 10) -> Path:
+ """
+ Создает видео из последовательности изображений
+
+ Args:
+ image_paths: Список путей к изображениям
+ output_path: Путь для сохранения видео
+ fps: Кадров в секунду
+
+ Returns:
+ Путь к созданному видео
+ """
+ if not image_paths:
+ raise ValueError("Нет изображений для создания видео")
+
+ # Загружаем первое изображение для определения размера
+ first_img = ImageProcessor.load_jp2(str(image_paths[0]))
+ if first_img is None:
+ raise ValueError("Не удалось загрузить первое изображение")
+
+ height, width = first_img.shape[:2]
+
+ # Определяем кодек и создаем VideoWriter
+ output_str = str(output_path)
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
+ video_writer = cv2.VideoWriter(output_str, fourcc, fps, (width, height))
+
+ # Добавляем все изображения
+ for img_path in image_paths:
+ # Загружаем изображение
+ img_data = ImageProcessor.load_jp2(str(img_path))
+
+ if img_data is not None:
+ # Конвертируем RGB в BGR для OpenCV
+ img_bgr = cv2.cvtColor(img_data, cv2.COLOR_RGB2BGR)
+ video_writer.write(img_bgr)
+
+ video_writer.release()
+
+ return output_path
+
+ @staticmethod
+ def create_gif(image_paths: List[Path], output_path: Path, duration: int = 200) -> Path:
+ """
+ Создает GIF анимацию из изображений
+
+ Args:
+ image_paths: Список путей к изображениям
+ output_path: Путь для сохранения GIF
+ duration: Длительность кадра в миллисекундах
+
+ Returns:
+ Путь к созданному GIF
+ """
+ from PIL import Image
+
+ images = []
+ for img_path in image_paths:
+ img_data = ImageProcessor.load_jp2(str(img_path))
+ if img_data is not None:
+ pil_image = Image.fromarray(img_data)
+ images.append(pil_image)
+
+ if images:
+ # Сохраняем GIF
+ images[0].save(
+ output_path,
+ save_all=True,
+ append_images=images[1:],
+ duration=duration,
+ loop=0
+ )
+
+ return output_path
\ No newline at end of file
diff --git a/views/__init__.py b/views/__init__.py
new file mode 100644
index 0000000..a188654
--- /dev/null
+++ b/views/__init__.py
@@ -0,0 +1,16 @@
+# views/__init__.py
+from views.main_window import MainWindow
+from views.layer_widget import LayerWidget
+from views.control_panel import ControlPanel
+from views.canvas_widget import SolarCanvas
+from views.timelapse_dialog import TimelapseDialog
+from views.metadata_viewer import MetadataViewer # ← должен импортировать класс
+
+__all__ = [
+ 'MainWindow',
+ 'LayerWidget',
+ 'ControlPanel',
+ 'SolarCanvas',
+ 'TimelapseDialog',
+ 'MetadataViewer'
+]
\ No newline at end of file
diff --git a/views/__pycache__/__init__.cpython-312.pyc b/views/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..1d6ab1d
Binary files /dev/null and b/views/__pycache__/__init__.cpython-312.pyc differ
diff --git a/views/__pycache__/canvas_widget.cpython-312.pyc b/views/__pycache__/canvas_widget.cpython-312.pyc
new file mode 100644
index 0000000..dd26070
Binary files /dev/null and b/views/__pycache__/canvas_widget.cpython-312.pyc differ
diff --git a/views/__pycache__/control_panel.cpython-312.pyc b/views/__pycache__/control_panel.cpython-312.pyc
new file mode 100644
index 0000000..3a870ab
Binary files /dev/null and b/views/__pycache__/control_panel.cpython-312.pyc differ
diff --git a/views/__pycache__/layer_widget.cpython-312.pyc b/views/__pycache__/layer_widget.cpython-312.pyc
new file mode 100644
index 0000000..4e8af6c
Binary files /dev/null and b/views/__pycache__/layer_widget.cpython-312.pyc differ
diff --git a/views/__pycache__/main_window.cpython-312.pyc b/views/__pycache__/main_window.cpython-312.pyc
new file mode 100644
index 0000000..1f4ebfa
Binary files /dev/null and b/views/__pycache__/main_window.cpython-312.pyc differ
diff --git a/views/__pycache__/metadata_viewer.cpython-312.pyc b/views/__pycache__/metadata_viewer.cpython-312.pyc
new file mode 100644
index 0000000..611b697
Binary files /dev/null and b/views/__pycache__/metadata_viewer.cpython-312.pyc differ
diff --git a/views/__pycache__/timelapse_dialog.cpython-312.pyc b/views/__pycache__/timelapse_dialog.cpython-312.pyc
new file mode 100644
index 0000000..0182548
Binary files /dev/null and b/views/__pycache__/timelapse_dialog.cpython-312.pyc differ
diff --git a/views/canvas_widget.py b/views/canvas_widget.py
new file mode 100644
index 0000000..a15549d
--- /dev/null
+++ b/views/canvas_widget.py
@@ -0,0 +1,149 @@
+"""
+Виджет для отображения и манипуляции солнечными изображениями
+Поддерживает: зум, панорамирование, композитинг слоев
+"""
+
+from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem
+from PySide6.QtCore import Qt, QRectF, QPointF, Signal
+from PySide6.QtGui import QPixmap, QImage, QWheelEvent, QMouseEvent, QPainter
+import numpy as np
+
+
+class SolarCanvas(QGraphicsView):
+ """
+ Кастомный Canvas для отображения солнечных изображений
+ Поддерживает масштабирование, панорамирование и наложение слоев
+ """
+
+ zoom_changed = Signal(float)
+
+ def __init__(self, controller):
+ super().__init__()
+ self.controller = controller
+ self.scene = QGraphicsScene(self)
+ self.setScene(self.scene)
+
+ # Настройки отображения - ИСПРАВЛЕНО: используем QPainter
+ self.setRenderHint(QPainter.RenderHint.Antialiasing)
+ self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
+ self.setDragMode(self.DragMode.ScrollHandDrag)
+ self.setTransformationAnchor(self.ViewportAnchor.AnchorUnderMouse)
+ self.setResizeAnchor(self.ViewportAnchor.AnchorUnderMouse)
+
+ # Переменные для зума
+ self.current_zoom = 1.0
+ self.min_zoom = 0.1
+ self.max_zoom = 10.0
+ self.zoom_factor = 1.1
+
+ # Словарь для хранения слоев (QGraphicsPixmapItem)
+ self.layer_items = {}
+
+ # Фон для лучшего контраста (черный для астрономических изображений)
+ self.setBackgroundBrush(Qt.GlobalColor.black)
+
+ # Дополнительные настройки для плавности
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+
+ def set_image(self, layer_id: int, image_data: np.ndarray):
+ """
+ Устанавливает изображение для слоя
+
+ Args:
+ layer_id: ID слоя
+ image_data: numpy массив с изображением
+ """
+ # Конвертируем numpy array в QImage
+ height, width = image_data.shape[:2]
+
+ if len(image_data.shape) == 2:
+ # Черно-белое изображение
+ bytes_per_line = width
+ qimage = QImage(image_data.data, width, height, bytes_per_line, QImage.Format.Format_Grayscale8)
+ else:
+ # RGB изображение
+ bytes_per_line = 3 * width
+ qimage = QImage(image_data.data, width, height, bytes_per_line, QImage.Format.Format_RGB888)
+
+ # Конвертируем в QPixmap
+ pixmap = QPixmap.fromImage(qimage)
+
+ # Создаем или обновляем графический элемент
+ if layer_id in self.layer_items:
+ self.layer_items[layer_id].setPixmap(pixmap)
+ else:
+ item = self.scene.addPixmap(pixmap)
+ self.layer_items[layer_id] = item
+
+ # Центрируем изображение при первом добавлении
+ if len(self.layer_items) == 1:
+ self.centerOn(item)
+ self.fitInView(item, Qt.AspectRatioMode.KeepAspectRatio)
+
+ def set_layer_visibility(self, layer_id: int, visible: bool):
+ """Устанавливает видимость слоя"""
+ if layer_id in self.layer_items:
+ self.layer_items[layer_id].setVisible(visible)
+
+ def set_layer_opacity(self, layer_id: int, opacity: float):
+ """Устанавливает прозрачность слоя"""
+ if layer_id in self.layer_items:
+ self.layer_items[layer_id].setOpacity(opacity)
+
+ def remove_layer(self, layer_id: int):
+ """Удаляет слой"""
+ if layer_id in self.layer_items:
+ self.scene.removeItem(self.layer_items[layer_id])
+ del self.layer_items[layer_id]
+
+ def clear_all_layers(self):
+ """Очищает все слои"""
+ for item in self.layer_items.values():
+ self.scene.removeItem(item)
+ self.layer_items.clear()
+
+ def wheelEvent(self, event: QWheelEvent):
+ """Обработка колесика мыши для зума"""
+ # Определяем направление зума
+ zoom_in = event.angleDelta().y() > 0
+
+ # Вычисляем новый уровень зума
+ if zoom_in:
+ new_zoom = self.current_zoom * self.zoom_factor
+ else:
+ new_zoom = self.current_zoom / self.zoom_factor
+
+ # Ограничиваем зум
+ if self.min_zoom <= new_zoom <= self.max_zoom:
+ self.current_zoom = new_zoom
+ self.scale(self.zoom_factor if zoom_in else 1/self.zoom_factor,
+ self.zoom_factor if zoom_in else 1/self.zoom_factor)
+ self.zoom_changed.emit(self.current_zoom)
+
+ def mousePressEvent(self, event: QMouseEvent):
+ """Обработка нажатия мыши для панорамирования"""
+ if event.button() == Qt.MouseButton.MiddleButton:
+ self.setDragMode(self.DragMode.ScrollHandDrag)
+ # Создаем фейковое событие с левой кнопкой
+ fake_event = QMouseEvent(
+ event.type(), event.pos(), Qt.MouseButton.LeftButton,
+ Qt.MouseButton.LeftButton, event.modifiers()
+ )
+ super().mousePressEvent(fake_event)
+ else:
+ super().mousePressEvent(event)
+
+ def mouseReleaseEvent(self, event: QMouseEvent):
+ """Обработка отпускания мыши"""
+ if event.button() == Qt.MouseButton.MiddleButton:
+ self.setDragMode(self.DragMode.NoDrag)
+ super().mouseReleaseEvent(event)
+
+ def reset_view(self):
+ """Сбрасывает зум и позицию"""
+ if self.layer_items:
+ first_item = next(iter(self.layer_items.values()))
+ self.fitInView(first_item, Qt.AspectRatioMode.KeepAspectRatio)
+ self.current_zoom = 1.0
+ self.zoom_changed.emit(self.current_zoom)
\ No newline at end of file
diff --git a/views/control_panel.py b/views/control_panel.py
new file mode 100644
index 0000000..91f797f
--- /dev/null
+++ b/views/control_panel.py
@@ -0,0 +1,159 @@
+"""
+Панель управления - выбор даты, спектра, загрузка изображений
+"""
+
+from PySide6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
+ QComboBox, QDateEdit, QTimeEdit, QPushButton,
+ QLabel, QProgressBar, QFrame
+)
+from PySide6.QtCore import Qt, QDateTime, Signal
+from datetime import datetime
+
+
+class ControlPanel(QWidget):
+ """Панель управления для выбора параметров загрузки"""
+
+ load_image_requested = Signal(int, datetime) # source_id, date
+
+ def __init__(self, controller):
+ super().__init__()
+ self.controller = controller
+ self.init_ui()
+
+ def init_ui(self):
+ """Инициализация UI"""
+ layout = QVBoxLayout(self)
+ layout.setSpacing(10)
+ layout.setContentsMargins(5, 5, 5, 5)
+
+ # Группа выбора спектра
+ spectrum_group = QGroupBox("🌞 Спектральный канал")
+ spectrum_layout = QVBoxLayout(spectrum_group)
+
+ self.spectrum_combo = QComboBox()
+ self.spectrum_combo.setMinimumHeight(30)
+ self.populate_spectrums()
+ spectrum_layout.addWidget(self.spectrum_combo)
+
+ layout.addWidget(spectrum_group)
+
+ # Группа выбора даты и времени
+ date_group = QGroupBox("📅 Дата и время (UTC)")
+ date_layout = QVBoxLayout(date_group)
+
+ # Дата
+ date_label = QLabel("Дата:")
+ date_layout.addWidget(date_label)
+
+ self.date_edit = QDateEdit()
+ self.date_edit.setDateTime(QDateTime.currentDateTime())
+ self.date_edit.setCalendarPopup(True)
+ self.date_edit.setMinimumHeight(30)
+ date_layout.addWidget(self.date_edit)
+
+ # Время
+ time_label = QLabel("Время:")
+ date_layout.addWidget(time_label)
+
+ self.time_edit = QTimeEdit()
+ self.time_edit.setTime(QDateTime.currentDateTime().time())
+ self.time_edit.setMinimumHeight(30)
+ date_layout.addWidget(self.time_edit)
+
+ # Кнопки быстрого выбора
+ quick_layout = QHBoxLayout()
+
+ now_button = QPushButton("Сейчас")
+ now_button.setMinimumHeight(30)
+ now_button.clicked.connect(self.set_now)
+ quick_layout.addWidget(now_button)
+
+ today_button = QPushButton("Сегодня")
+ today_button.setMinimumHeight(30)
+ today_button.clicked.connect(self.set_today)
+ quick_layout.addWidget(today_button)
+
+ date_layout.addLayout(quick_layout)
+
+ layout.addWidget(date_group)
+
+ # Кнопка загрузки
+ self.load_button = QPushButton("📥 Загрузить изображение")
+ self.load_button.setMinimumHeight(40)
+ self.load_button.clicked.connect(self.request_load_image)
+ self.load_button.setStyleSheet("""
+ QPushButton {
+ background-color: #2ecc71;
+ color: white;
+ font-weight: bold;
+ font-size: 14px;
+ padding: 10px;
+ border-radius: 5px;
+ }
+ QPushButton:hover {
+ background-color: #27ae60;
+ }
+ QPushButton:disabled {
+ background-color: #555;
+ }
+ """)
+ layout.addWidget(self.load_button)
+
+ # Прогресс бар (для загрузки)
+ self.progress_bar = QProgressBar()
+ self.progress_bar.setVisible(False)
+ self.progress_bar.setMinimumHeight(20)
+ layout.addWidget(self.progress_bar)
+
+ # Информационная метка
+ self.info_label = QLabel("Выберите спектральный канал и дату")
+ self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.info_label.setStyleSheet("color: #888; font-size: 11px; padding: 5px;")
+ layout.addWidget(self.info_label)
+
+ def populate_spectrums(self):
+ """Заполняет список доступных спектров"""
+ from models.api_model import HelioviewerAPI
+
+ sources = HelioviewerAPI.get_available_sources()
+ for source_id, info in sources.items():
+ display_text = f"{info['observatory']} - {info['name']} ({info['wavelength']})"
+ self.spectrum_combo.addItem(display_text, source_id)
+
+ def get_selected_source_id(self) -> int:
+ """Возвращает ID выбранного источника"""
+ return self.spectrum_combo.currentData()
+
+ def get_selected_datetime(self) -> datetime:
+ """Возвращает выбранную дату и время"""
+ qdatetime = QDateTime(self.date_edit.date(), self.time_edit.time())
+ return qdatetime.toPython()
+
+ def set_now(self):
+ """Устанавливает текущее время"""
+ now = QDateTime.currentDateTime()
+ self.date_edit.setDate(now.date())
+ self.time_edit.setTime(now.time())
+
+ def set_today(self):
+ """Устанавливает сегодняшнюю дату"""
+ self.date_edit.setDate(QDateTime.currentDateTime().date())
+
+ def request_load_image(self):
+ """Запрашивает загрузку изображения"""
+ source_id = self.get_selected_source_id()
+ date = self.get_selected_datetime()
+ self.load_image_requested.emit(source_id, date)
+
+ def show_progress(self, show: bool):
+ """Показывает/скрывает прогресс бар"""
+ self.progress_bar.setVisible(show)
+ self.load_button.setEnabled(not show)
+ self.info_label.setText("Загрузка..." if show else "Готово")
+
+ def update_progress(self, value: int, maximum: int):
+ """Обновляет прогресс"""
+ self.progress_bar.setRange(0, maximum)
+ self.progress_bar.setValue(value)
+ self.info_label.setText(f"Загрузка: {int(value/maximum*100)}%")
\ No newline at end of file
diff --git a/views/layer_widget.py b/views/layer_widget.py
new file mode 100644
index 0000000..e0d08f2
--- /dev/null
+++ b/views/layer_widget.py
@@ -0,0 +1,219 @@
+"""
+Виджет для управления слоями изображений
+"""
+
+from PySide6.QtWidgets import (
+ QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
+ QHBoxLayout, QCheckBox, QSlider, QLabel, QPushButton,
+ QFrame
+)
+from PySide6.QtCore import Qt, Signal, QSize
+
+
+class LayerWidgetItem(QWidget):
+ """Виджет для отдельного слоя в списке"""
+
+ visibility_toggled = Signal(int, bool)
+ opacity_changed = Signal(int, float)
+
+ def __init__(self, layer_id: int, name: str, visible: bool = True):
+ super().__init__()
+ self.layer_id = layer_id
+ self.init_ui(name, visible)
+
+ def init_ui(self, name: str, visible: bool):
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(8, 6, 8, 6)
+ layout.setSpacing(8)
+
+ # Чекбокс видимости
+ self.visibility_checkbox = QCheckBox()
+ self.visibility_checkbox.setChecked(visible)
+ self.visibility_checkbox.setFixedSize(22, 22)
+ self.visibility_checkbox.toggled.connect(self.on_visibility_toggled)
+ layout.addWidget(self.visibility_checkbox)
+
+ # Название слоя
+ self.name_label = QLabel(name)
+ self.name_label.setStyleSheet("font-size: 11px; font-weight: 500;")
+ self.name_label.setWordWrap(True)
+ layout.addWidget(self.name_label, 2)
+
+ # Слайдер прозрачности
+ self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
+ self.opacity_slider.setRange(0, 100)
+ self.opacity_slider.setValue(100)
+ self.opacity_slider.setFixedWidth(100)
+ self.opacity_slider.valueChanged.connect(self.on_opacity_changed)
+ layout.addWidget(self.opacity_slider)
+
+ # Метка прозрачности
+ self.opacity_label = QLabel("100%")
+ self.opacity_label.setFixedWidth(35)
+ self.opacity_label.setStyleSheet("font-size: 10px; font-family: monospace;")
+ layout.addWidget(self.opacity_label)
+
+ # Кнопка удаления
+ remove_button = QPushButton("✖")
+ remove_button.setFixedSize(26, 26)
+ remove_button.setStyleSheet("""
+ QPushButton {
+ background-color: #e74c3c;
+ color: white;
+ border-radius: 13px;
+ font-size: 12px;
+ font-weight: bold;
+ }
+ QPushButton:hover {
+ background-color: #c0392b;
+ }
+ """)
+ remove_button.clicked.connect(lambda: self.visibility_toggled.emit(self.layer_id, False))
+ layout.addWidget(remove_button)
+
+ def on_visibility_toggled(self, checked):
+ self.visibility_toggled.emit(self.layer_id, checked)
+
+ def on_opacity_changed(self, value):
+ opacity = value / 100.0
+ self.opacity_label.setText(f"{value}%")
+ self.opacity_changed.emit(self.layer_id, opacity)
+
+ def set_visible(self, visible: bool):
+ self.visibility_checkbox.setChecked(visible)
+
+ def set_opacity(self, opacity: float):
+ value = int(opacity * 100)
+ self.opacity_slider.setValue(value)
+ self.opacity_label.setText(f"{value}%")
+
+
+class LayerWidget(QWidget):
+ """Виджет для отображения списка слоев"""
+
+ def __init__(self, controller):
+ super().__init__()
+ self.controller = controller
+ self.layer_widgets = {}
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(5)
+
+ # Список слоев
+ self.list_widget = QListWidget()
+ self.list_widget.setStyleSheet("""
+ QListWidget {
+ border: 1px solid #555;
+ border-radius: 5px;
+ background-color: #2b2b2b;
+ outline: none;
+ }
+ QListWidget::item {
+ border-bottom: 1px solid #444;
+ }
+ QListWidget::item:selected {
+ background-color: #3c5a8c;
+ }
+ QListWidget::item:hover {
+ background-color: #3c3c3c;
+ }
+ """)
+ layout.addWidget(self.list_widget)
+
+ # Кнопка очистки
+ clear_button = QPushButton("🗑️ Очистить все слои")
+ clear_button.setMinimumHeight(32)
+ clear_button.setStyleSheet("""
+ QPushButton {
+ background-color: #555;
+ color: white;
+ padding: 5px;
+ border-radius: 4px;
+ }
+ QPushButton:hover {
+ background-color: #e74c3c;
+ }
+ """)
+ clear_button.clicked.connect(self.clear_all_layers)
+ layout.addWidget(clear_button)
+
+ # Пустое состояние
+ self.empty_label = QLabel("📭 Нет загруженных слоев\n\nНажмите 'Загрузить изображение'")
+ self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.empty_label.setStyleSheet("color: #888; padding: 30px; background-color: #2b2b2b; border-radius: 5px;")
+ self.empty_label.setVisible(True)
+ layout.addWidget(self.empty_label)
+
+ def add_layer(self, layer_id: int, name: str, visible: bool = True):
+ """Добавляет новый слой в список"""
+ self.empty_label.setVisible(False)
+ self.list_widget.setVisible(True)
+
+ item = QListWidgetItem()
+ item.setSizeHint(QSize(0, 55))
+ self.list_widget.addItem(item)
+
+ widget = LayerWidgetItem(layer_id, name, visible)
+ widget.visibility_toggled.connect(self.on_visibility_toggled)
+ widget.opacity_changed.connect(self.on_opacity_changed)
+
+ self.list_widget.setItemWidget(item, widget)
+ self.layer_widgets[layer_id] = (item, widget)
+ self.list_widget.setCurrentItem(item)
+ self.list_widget.scrollToItem(item)
+
+ def remove_layer(self, layer_id: int):
+ """Удаляет слой из списка"""
+ if layer_id in self.layer_widgets:
+ item, _ = self.layer_widgets[layer_id]
+ row = self.list_widget.row(item)
+ self.list_widget.takeItem(row)
+ del self.layer_widgets[layer_id]
+
+ if len(self.layer_widgets) == 0:
+ self.empty_label.setVisible(True)
+ self.list_widget.setVisible(False)
+
+ def update_layers(self, layers):
+ """Обновляет весь список слоев"""
+ self.clear_all_layers(keep_controller=False)
+ for layer in layers:
+ self.add_layer(layer.id, layer.name, layer.visible)
+
+ if layers:
+ self.empty_label.setVisible(False)
+ self.list_widget.setVisible(True)
+ else:
+ self.empty_label.setVisible(True)
+ self.list_widget.setVisible(False)
+
+ def clear_all_layers(self, keep_controller: bool = True):
+ """Очищает список слоев"""
+ self.list_widget.clear()
+ self.layer_widgets.clear()
+
+ if keep_controller:
+ self.controller.clear_all_layers()
+
+ self.empty_label.setVisible(True)
+ self.list_widget.setVisible(False)
+
+ def on_visibility_toggled(self, layer_id: int, visible: bool):
+ self.controller.set_layer_visibility(layer_id, visible)
+
+ def on_opacity_changed(self, layer_id: int, opacity: float):
+ self.controller.set_layer_opacity(layer_id, opacity)
+
+ def get_selected_layer_id(self) -> int:
+ current_item = self.list_widget.currentItem()
+ if current_item:
+ for layer_id, (item, _) in self.layer_widgets.items():
+ if item == current_item:
+ return layer_id
+ return None
+
+ def get_layers_count(self) -> int:
+ return len(self.layer_widgets)
\ No newline at end of file
diff --git a/views/main_window.py b/views/main_window.py
new file mode 100644
index 0000000..e835d19
--- /dev/null
+++ b/views/main_window.py
@@ -0,0 +1,251 @@
+"""
+Главное окно приложения - содержит меню, панели и canvas
+"""
+
+from PySide6.QtWidgets import (
+ QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
+ QSplitter, QMenuBar, QMenu, QStatusBar, QMessageBox,
+ QFileDialog, QToolBar, QFrame, QScrollArea, QLabel
+)
+from PySide6.QtCore import Qt, QSize
+from PySide6.QtGui import QAction, QKeySequence
+
+from views.layer_widget import LayerWidget
+from views.canvas_widget import SolarCanvas
+from views.control_panel import ControlPanel
+from views.timelapse_dialog import TimelapseDialog
+from views.metadata_viewer import MetadataViewer
+
+
+class MainWindow(QMainWindow):
+ """Главное окно приложения"""
+
+ def __init__(self, controller):
+ super().__init__()
+ self.controller = controller
+ self.init_ui()
+
+ def init_ui(self):
+ """Инициализация пользовательского интерфейса"""
+ self.setWindowTitle("Helioviewer Solar Viewer - Профессиональный просмотрщик снимков Солнца")
+ self.setMinimumSize(1200, 800)
+
+ # Центральный виджет
+ central_widget = QWidget()
+ self.setCentralWidget(central_widget)
+
+ # Основной layout
+ main_layout = QHBoxLayout(central_widget)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+
+ # Создаем сплиттер для левой панели и canvas
+ splitter = QSplitter(Qt.Orientation.Horizontal)
+ main_layout.addWidget(splitter)
+
+ # Левая панель (с прокруткой)
+ left_panel = self.create_left_panel_with_scroll()
+ splitter.addWidget(left_panel)
+
+ # Устанавливаем начальную ширину сплиттера
+ splitter.setSizes([400, self.width() - 400])
+
+ # Правая область (canvas)
+ right_area = self.create_right_area()
+ splitter.addWidget(right_area)
+
+ # Создаем меню
+ self.create_menu_bar()
+
+ # Создаем статус бар
+ self.status_bar = QStatusBar()
+ self.setStatusBar(self.status_bar)
+ self.status_bar.showMessage("Готов к работе")
+
+ # Добавляем тулбар для быстрого доступа
+ self.create_toolbar()
+
+ def create_left_panel_with_scroll(self) -> QWidget:
+ """
+ Создает левую панель с прокруткой, где все виджеты равномерно распределяют пространство
+ """
+ # Внешний контейнер для скролла
+ scroll_container = QWidget()
+ scroll_layout = QVBoxLayout(scroll_container)
+ scroll_layout.setContentsMargins(0, 0, 0, 0)
+
+ # Создаем QScrollArea
+ scroll_area = QScrollArea()
+ scroll_area.setWidgetResizable(True) # Позволяет виджету изменять размер
+ scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ scroll_area.setStyleSheet("""
+ QScrollArea {
+ border: none;
+ background-color: #2b2b2b;
+ }
+ QScrollBar:vertical {
+ background-color: #2b2b2b;
+ width: 12px;
+ border-radius: 6px;
+ }
+ QScrollBar::handle:vertical {
+ background-color: #555;
+ border-radius: 6px;
+ min-height: 20px;
+ }
+ QScrollBar::handle:vertical:hover {
+ background-color: #777;
+ }
+ QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
+ height: 0px;
+ }
+ """)
+
+ # Внутренний контейнер для всех виджетов
+ content_widget = QWidget()
+ content_widget.setStyleSheet("background-color: #2b2b2b;")
+ layout = QVBoxLayout(content_widget)
+ layout.setSpacing(10)
+ layout.setContentsMargins(10, 10, 10, 10)
+
+ # Панель управления (выбор даты, спектра)
+ self.control_panel = ControlPanel(self.controller)
+ layout.addWidget(self.control_panel, 1) # stretch factor = 1
+
+ # Разделитель
+ layout.addWidget(self.create_separator())
+
+ # Заголовок слоев
+ title_layers = self.create_section_title("📁 Слои изображений")
+ layout.addWidget(title_layers)
+
+ # Виджет слоев - будет занимать столько места, сколько нужно
+ self.layer_widget = LayerWidget(self.controller)
+ layout.addWidget(self.layer_widget, 2) # stretch factor = 2 (больше места)
+
+ # Разделитель
+ layout.addWidget(self.create_separator())
+
+ # Заголовок метаданных
+ title_metadata = self.create_section_title("📊 Метаданные FITS")
+ layout.addWidget(title_metadata)
+
+ # Просмотрщик метаданных
+ self.metadata_viewer = MetadataViewer()
+ layout.addWidget(self.metadata_viewer, 1) # stretch factor = 1
+
+ # Растягивающийся спейсер внизу (опционально)
+ layout.addStretch()
+
+ # Устанавливаем content_widget в scroll_area
+ scroll_area.setWidget(content_widget)
+
+ # Добавляем scroll_area в контейнер
+ scroll_layout.addWidget(scroll_area)
+
+ return scroll_container
+
+ def create_separator(self) -> QFrame:
+ """Создает линию-разделитель"""
+ separator = QFrame()
+ separator.setFrameShape(QFrame.Shape.HLine)
+ separator.setFrameShadow(QFrame.Shadow.Sunken)
+ separator.setStyleSheet("background-color: #555; max-height: 1px;")
+ return separator
+
+ def create_section_title(self, title: str) -> QLabel:
+ """Создает заголовок секции"""
+ label = QLabel(title)
+ label.setStyleSheet("""
+ font-weight: bold;
+ font-size: 14px;
+ padding: 8px 5px;
+ background-color: #3c3c3c;
+ border-radius: 4px;
+ margin-top: 5px;
+ margin-bottom: 5px;
+ """)
+ label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
+ return label
+
+ def create_right_area(self) -> QWidget:
+ """Создает правую область с canvas"""
+ area = QWidget()
+ layout = QVBoxLayout(area)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ # Canvas для отображения изображений
+ self.canvas = SolarCanvas(self.controller)
+ layout.addWidget(self.canvas)
+
+ return area
+
+ def create_menu_bar(self):
+ """Создает меню приложения"""
+ menubar = self.menuBar()
+
+ # Меню Файл
+ file_menu = menubar.addMenu("📁 Файл")
+
+ # Действия для меню Файл
+ self.setup_action = QAction("⚙️ Настройки", self)
+ self.exit_action = QAction("🚪 Выход", self)
+ self.exit_action.setShortcut(QKeySequence.StandardKey.Quit)
+ self.exit_action.triggered.connect(self.close)
+
+ file_menu.addAction(self.setup_action)
+ file_menu.addSeparator()
+ file_menu.addAction(self.exit_action)
+
+ # Меню Инструменты
+ tools_menu = menubar.addMenu("🛠️ Инструменты")
+
+ self.timelapse_action = QAction("🎬 Таймлапс...", self)
+ self.timelapse_action.triggered.connect(self.open_timelapse_dialog)
+ tools_menu.addAction(self.timelapse_action)
+
+ # Меню Справка
+ help_menu = menubar.addMenu("❓ Справка")
+
+ about_action = QAction("ℹ️ О программе", self)
+ about_action.triggered.connect(self.show_about)
+ help_menu.addAction(about_action)
+
+ def create_toolbar(self):
+ """Создает панель инструментов"""
+ toolbar = QToolBar("Быстрый доступ")
+ self.addToolBar(toolbar)
+ toolbar.setIconSize(QSize(24, 24))
+
+ def open_timelapse_dialog(self):
+ """Открывает диалог создания таймлапса"""
+ dialog = TimelapseDialog(self.controller, self)
+ dialog.exec()
+
+ def show_about(self):
+ """Показывает информацию о программе"""
+ QMessageBox.about(
+ self,
+ "О программе",
+ "Helioviewer Solar Viewer v1.0\n\n"
+ "Профессиональный просмотрщик снимков Солнца\n"
+ "Использует данные Helioviewer API\n\n"
+ "Возможности:\n"
+ "• Загрузка снимков в различных спектрах\n"
+ "• Многослойный режим с наложением\n"
+ "• Создание таймлапс-анимаций\n"
+ "• Просмотр FITS-метаданных\n\n"
+ "© 2024 SolarViewer Team"
+ )
+
+ def update_status(self, message: str, timeout: int = 3000):
+ """Обновляет статус в статус-баре"""
+ self.status_bar.showMessage(message, timeout)
+
+ def update_layer_list(self, layers):
+ """Обновляет список слоев"""
+ self.layer_widget.update_layers(layers)
+
+ def get_control_panel(self):
+ """Возвращает панель управления"""
+ return self.control_panel
\ No newline at end of file
diff --git a/views/metadata_viewer.py b/views/metadata_viewer.py
new file mode 100644
index 0000000..1e6bfed
--- /dev/null
+++ b/views/metadata_viewer.py
@@ -0,0 +1,126 @@
+"""
+Виджет для отображения FITS-метаданных из JP2 файлов
+"""
+
+from PySide6.QtWidgets import (
+ QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem,
+ QGroupBox, QLabel, QScrollArea
+)
+from PySide6.QtCore import Qt
+
+
+class MetadataViewer(QWidget):
+ """Просмотрщик метаданных FITS"""
+
+ def __init__(self):
+ super().__init__()
+ self.init_ui()
+
+ def init_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ # Используем QScrollArea для метаданных
+ scroll_area = QScrollArea()
+ scroll_area.setWidgetResizable(True)
+ scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ scroll_area.setStyleSheet("""
+ QScrollArea {
+ border: 1px solid #555;
+ border-radius: 5px;
+ background-color: #2b2b2b;
+ }
+ """)
+
+ # Внутренний контейнер
+ content_widget = QWidget()
+ content_layout = QVBoxLayout(content_widget)
+ content_layout.setContentsMargins(5, 5, 5, 5)
+
+ # Дерево для метаданных
+ self.tree = QTreeWidget()
+ self.tree.setHeaderLabels(["Параметр", "Значение"])
+ self.tree.setAlternatingRowColors(True)
+ self.tree.setIndentation(10)
+ self.tree.setMinimumHeight(150)
+ self.tree.setStyleSheet("""
+ QTreeWidget {
+ background-color: #2b2b2b;
+ alternate-background-color: #252525;
+ border: none;
+ }
+ QTreeWidget::item {
+ padding: 3px;
+ }
+ """)
+ content_layout.addWidget(self.tree)
+
+ # Информационная метка
+ self.info_label = QLabel("Выберите слой для просмотра метаданных")
+ self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.info_label.setStyleSheet("color: #888; padding: 20px;")
+ self.info_label.setWordWrap(True)
+ content_layout.addWidget(self.info_label)
+
+ scroll_area.setWidget(content_widget)
+ layout.addWidget(scroll_area)
+
+ def display_metadata(self, metadata: dict):
+ """Отображает метаданные в дереве"""
+ self.tree.clear()
+
+ if not metadata:
+ self.info_label.setVisible(True)
+ self.tree.setVisible(False)
+ self.info_label.setText("Метаданные не найдены")
+ return
+
+ self.info_label.setVisible(False)
+ self.tree.setVisible(True)
+
+ # Группировка метаданных
+ categories = {
+ "📡 Инструмент": ["Телескоп", "Инструмент", "Детектор", "Обсерватория"],
+ "🌊 Спектр": ["Длина волны", "Единица длины волны"],
+ "📅 Время": ["Дата наблюдения", "Начало экспозиции", "Конец экспозиции", "Время экспозиции (сек)"],
+ "📐 Геометрия": ["Центр X (пикс)", "Центр Y (пикс)", "Шаг пикселя X (arcsec)", "Шаг пикселя Y (arcsec)",
+ "Угол поворота (град)"],
+ "📊 Изображение": ["Ширина (пикс)", "Высота (пикс)", "Бит на пиксель"],
+ "📝 Другое": []
+ }
+
+ # Сортируем метаданные
+ for category, keys in categories.items():
+ category_items = []
+ for key in keys:
+ if key in metadata and metadata[key]:
+ category_items.append((key, metadata[key]))
+
+ # Для категории "Другое" собираем остальные ключи
+ if category == "📝 Другое":
+ all_keys = [k for sublist in categories.values() for k in sublist if k != "Другое"]
+ for key, value in metadata.items():
+ if key not in all_keys and value:
+ category_items.append((key, value))
+
+ if category_items:
+ category_item = QTreeWidgetItem(self.tree)
+ category_item.setText(0, category)
+ category_item.setExpanded(True)
+
+ for key, value in category_items:
+ param_item = QTreeWidgetItem(category_item)
+ param_item.setText(0, key)
+ param_item.setText(1, str(value))
+
+ self.tree.expandAll()
+ self.tree.resizeColumnToContents(0)
+ self.tree.resizeColumnToContents(1)
+
+ def clear(self):
+ """Очищает отображение метаданных"""
+ self.tree.clear()
+ self.info_label.setVisible(True)
+ self.tree.setVisible(False)
+ self.info_label.setText("Выберите слой для просмотра метаданных")
\ No newline at end of file
diff --git a/views/timelapse_dialog.py b/views/timelapse_dialog.py
new file mode 100644
index 0000000..4850e18
--- /dev/null
+++ b/views/timelapse_dialog.py
@@ -0,0 +1,370 @@
+"""
+Диалог для создания таймлапс-анимации
+"""
+
+from PySide6.QtWidgets import (
+ QDialog, QVBoxLayout, QHBoxLayout, QGroupBox,
+ QComboBox, QDateEdit, QPushButton, QLabel,
+ QProgressBar, QFileDialog, QMessageBox, QCheckBox,
+ QTextEdit
+)
+from PySide6.QtCore import Qt, QDateTime, QSettings, QThread, Signal
+from pathlib import Path
+from datetime import datetime, timedelta
+import tempfile
+import shutil
+
+
+class TimelapseWorker(QThread):
+ progress = Signal(int, int, str)
+ log = Signal(str)
+ finished = Signal(bool, str)
+
+ def __init__(self, source_id, start_date, end_date, output_path, fps=10):
+ super().__init__()
+ self.source_id = source_id
+ self.start_date = start_date
+ self.end_date = end_date
+ self.output_path = output_path
+ self.fps = fps
+ self._is_cancelled = False
+
+ def cancel(self):
+ self._is_cancelled = True
+
+ def run(self):
+ from models.api_model import HelioviewerAPI
+ from utils.video_creator import VideoCreator
+
+ try:
+ # Создаем временную папку
+ temp_dir = Path(tempfile.mkdtemp(prefix="helioviewer_timelapse_"))
+ self.log.emit(f"Создана временная папка: {temp_dir}")
+
+ # Генерируем даты - ИСПРАВЛЕНО
+ dates = []
+
+ # Проверяем тип и преобразуем в datetime если нужно
+ from datetime import datetime as dt
+ if isinstance(self.start_date, dt):
+ current = self.start_date.replace(hour=12, minute=0, second=0)
+ else:
+ # Если это date, конвертируем в datetime
+ current = dt.combine(self.start_date, dt.min.time()).replace(hour=12)
+
+ # Проверяем конец
+ if isinstance(self.end_date, dt):
+ end = self.end_date
+ else:
+ end = dt.combine(self.end_date, dt.max.time())
+
+ while current <= end:
+ dates.append(current)
+ current += timedelta(days=1)
+
+ total = len(dates)
+ self.log.emit(f"Всего файлов в очереди: {total}")
+
+ downloaded = []
+
+ for i, date in enumerate(dates):
+ if self._is_cancelled:
+ self.log.emit("Отменено пользователем")
+ shutil.rmtree(temp_dir, ignore_errors=True)
+ self.finished.emit(False, "Отменено")
+ return
+
+ current_num = i + 1
+ percent = int(current_num / total * 100)
+ self.progress.emit(current_num, total, f"Скачивание {current_num}/{total} ({percent}%)")
+ self.log.emit(f"Скачивание {current_num}/{total}: {date.strftime('%Y-%m-%d')}")
+
+ filepath = HelioviewerAPI.download_image(self.source_id, date, temp_dir)
+ if filepath:
+ downloaded.append(filepath)
+ self.log.emit(f" ✓ Успешно")
+ else:
+ self.log.emit(f" ✗ Ошибка")
+
+ if self._is_cancelled:
+ self.log.emit("Отменено пользователем")
+ shutil.rmtree(temp_dir, ignore_errors=True)
+ self.finished.emit(False, "Отменено")
+ return
+
+ if not downloaded:
+ shutil.rmtree(temp_dir, ignore_errors=True)
+ self.finished.emit(False, "Не удалось скачать ни одного изображения")
+ return
+
+ self.log.emit(f"Создание видео из {len(downloaded)} кадров...")
+ self.progress.emit(total, total, "Создание видео...")
+
+ video_path = VideoCreator.create_timelapse(downloaded, self.output_path, self.fps)
+
+ # Очищаем временную папку
+ shutil.rmtree(temp_dir, ignore_errors=True)
+ self.log.emit(f"Временные файлы удалены")
+
+ self.finished.emit(True, str(video_path))
+
+ except Exception as e:
+ self.log.emit(f"Ошибка: {str(e)}")
+ self.finished.emit(False, str(e))
+
+
+class TimelapseDialog(QDialog):
+ """Диалог для создания таймлапса"""
+
+ def __init__(self, controller, parent=None):
+ super().__init__(parent)
+ self.controller = controller
+ self.settings = QSettings("SolarViewer", "Helioviewer")
+ self.worker = None
+ self.init_ui()
+ self.load_settings()
+
+ def init_ui(self):
+ """Инициализация UI"""
+ self.setWindowTitle("Создание таймлапс-анимации")
+ self.setModal(True)
+ self.setMinimumWidth(500)
+
+ layout = QVBoxLayout(self)
+
+ # Группа выбора спектра
+ spectrum_group = QGroupBox("🌞 Спектральный канал")
+ spectrum_layout = QVBoxLayout(spectrum_group)
+
+ self.spectrum_combo = QComboBox()
+ self.populate_spectrums()
+ spectrum_layout.addWidget(self.spectrum_combo)
+
+ layout.addWidget(spectrum_group)
+
+ # Группа выбора дат
+ date_group = QGroupBox("📅 Период")
+ date_layout = QVBoxLayout(date_group)
+
+ # Начальная дата
+ start_layout = QHBoxLayout()
+ start_layout.addWidget(QLabel("Начало:"))
+ self.start_date = QDateEdit()
+ self.start_date.setDateTime(QDateTime.currentDateTime().addDays(-7))
+ self.start_date.setCalendarPopup(True)
+ start_layout.addWidget(self.start_date)
+ date_layout.addLayout(start_layout)
+
+ # Конечная дата
+ end_layout = QHBoxLayout()
+ end_layout.addWidget(QLabel("Конец:"))
+ self.end_date = QDateEdit()
+ self.end_date.setDateTime(QDateTime.currentDateTime())
+ self.end_date.setCalendarPopup(True)
+ end_layout.addWidget(self.end_date)
+ date_layout.addLayout(end_layout)
+
+ layout.addWidget(date_group)
+
+ # Параметры видео
+ video_group = QGroupBox("Параметры видео")
+ video_layout = QHBoxLayout(video_group)
+
+ video_layout.addWidget(QLabel("FPS:"))
+ self.fps_spin = QComboBox()
+ self.fps_spin.addItems(['5', '10', '15', '24', '30'])
+ self.fps_spin.setCurrentText('10')
+ video_layout.addWidget(self.fps_spin)
+
+ video_layout.addStretch()
+
+ layout.addWidget(video_group)
+
+ # Выбор папки сохранения
+ folder_group = QGroupBox("Папка сохранения")
+ folder_layout = QVBoxLayout(folder_group)
+
+ folder_select_layout = QHBoxLayout()
+ self.folder_path = QLabel("Не выбрана")
+ self.folder_path.setStyleSheet("color: gray;")
+ folder_select_layout.addWidget(self.folder_path, 1)
+
+ browse_button = QPushButton("Обзор...")
+ browse_button.clicked.connect(self.browse_folder)
+ folder_select_layout.addWidget(browse_button)
+
+ folder_layout.addLayout(folder_select_layout)
+
+ # Чекбокс "Запомнить путь"
+ self.remember_path_checkbox = QCheckBox("Запомнить этот путь")
+ self.remember_path_checkbox.setChecked(True)
+ folder_layout.addWidget(self.remember_path_checkbox)
+
+ layout.addWidget(folder_group)
+
+ # Прогресс бар
+ self.progress_bar = QProgressBar()
+ self.progress_bar.setVisible(False)
+ layout.addWidget(self.progress_bar)
+
+ # Лог сообщений
+ self.log_text = QTextEdit()
+ self.log_text.setMaximumHeight(150)
+ self.log_text.setReadOnly(True)
+ self.log_text.setVisible(False)
+ layout.addWidget(self.log_text)
+
+ # Кнопки
+ button_layout = QHBoxLayout()
+
+ self.create_button = QPushButton("Создать")
+ self.create_button.clicked.connect(self.start_timelapse)
+ button_layout.addWidget(self.create_button)
+
+ self.cancel_button = QPushButton("Отмена")
+ self.cancel_button.clicked.connect(self.cancel_timelapse)
+ self.cancel_button.setEnabled(False)
+ button_layout.addWidget(self.cancel_button)
+
+ close_button = QPushButton("Закрыть")
+ close_button.clicked.connect(self.reject)
+ button_layout.addWidget(close_button)
+
+ layout.addLayout(button_layout)
+
+ self.selected_folder = None
+
+ def populate_spectrums(self):
+ """Заполняет список доступных спектров"""
+ from models.api_model import HelioviewerAPI
+
+ sources = HelioviewerAPI.get_available_sources()
+ for source_id, info in sources.items():
+ display_text = f"{info['observatory']} - {info['name']} ({info['wavelength']})"
+ self.spectrum_combo.addItem(display_text, source_id)
+
+ def browse_folder(self):
+ """Выбор папки для сохранения"""
+ folder = QFileDialog.getExistingDirectory(self, "Выберите папку для сохранения")
+ if folder:
+ self.selected_folder = Path(folder)
+ self.folder_path.setText(str(self.selected_folder))
+ self.folder_path.setStyleSheet("color: green;")
+
+ if self.remember_path_checkbox.isChecked():
+ self.save_settings()
+
+ def save_settings(self):
+ """Сохраняет настройки"""
+ if self.selected_folder:
+ self.settings.setValue("timelapse/last_folder", str(self.selected_folder))
+ self.settings.setValue("timelapse/remember_path", self.remember_path_checkbox.isChecked())
+
+ def load_settings(self):
+ """Загружает настройки"""
+ remember = self.settings.value("timelapse/remember_path", True, type=bool)
+ self.remember_path_checkbox.setChecked(remember)
+
+ if remember:
+ last_folder = self.settings.value("timelapse/last_folder", "")
+ if last_folder and Path(last_folder).exists():
+ self.selected_folder = Path(last_folder)
+ self.folder_path.setText(str(self.selected_folder))
+ self.folder_path.setStyleSheet("color: green;")
+
+ def start_timelapse(self):
+ """Запускает создание таймлапса"""
+ if not self.selected_folder:
+ QMessageBox.warning(self, "Внимание", "Выберите папку для сохранения")
+ return
+
+ source_id = self.spectrum_combo.currentData()
+ start_date = self.start_date.date().toPython()
+ end_date = self.end_date.date().toPython()
+ fps = int(self.fps_spin.currentText())
+
+ from datetime import datetime as dt
+
+ # Конвертируем date в datetime с правильным временем
+ start_datetime = dt(start_date.year, start_date.month, start_date.day, 12, 0, 0)
+ end_datetime = dt(end_date.year, end_date.month, end_date.day, 23, 59, 59)
+
+ days = (end_date - start_date).days + 1
+ filename = f"timelapse_{source_id}_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}.mp4"
+ output_path = self.selected_folder / filename
+
+ self.save_settings()
+
+ # Обновляем UI
+ self.create_button.setEnabled(False)
+ self.cancel_button.setEnabled(True)
+ self.progress_bar.setVisible(True)
+ self.log_text.setVisible(True)
+ self.progress_bar.setValue(0)
+ self.log_text.clear()
+
+ self.add_log(f"🚀 Запуск таймлапса: {days} файлов")
+ self.add_log(f"📁 Папка: {self.selected_folder}")
+
+ # Создаем и запускаем поток
+ self.worker = TimelapseWorker(source_id, start_datetime, end_datetime, output_path, fps)
+ self.worker.progress.connect(self.update_progress)
+ self.worker.log.connect(self.add_log)
+ self.worker.finished.connect(self.on_finished)
+ self.worker.start()
+
+ def cancel_timelapse(self):
+ """Отмена создания таймлапса"""
+ if self.worker and self.worker.isRunning():
+ reply = QMessageBox.question(
+ self, "Отмена",
+ "Вы уверены? Все уже скачанные файлы будут удалены.",
+ QMessageBox.Yes | QMessageBox.No
+ )
+ if reply == QMessageBox.Yes:
+ self.worker.cancel()
+ self.add_log("⏹️ Отмена процесса...")
+ self.cancel_button.setEnabled(False)
+
+ def update_progress(self, current, total, message):
+ """Обновление прогресса"""
+ if total > 0:
+ percent = int((current / total) * 100)
+ self.progress_bar.setValue(percent)
+
+ def add_log(self, message):
+ """Добавление сообщения в лог"""
+ timestamp = datetime.now().strftime("%H:%M:%S")
+ self.log_text.append(f"[{timestamp}] {message}")
+ self.log_text.verticalScrollBar().setValue(
+ self.log_text.verticalScrollBar().maximum()
+ )
+
+ def on_finished(self, success, message):
+ """Обработка завершения"""
+ self.create_button.setEnabled(True)
+ self.cancel_button.setEnabled(False)
+
+ if success:
+ self.add_log(f"✅ ГОТОВО: {message}")
+ QMessageBox.information(self, "Готово", f"Таймлапс успешно создан!\n{message}")
+ self.accept()
+ else:
+ self.add_log(f"❌ Ошибка: {message}")
+ QMessageBox.critical(self, "Ошибка", message)
+
+ def closeEvent(self, event):
+ """При закрытии окна"""
+ if self.worker and self.worker.isRunning():
+ reply = QMessageBox.question(
+ self, "Процесс выполняется",
+ "Создание таймлапса еще не завершено.\n\nЗакрыть окно?",
+ QMessageBox.Yes | QMessageBox.No
+ )
+ if reply == QMessageBox.Yes:
+ self.worker.cancel()
+ event.accept()
+ else:
+ event.ignore()
+ else:
+ event.accept()
\ No newline at end of file