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'':
- depth -= 1
- elif data[pos:pos+1] == b'<':
- depth += 1
-
- if pos < len(data):
- xml_data = data[xml_start:pos+1].decode('utf-8', errors='ignore')
- return MetadataParser._parse_fits_xml(xml_data)
-
- # Если не нашли XML, пытаемся извлечь из текстовых блоков
- # Ищем ASCII текст
- text = data.decode('utf-8', errors='ignore')
-
- # Ищем FITS-подобные ключи
- metadata = {}
- fits_keys = ['TELESCOP', 'INSTRUME', 'WAVELNTH', 'DATE-OBS', 'EXPTIME',
- 'CRPIX1', 'CRPIX2', 'CDELT1', 'CDELT2', 'NAXIS1', 'NAXIS2']
-
- for key in fits_keys:
- # Ищем ключ в тексте
- pattern = f'{key}='
- start = text.find(pattern)
- if start != -1:
- # Находим значение
- value_start = start + len(pattern)
- # Ищем конец строки или следующий ключ
- value_end = text.find('/', value_start)
- if value_end == -1:
- value_end = text.find('\n', value_start)
- if value_end == -1:
- value_end = text.find(' ', value_start + 20)
-
- value = text[value_start:value_end].strip().strip("'\"")
- if value:
- metadata[key] = value
-
- if metadata:
- return MetadataParser._format_metadata(metadata)
-
- return None
-
- except Exception as e:
- print(f"Ошибка извлечения метаданных: {e}")
- return None
-
- @staticmethod
- def _parse_fits_xml(xml_content: str) -> Dict[str, str]:
- """
- Парсит XML с FITS-заголовком
-
- Args:
- xml_content: XML строка
-
- Returns:
- Словарь с параметрами FITS
- """
- metadata = {}
-
- try:
- root = ET.fromstring(xml_content)
-
- # Ищем секцию fits или FITS
- fits_section = root.find('.//fits') or root.find('.//FITS')
-
- if fits_section is not None:
- for child in fits_section:
- # Извлекаем ключ и значение
- key = child.tag
- value = child.text if child.text else ""
-
- # Убираем namespace если есть
- if '}' in key:
- key = key.split('}')[-1]
-
- metadata[key.upper()] = value.strip()
-
- # Если не нашли fits секцию, ищем другие возможные места
- if not metadata:
- for child in root.iter():
- if child.tag.endswith('keyword'):
- key = child.get('name', '')
- value = child.text if child.text else ''
- if key:
- metadata[key.upper()] = value.strip()
-
- # Форматируем некоторые ключи для удобства чтения
- metadata = MetadataParser._format_metadata(metadata)
-
- except Exception as e:
- print(f"Ошибка парсинга XML: {e}")
-
- return metadata
-
- @staticmethod
- def _format_metadata(metadata: Dict[str, str]) -> Dict[str, str]:
- """
- Форматирует метаданные для удобного отображения
-
- Args:
- metadata: Сырые метаданные
-
- Returns:
- Отформатированные метаданные
- """
- formatted = {}
-
- # Переименовываем некоторые ключи для понятности
- key_mapping = {
- 'TELESCOP': 'Телескоп',
- 'INSTRUME': 'Инструмент',
- 'DETECTOR': 'Детектор',
- 'WAVELNTH': 'Длина волны',
- 'WAVEUNIT': 'Единица длины волны',
- 'DATE-OBS': 'Дата наблюдения',
- 'DATE-BEG': 'Начало экспозиции',
- 'DATE-END': 'Конец экспозиции',
- 'EXPTIME': 'Время экспозиции (сек)',
- 'CRPIX1': 'Центр X (пикс)',
- 'CRPIX2': 'Центр Y (пикс)',
- 'CDELT1': 'Шаг пикселя X (arcsec)',
- 'CDELT2': 'Шаг пикселя Y (arcsec)',
- 'CROTA2': 'Угол поворота (град)',
- 'NAXIS1': 'Ширина (пикс)',
- 'NAXIS2': 'Высота (пикс)',
- 'BITPIX': 'Бит на пиксель',
- 'BZERO': 'Смещение данных',
- 'BSCALE': 'Масштаб данных',
- 'IMG_TYPE': 'Тип изображения',
- 'QUALITY': 'Качество',
- 'LEVEL': 'Уровень обработки',
- 'STATUS': 'Статус',
- 'OBSRVTRY': 'Обсерватория'
- }
-
- for key, value in metadata.items():
- # Используем переименованный ключ или оригинал
- display_key = key_mapping.get(key, key)
- formatted[display_key] = value
-
- return formatted
-
- @staticmethod
- def extract_from_jp2_box(filepath: str) -> Optional[Dict]:
- """
- Альтернативный метод: извлечение метаданных через чтение JP2 боксов
- Не требует glymur, читает файл напрямую
- """
- try:
- with open(filepath, 'rb') as f:
- data = f.read()
-
- # JP2 signature
- if data[0:4] != b'\x00\x00\x00\x0c':
- return None
-
- # Ищем XML бокс (box type 'xml ')
- offset = 0
- while offset < len(data) - 8:
- box_len = struct.unpack('>I', data[offset:offset+4])[0]
- box_type = data[offset+4:offset+8]
-
- if box_type == b'xml ' or box_type == b'XML ':
- # Нашли XML бокс
- xml_data = data[offset+8:offset+box_len]
- try:
- xml_str = xml_data.decode('utf-8', errors='ignore')
- return MetadataParser._parse_fits_xml(xml_str)
- except:
- pass
-
- if box_len == 0:
- break
- offset += box_len
-
- return None
-
- except Exception as e:
- print(f"Ошибка чтения JP2 боксов: {e}")
- return None
\ No newline at end of file
diff --git a/utils/video_creator.py b/utils/video_creator.py
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