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 - - - - - - - - - - - - - - \ 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' 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