diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 3f847f6..0000000 Binary files a/.gitignore and /dev/null differ diff --git a/.idea/HelioParser.iml b/.idea/HelioParser.iml deleted file mode 100644 index 6414205..0000000 --- a/.idea/HelioParser.iml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 6388de0..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index e575720..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/controllers/__init__.py b/controllers/__init__.py deleted file mode 100644 index 55172a7..0000000 --- a/controllers/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# 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 deleted file mode 100644 index 86b9f07..0000000 Binary files a/controllers/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/controllers/__pycache__/app_controller.cpython-312.pyc b/controllers/__pycache__/app_controller.cpython-312.pyc deleted file mode 100644 index cf6f514..0000000 Binary files a/controllers/__pycache__/app_controller.cpython-312.pyc and /dev/null differ diff --git a/controllers/__pycache__/layer_controller.cpython-312.pyc b/controllers/__pycache__/layer_controller.cpython-312.pyc deleted file mode 100644 index 900c16c..0000000 Binary files a/controllers/__pycache__/layer_controller.cpython-312.pyc and /dev/null differ diff --git a/controllers/__pycache__/timelapse_controller.cpython-312.pyc b/controllers/__pycache__/timelapse_controller.cpython-312.pyc deleted file mode 100644 index 762bcca..0000000 Binary files a/controllers/__pycache__/timelapse_controller.cpython-312.pyc and /dev/null differ diff --git a/controllers/app_controller.py b/controllers/app_controller.py deleted file mode 100644 index e182ac3..0000000 --- a/controllers/app_controller.py +++ /dev/null @@ -1,191 +0,0 @@ -""" -Главный контроллер приложения - связывает модель и представление -""" - -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 deleted file mode 100644 index b39e920..0000000 --- a/controllers/layer_controller.py +++ /dev/null @@ -1,384 +0,0 @@ -""" -Контроллер для управления слоями изображений -Отвечает за бизнес-логику работы со слоями: композитинг, обработку, трансформации -""" - -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 deleted file mode 100644 index 3751a2c..0000000 --- a/controllers/timelapse_controller.py +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index 7089352..0000000 --- a/fix_imports.py +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index a337f04..0000000 --- a/info/nuitka compile.txt +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 2ff3134..0000000 Binary files a/main.exe and /dev/null differ diff --git a/main.py b/main.py deleted file mode 100644 index e81ba14..0000000 --- a/main.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/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 deleted file mode 100644 index c5784bf..0000000 --- a/models/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Модели для 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 deleted file mode 100644 index 63e5fcc..0000000 Binary files a/models/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/models/__pycache__/api_model.cpython-312.pyc b/models/__pycache__/api_model.cpython-312.pyc deleted file mode 100644 index dd4bc84..0000000 Binary files a/models/__pycache__/api_model.cpython-312.pyc and /dev/null differ diff --git a/models/__pycache__/image_model.cpython-312.pyc b/models/__pycache__/image_model.cpython-312.pyc deleted file mode 100644 index 5cf8ea7..0000000 Binary files a/models/__pycache__/image_model.cpython-312.pyc and /dev/null differ diff --git a/models/__pycache__/timelapse_model.cpython-312.pyc b/models/__pycache__/timelapse_model.cpython-312.pyc deleted file mode 100644 index a121dad..0000000 Binary files a/models/__pycache__/timelapse_model.cpython-312.pyc and /dev/null differ diff --git a/models/api_model.py b/models/api_model.py deleted file mode 100644 index 6300442..0000000 --- a/models/api_model.py +++ /dev/null @@ -1,122 +0,0 @@ -""" -Модель для работы с 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 deleted file mode 100644 index 350d91b..0000000 --- a/models/image_model.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -Модель для управления слоями изображений -""" - -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 deleted file mode 100644 index b350cd0..0000000 --- a/models/timelapse_model.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -Модель для управления процессом создания таймлапса -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 deleted file mode 100644 index fc8dd8c..0000000 --- a/utils/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# Утилиты для 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 deleted file mode 100644 index fa7c39e..0000000 Binary files a/utils/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/utils/__pycache__/image_processor.cpython-312.pyc b/utils/__pycache__/image_processor.cpython-312.pyc deleted file mode 100644 index 4299cb1..0000000 Binary files a/utils/__pycache__/image_processor.cpython-312.pyc and /dev/null differ diff --git a/utils/__pycache__/metadata_parser.cpython-312.pyc b/utils/__pycache__/metadata_parser.cpython-312.pyc deleted file mode 100644 index 20f43a3..0000000 Binary files a/utils/__pycache__/metadata_parser.cpython-312.pyc and /dev/null differ diff --git a/utils/__pycache__/video_creator.cpython-312.pyc b/utils/__pycache__/video_creator.cpython-312.pyc deleted file mode 100644 index cc1f960..0000000 Binary files a/utils/__pycache__/video_creator.cpython-312.pyc and /dev/null differ diff --git a/utils/image_processor.py b/utils/image_processor.py deleted file mode 100644 index cf8175a..0000000 --- a/utils/image_processor.py +++ /dev/null @@ -1,267 +0,0 @@ -""" -Утилиты для обработки изображений: нормализация, преобразование цветов и т.д. -""" - -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 deleted file mode 100644 index 94d16b1..0000000 --- a/utils/metadata_parser.py +++ /dev/null @@ -1,225 +0,0 @@ -""" -Парсер для извлечения 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 deleted file mode 100644 index ee654ec..0000000 --- a/utils/video_creator.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -Утилиты для создания видео из последовательности изображений -""" - -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 deleted file mode 100644 index a188654..0000000 --- a/views/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100644 index 1d6ab1d..0000000 Binary files a/views/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/views/__pycache__/canvas_widget.cpython-312.pyc b/views/__pycache__/canvas_widget.cpython-312.pyc deleted file mode 100644 index dd26070..0000000 Binary files a/views/__pycache__/canvas_widget.cpython-312.pyc and /dev/null differ diff --git a/views/__pycache__/control_panel.cpython-312.pyc b/views/__pycache__/control_panel.cpython-312.pyc deleted file mode 100644 index 3a870ab..0000000 Binary files a/views/__pycache__/control_panel.cpython-312.pyc and /dev/null differ diff --git a/views/__pycache__/layer_widget.cpython-312.pyc b/views/__pycache__/layer_widget.cpython-312.pyc deleted file mode 100644 index 4e8af6c..0000000 Binary files a/views/__pycache__/layer_widget.cpython-312.pyc and /dev/null differ diff --git a/views/__pycache__/main_window.cpython-312.pyc b/views/__pycache__/main_window.cpython-312.pyc deleted file mode 100644 index 1f4ebfa..0000000 Binary files a/views/__pycache__/main_window.cpython-312.pyc and /dev/null differ diff --git a/views/__pycache__/metadata_viewer.cpython-312.pyc b/views/__pycache__/metadata_viewer.cpython-312.pyc deleted file mode 100644 index 611b697..0000000 Binary files a/views/__pycache__/metadata_viewer.cpython-312.pyc and /dev/null differ diff --git a/views/__pycache__/timelapse_dialog.cpython-312.pyc b/views/__pycache__/timelapse_dialog.cpython-312.pyc deleted file mode 100644 index 0182548..0000000 Binary files a/views/__pycache__/timelapse_dialog.cpython-312.pyc and /dev/null differ diff --git a/views/canvas_widget.py b/views/canvas_widget.py deleted file mode 100644 index a15549d..0000000 --- a/views/canvas_widget.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -Виджет для отображения и манипуляции солнечными изображениями -Поддерживает: зум, панорамирование, композитинг слоев -""" - -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 deleted file mode 100644 index 91f797f..0000000 --- a/views/control_panel.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -Панель управления - выбор даты, спектра, загрузка изображений -""" - -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 deleted file mode 100644 index e0d08f2..0000000 --- a/views/layer_widget.py +++ /dev/null @@ -1,219 +0,0 @@ -""" -Виджет для управления слоями изображений -""" - -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 deleted file mode 100644 index e835d19..0000000 --- a/views/main_window.py +++ /dev/null @@ -1,251 +0,0 @@ -""" -Главное окно приложения - содержит меню, панели и 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 deleted file mode 100644 index 1e6bfed..0000000 --- a/views/metadata_viewer.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Виджет для отображения 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 deleted file mode 100644 index 4850e18..0000000 --- a/views/timelapse_dialog.py +++ /dev/null @@ -1,370 +0,0 @@ -""" -Диалог для создания таймлапс-анимации -""" - -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