Merge dev-ui into main - resolve workspace.xml conflict

This commit is contained in:
Vic Sergeev 2026-06-12 09:34:19 +03:00
commit 968a41a6cd
49 changed files with 3244 additions and 90 deletions

BIN
.gitignore vendored Normal file

Binary file not shown.

10
.idea/HelioParser.iml generated Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (HelioParser)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View file

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml generated Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.12 (HelioParser)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (HelioParser)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/HelioParser.iml" filepath="$PROJECT_DIR$/.idea/HelioParser.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

90
.idea/workspace.xml generated
View file

@ -1,90 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ChangeListManager">
<list default="true" id="01167edf-a4fe-4b9b-bd49-22d768ae9f8b" name="Changes" comment="working" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="Python Script" />
</list>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 1
}</component>
<component name="ProjectId" id="3EwIJwKPDl7xmBDbQQN0GJ515Pu" />
<component name="ProjectLevelVcsManager">
<ConfirmationsSetting value="2" id="Add" />
</component>
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"Python.fix_imports.executor": "Run",
"Python.main.executor": "Run",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"ai.playground.ignore.import.keys.banner.in.settings": "true",
"git-widget-placeholder": "main",
"ignore.virus.scanning.warn.message": "true",
"nodejs_package_manager_path": "npm",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-PY-253.30387.173" />
<option value="bundled-python-sdk-4762d8aabb82-6d6dccd035ac-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-253.30387.173" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="01167edf-a4fe-4b9b-bd49-22d768ae9f8b" name="Changes" comment="" />
<created>1781083136733</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1781083136733</updated>
<workItem from="1781083137783" duration="433000" />
<workItem from="1781093057561" duration="1654000" />
<workItem from="1781095333974" duration="8869000" />
<workItem from="1781124166531" duration="1347000" />
<workItem from="1781244661466" duration="1274000" />
</task>
<task id="LOCAL-00001" summary="working">
<option name="closed" value="true" />
<created>1781245747321</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1781245747321</updated>
</task>
<option name="localTasksCounter" value="2" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="working" />
<option name="LAST_COMMIT_MESSAGE" value="working" />
</component>
<component name="com.intellij.coverage.CoverageDataManagerImpl">
<SUITE FILE_PATH="coverage/HelioParser$main.coverage" NAME="main Coverage Results" MODIFIED="1781124407225" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
<SUITE FILE_PATH="coverage/HelioParser$fix_imports.coverage" NAME="fix_imports Coverage Results" MODIFIED="1781096214913" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
</component>
</project>

10
controllers/__init__.py Normal file
View file

@ -0,0 +1,10 @@
# controllers/__init__.py
from controllers.app_controller import AppController
from controllers.layer_controller import LayerController
from controllers.timelapse_controller import TimelapseController
__all__ = [
'AppController',
'LayerController',
'TimelapseController'
]

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,191 @@
"""
Главный контроллер приложения - связывает модель и представление
"""
from PySide6.QtCore import QObject, Signal
from datetime import datetime
from pathlib import Path
import numpy as np
from models.api_model import HelioviewerAPI
from models.image_model import ImageModel
from views.main_window import MainWindow
from utils.image_processor import ImageProcessor
from utils.metadata_parser import MetadataParser
class AppController(QObject):
"""Главный контроллер приложения"""
def __init__(self):
super().__init__()
self.image_model = ImageModel()
self.api = HelioviewerAPI()
self.main_window = None
self.timelapse_controller = None
# Подключаем сигналы модели
self.image_model.layer_added.connect(self.on_layer_added)
self.image_model.layer_removed.connect(self.on_layer_removed)
self.image_model.layer_visibility_changed.connect(self.on_layer_visibility_changed)
self.image_model.layer_opacity_changed.connect(self.on_layer_opacity_changed)
self.image_model.layer_selected.connect(self.on_layer_selected)
# Папка для сохранения по умолчанию
self.download_folder = Path.home() / "SolarImages"
self.download_folder.mkdir(exist_ok=True)
def show_main_window(self):
"""Показывает главное окно"""
self.main_window = MainWindow(self)
self.main_window.show()
# Подключаем сигналы от панели управления
control_panel = self.main_window.get_control_panel()
control_panel.load_image_requested.connect(self.load_image_from_api)
def load_image_from_api(self, source_id: int, date: datetime):
"""Загружает изображение из API"""
self.main_window.update_status(f"Загрузка изображения: {date.strftime('%Y-%m-%d %H:%M')} UTC")
# Скачиваем изображение
filepath = self.api.download_image(source_id, date, self.download_folder)
if filepath:
# Загружаем данные изображения
img_data = ImageProcessor.load_jp2(str(filepath))
if img_data is not None:
# Извлекаем метаданные
metadata = MetadataParser.extract_metadata(str(filepath))
# Получаем информацию о спектре
source_info = self.api.SOURCES.get(source_id, {})
wavelength = source_info.get("wavelength", "Unknown")
# Добавляем слой в модель
layer_id = self.image_model.add_layer(
filepath, source_id, date, wavelength, img_data, metadata
)
self.main_window.update_status(f"✓ Загружено: {filepath.name}")
else:
self.main_window.update_status("✗ Ошибка обработки изображения")
else:
self.main_window.update_status("✗ Ошибка загрузки изображения")
def on_layer_added(self, layer):
"""Обработчик добавления слоя"""
self.main_window.update_layer_list(self.image_model.get_all_layers())
self.main_window.canvas.set_image(layer.id, layer.image_data)
def on_layer_removed(self, layer_id):
"""Обработчик удаления слоя"""
self.main_window.layer_widget.remove_layer(layer_id)
self.main_window.canvas.remove_layer(layer_id)
if not self.image_model.get_all_layers():
self.main_window.metadata_viewer.clear()
def on_layer_visibility_changed(self, layer_id, visible):
"""Обработчик изменения видимости слоя"""
self.main_window.canvas.set_layer_visibility(layer_id, visible)
def on_layer_opacity_changed(self, layer_id, opacity):
"""Обработчик изменения прозрачности слоя"""
self.main_window.canvas.set_layer_opacity(layer_id, opacity)
def on_layer_selected(self, layer_id):
"""Обработчик выбора слоя"""
for layer in self.image_model.get_all_layers():
if layer.id == layer_id:
if layer.metadata:
self.main_window.metadata_viewer.display_metadata(layer.metadata)
break
def set_layer_visibility(self, layer_id, visible):
self.image_model.set_layer_visibility(layer_id, visible)
def set_layer_opacity(self, layer_id, opacity):
self.image_model.set_layer_opacity(layer_id, opacity)
def clear_all_layers(self):
self.image_model.clear()
self.main_window.canvas.clear_all_layers()
self.main_window.metadata_viewer.clear()
# Добавьте эти методы в класс AppController:
def create_timelapse(self, source_id: int, start_date: datetime,
end_date: datetime, output_path: Path,
fps: int = 10, output_format: str = "mp4"):
"""Создает таймлапс"""
from controllers.timelapse_controller import TimelapseController
self.timelapse_controller = TimelapseController()
# Подключаем сигналы
self.timelapse_controller.progress_updated.connect(self.on_timelapse_progress)
self.timelapse_controller.log_message.connect(self.on_timelapse_log)
self.timelapse_controller.finished.connect(self.on_timelapse_finished)
# Запускаем
self.timelapse_controller.create_timelapse(
source_id, start_date, end_date, output_path, fps, output_format
)
def cancel_timelapse(self):
"""Отменяет создание таймлапса"""
if hasattr(self, 'timelapse_controller'):
self.timelapse_controller.cancel()
def on_timelapse_progress(self, current, total, message):
"""Прогресс таймлапса"""
if self.main_window:
self.main_window.update_status(f"Таймлапс: {message}")
def on_timelapse_log(self, message):
"""Лог таймлапса"""
print(f"[Timelapse] {message}")
def on_timelapse_finished(self, success, message):
"""Завершение таймлапса"""
if self.main_window:
if success:
self.main_window.update_status(f"✅ Таймлапс создан")
from PySide6.QtWidgets import QMessageBox
QMessageBox.information(self.main_window, "Готово", f"Таймлапс сохранен:\n{message}")
else:
self.main_window.update_status(f"❌ Ошибка: {message}")
# Добавьте метод:
def create_timelapse(self, source_id, start_date, end_date, output_path, fps=10, output_format="mp4"):
"""Создает таймлапс"""
print(f"[DEBUG] AppController.create_timelapse вызван")
from controllers.timelapse_controller import TimelapseController
self.timelapse_controller = TimelapseController()
# Подключаем сигналы
self.timelapse_controller.progress_updated.connect(self.on_timelapse_progress)
self.timelapse_controller.log_message.connect(self.on_timelapse_log)
self.timelapse_controller.finished.connect(self.on_timelapse_finished)
# Запускаем
self.timelapse_controller.create_timelapse(
source_id, start_date, end_date, output_path, fps, output_format
)
print(f"[DEBUG] TimelapseController создан и запущен")
def on_timelapse_log(self, message):
"""Лог таймлапса"""
print(f"[TIMELAPSE] {message}")
if self.main_window:
self.main_window.update_status(f"Таймлапс: {message}")
def create_timelapse(self, source_id, start_date, end_date, output_path, fps=10, output_format="mp4"):
"""Создает таймлапс - заглушка, реальный вызов из диалога"""
# Реальная реализация в диалоге
pass
def cancel_timelapse(self):
pass

View file

@ -0,0 +1,384 @@
"""
Контроллер для управления слоями изображений
Отвечает за бизнес-логику работы со слоями: композитинг, обработку, трансформации
"""
from PySide6.QtCore import QObject, Signal, Qt
from typing import List, Dict, Optional
from datetime import datetime # ← ДОБАВИТЬ ЭТУ СТРОКУ
import numpy as np
from pathlib import Path
from models.image_model import ImageModel, ImageLayer
from utils.image_processor import ImageProcessor
from utils.metadata_parser import MetadataParser
class LayerController(QObject):
"""
Контроллер для управления слоями изображений
Single Responsibility: только логика работы со слоями
"""
# Сигналы для оповещения View
layer_loaded = Signal(object) # ImageLayer
layer_updated = Signal(int) # layer_id
composite_updated = Signal(np.ndarray) # скомпозированное изображение
processing_started = Signal(str) # сообщение
processing_finished = Signal(bool) # успех/неудача
def __init__(self, image_model: ImageModel):
super().__init__()
self.image_model = image_model
self._processing_thread = None
# Подключаемся к сигналам модели
self.image_model.layer_added.connect(self.on_layer_added)
self.image_model.layer_removed.connect(self.on_layer_removed)
self.image_model.layer_visibility_changed.connect(self.on_visibility_changed)
self.image_model.layer_opacity_changed.connect(self.on_opacity_changed)
def load_layer_from_file(self, filepath: Path, source_id: int = None,
wavelength: str = None) -> Optional[int]:
"""
Загружает слой из файла
Args:
filepath: Путь к JP2 файлу
source_id: ID источника (опционально)
wavelength: Длина волны (опционально)
Returns:
ID созданного слоя или None
"""
self.processing_started.emit(f"Загрузка: {filepath.name}")
try:
# Загружаем изображение
img_data = ImageProcessor.load_jp2(str(filepath))
if img_data is None:
self.processing_finished.emit(False)
return None
# Извлекаем метаданные
metadata = MetadataParser.extract_metadata(str(filepath))
# Извлекаем информацию из метаданных если не передана
if source_id is None and metadata:
# Пытаемся определить инструмент из метаданных
instrument = metadata.get('INSTRUME', '')
wavelength_val = metadata.get('WAVELNTH', '')
# Можно добавить логику определения source_id по инструменту
source_id = self._guess_source_id(instrument, wavelength_val)
if wavelength is None and metadata:
wavelength = metadata.get('WAVELNTH', 'Unknown')
# Получаем дату из метаданных или из имени файла
date = self._extract_date_from_metadata(metadata)
if date is None:
date = datetime.now()
# Добавляем слой в модель
layer_id = self.image_model.add_layer(
filepath, source_id or 0, date, wavelength or "Unknown",
img_data, metadata
)
self.processing_finished.emit(True)
return layer_id
except Exception as e:
print(f"Ошибка загрузки слоя: {e}")
self.processing_finished.emit(False)
return None
def apply_color_map(self, layer_id: int, color_map: str):
"""
Применяет цветовую карту к слою
Args:
layer_id: ID слоя
color_map: Название цветовой карты
"""
layer = self._get_layer_by_id(layer_id)
if layer and layer.image_data is not None:
# Применяем цветовую карту к черно-белому изображению
if len(layer.image_data.shape) == 2 or layer.image_data.shape[2] == 1:
colored = ImageProcessor.apply_color_map(
layer.image_data if len(layer.image_data.shape) == 2 else layer.image_data[:, :, 0],
color_map
)
layer.image_data = colored
self.layer_updated.emit(layer_id)
self._update_composite()
def adjust_brightness_contrast(self, layer_id: int, brightness: float = 0,
contrast: float = 1.0):
"""
Регулирует яркость и контраст слоя
Args:
layer_id: ID слоя
brightness: Смещение яркости (-127 до 127)
contrast: Коэффициент контраста (0.5 до 2.0)
"""
layer = self._get_layer_by_id(layer_id)
if layer and layer.image_data is not None:
img = layer.image_data.astype(float)
# Применяем контраст и яркость
img = img * contrast + brightness
# Клиппируем и конвертируем обратно в uint8
img = np.clip(img, 0, 255).astype(np.uint8)
layer.image_data = img
self.layer_updated.emit(layer_id)
self._update_composite()
def auto_adjust_levels(self, layer_id: int, low_percent: float = 0.5,
high_percent: float = 99.5):
"""
Автоматическая настройка уровней (перцентильная нормализация)
Args:
layer_id: ID слоя
low_percent: Нижний процент отсечения
high_percent: Верхний процент отсечения
"""
layer = self._get_layer_by_id(layer_id)
if layer and layer.image_data is not None:
# Для черно-белых изображений
if len(layer.image_data.shape) == 2:
img = layer.image_data
normalized = ImageProcessor.percent_normalize(img, low_percent, high_percent)
layer.image_data = normalized
else:
# Для цветных - обрабатываем каждый канал отдельно
for c in range(layer.image_data.shape[2]):
channel = layer.image_data[:, :, c]
normalized = ImageProcessor.percent_normalize(channel, low_percent, high_percent)
layer.image_data[:, :, c] = normalized
self.layer_updated.emit(layer_id)
self._update_composite()
def merge_visible_layers(self) -> Optional[np.ndarray]:
"""
Объединяет все видимые слои в одно изображение
Returns:
Скомпозированное изображение или None
"""
visible_layers = self.image_model.get_visible_layers()
if not visible_layers:
return None
# Собираем данные и прозрачности
layers_data = []
opacities = []
for layer in visible_layers:
if layer.image_data is not None:
layers_data.append(layer.image_data)
opacities.append(layer.opacity)
if layers_data:
# Приводим все слои к одному размеру (берем минимальный)
min_height = min(img.shape[0] for img in layers_data)
min_width = min(img.shape[1] for img in layers_data)
# Обрезаем или ресайзим слои до минимального размера
resized_layers = []
for img in layers_data:
if img.shape[0] != min_height or img.shape[1] != min_width:
from PIL import Image
pil_img = Image.fromarray(img)
pil_img = pil_img.resize((min_width, min_height), Image.Resampling.LANCZOS)
resized_layers.append(np.array(pil_img))
else:
resized_layers.append(img)
# Композитим слои
composite = ImageProcessor.composite_layers(resized_layers, opacities)
self.composite_updated.emit(composite)
return composite
return None
def export_composite(self, filepath: Path, format: str = "PNG"):
"""
Экспортирует скомпозированное изображение в файл
Args:
filepath: Путь для сохранения
format: Формат (PNG, JPEG, TIFF)
"""
composite = self.merge_visible_layers()
if composite is not None:
from PIL import Image
pil_img = Image.fromarray(composite)
pil_img.save(filepath, format=format)
return True
return False
def align_layers(self, reference_layer_id: int = None):
"""
Выравнивает все слои относительно опорного слоя
Использует фазовую корреляцию для сдвига изображений
Args:
reference_layer_id: ID опорного слоя (если None - используется первый видимый)
"""
visible_layers = self.image_model.get_visible_layers()
if len(visible_layers) < 2:
return
# Определяем опорный слой
if reference_layer_id:
reference = self._get_layer_by_id(reference_layer_id)
else:
reference = visible_layers[0]
if not reference or reference.image_data is None:
return
self.processing_started.emit("Выравнивание слоев...")
# Для каждого слоя вычисляем сдвиг относительно опорного
for layer in visible_layers:
if layer.id == reference.id or layer.image_data is None:
continue
# Вычисляем сдвиг с помощью фазовой корреляции
shift = self._calculate_shift(reference.image_data, layer.image_data)
if shift != (0, 0):
# Сдвигаем изображение
layer.image_data = self._shift_image(layer.image_data, shift)
self.layer_updated.emit(layer.id)
self.processing_finished.emit(True)
self._update_composite()
def _calculate_shift(self, ref_img: np.ndarray, target_img: np.ndarray) -> tuple:
"""
Вычисляет сдвиг между двумя изображениями с помощью фазовой корреляции
Args:
ref_img: Опорное изображение
target_img: Целевое изображение
Returns:
Сдвиг в пикселях (dy, dx)
"""
# Преобразуем в grayscale если цветные
if len(ref_img.shape) == 3:
ref_gray = np.mean(ref_img, axis=2)
else:
ref_gray = ref_img
if len(target_img.shape) == 3:
target_gray = np.mean(target_img, axis=2)
else:
target_gray = target_img
# Вычисляем кросс-корреляцию через FFT
from scipy import signal
correlation = signal.correlate2d(ref_gray, target_gray, mode='same')
# Находим пик корреляции
y, x = np.unravel_index(np.argmax(correlation), correlation.shape)
# Центр изображения
center_y, center_x = np.array(ref_gray.shape) // 2
# Сдвиг
shift_y = center_y - y
shift_x = center_x - x
return (shift_y, shift_x)
def _shift_image(self, img: np.ndarray, shift: tuple) -> np.ndarray:
"""
Сдвигает изображение на заданное количество пикселей
Args:
img: Исходное изображение
shift: Сдвиг (dy, dx)
Returns:
Сдвинутое изображение
"""
from scipy.ndimage import shift as shift_image
shifted = shift_image(img, shift, mode='constant', cval=0)
return shifted.astype(img.dtype)
def _get_layer_by_id(self, layer_id: int) -> Optional[ImageLayer]:
"""Возвращает слой по ID"""
for layer in self.image_model.get_all_layers():
if layer.id == layer_id:
return layer
return None
def _guess_source_id(self, instrument: str, wavelength: str) -> int:
"""Пытается определить source_id по инструменту и длине волны"""
from models.api_model import HelioviewerAPI
instrument = instrument.upper()
wavelength = wavelength.upper()
for source_id, info in HelioviewerAPI.SOURCES.items():
if info['instrument'].upper() in instrument:
if wavelength and info['wavelength'].upper() in wavelength:
return source_id
return 0
def _extract_date_from_metadata(self, metadata: Dict) -> Optional[datetime]:
"""Извлекает дату из метаданных"""
date_str = metadata.get('DATE-OBS') or metadata.get('DATE-BEG')
if date_str:
try:
# Пробуем разные форматы дат
formats = [
"%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d"
]
for fmt in formats:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
except:
pass
return None
def _update_composite(self):
"""Обновляет композитное изображение"""
composite = self.merge_visible_layers()
if composite is not None:
self.composite_updated.emit(composite)
def on_layer_added(self, layer: ImageLayer):
"""Обработчик добавления слоя"""
self._update_composite()
def on_layer_removed(self, layer_id: int):
"""Обработчик удаления слоя"""
self._update_composite()
def on_visibility_changed(self, layer_id: int, visible: bool):
"""Обработчик изменения видимости"""
self._update_composite()
def on_opacity_changed(self, layer_id: int, opacity: float):
"""Обработчик изменения прозрачности"""
self._update_composite()

View file

@ -0,0 +1,117 @@
from PySide6.QtCore import QObject, QThread, Signal
from datetime import datetime, timedelta
from pathlib import Path
import shutil
import tempfile
from models.api_model import HelioviewerAPI
from utils.video_creator import VideoCreator
class TimelapseWorker(QThread):
log = Signal(str)
progress = Signal(int, int)
finished = Signal(bool, str)
def __init__(self, source_id, start_date, end_date, output_path, fps=10):
super().__init__()
self.source_id = source_id
self.start_date = start_date
self.end_date = end_date
self.output_path = output_path
self.fps = fps
self.cancelled = False
self.temp_dir = None
def cancel(self):
self.cancelled = True
self.log.emit("⏹️ Отмена процесса...")
def run(self):
try:
# Создаем временную папку
self.temp_dir = Path(tempfile.mkdtemp(prefix="timelapse_"))
self.log.emit(f"📁 Временная папка: {self.temp_dir}")
# Генерируем даты
dates = []
current = self.start_date.replace(hour=12, minute=0, second=0)
while current <= self.end_date:
dates.append(current)
current += timedelta(days=1)
total = len(dates)
self.log.emit(f"📊 Всего файлов: {total}")
downloaded = []
# Скачиваем
for i, date in enumerate(dates):
if self.cancelled:
self.cleanup()
self.finished.emit(False, "Отменено")
return
percent = int((i + 1) / total * 100)
self.progress.emit(i + 1, total)
self.log.emit(f"📥 [{i + 1}/{total}] {percent}% - {date.strftime('%Y-%m-%d')}")
filepath = HelioviewerAPI.download_image(self.source_id, date, self.temp_dir)
if filepath:
downloaded.append(filepath)
self.log.emit(f"✅ [{i + 1}/{total}] Успешно")
else:
self.log.emit(f"❌ [{i + 1}/{total}] Ошибка")
if self.cancelled:
self.cleanup()
self.finished.emit(False, "Отменено")
return
if not downloaded:
self.cleanup()
self.finished.emit(False, "Нет файлов")
return
# Создаем видео
self.log.emit("🎬 Создание видео...")
self.progress.emit(total, total)
video_path = VideoCreator.create_timelapse(downloaded, self.output_path, self.fps)
# Очистка
self.cleanup()
if video_path and video_path.exists():
self.finished.emit(True, str(video_path))
else:
self.finished.emit(False, "Ошибка создания видео")
except Exception as e:
self.cleanup()
self.finished.emit(False, str(e))
def cleanup(self):
if self.temp_dir and self.temp_dir.exists():
shutil.rmtree(self.temp_dir)
self.log.emit("🗑️ Временные файлы удалены")
class TimelapseController(QObject):
def __init__(self):
super().__init__()
self.worker = None
self.log = Signal(str)
self.progress = Signal(int, int)
self.finished = Signal(bool, str)
def create(self, source_id, start_date, end_date, output_path, fps=10):
self.worker = TimelapseWorker(source_id, start_date, end_date, output_path, fps)
self.worker.log.connect(self.log)
self.worker.progress.connect(self.progress)
self.worker.finished.connect(self.finished)
self.worker.start()
return self.worker
def cancel(self):
if self.worker:
self.worker.cancel()

64
fix_imports.py Normal file
View file

@ -0,0 +1,64 @@
import os
import re
def fix_pyside6_syntax(filepath):
"""Исправляет устаревший синтаксис PySide6"""
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Замены
replacements = [
(r'Qt\.Horizontal', 'Qt.Orientation.Horizontal'),
(r'Qt\.Vertical', 'Qt.Orientation.Vertical'),
(r'Qt\.LeftButton', 'Qt.MouseButton.LeftButton'),
(r'Qt\.RightButton', 'Qt.MouseButton.RightButton'),
(r'Qt\.MiddleButton', 'Qt.MouseButton.MiddleButton'),
(r'self\.RenderHint\.', 'QPainter.RenderHint.'),
(r'Qt\.KeepAspectRatio', 'Qt.AspectRatioMode.KeepAspectRatio'),
(r'Qt\.IgnoreAspectRatio', 'Qt.AspectRatioMode.IgnoreAspectRatio'),
(r'Qt\.ScrollBarAsNeeded', 'Qt.ScrollBarPolicy.ScrollBarAsNeeded'),
(r'Qt\.ScrollBarAlwaysOff', 'Qt.ScrollBarPolicy.ScrollBarAlwaysOff'),
(r'Qt\.ScrollBarAlwaysOn', 'Qt.ScrollBarPolicy.ScrollBarAlwaysOn'),
(r'Qt\.black', 'Qt.GlobalColor.black'),
(r'Qt\.white', 'Qt.GlobalColor.white'),
(r'Qt\.red', 'Qt.GlobalColor.red'),
(r'Qt\.green', 'Qt.GlobalColor.green'),
(r'Qt\.blue', 'Qt.GlobalColor.blue'),
(r'Qt\.yellow', 'Qt.GlobalColor.yellow'),
(r'Qt\.gray', 'Qt.GlobalColor.gray'),
(r'Qt\.darkGray', 'Qt.GlobalColor.darkGray'),
(r'Qt\.lightGray', 'Qt.GlobalColor.lightGray'),
(r'Qt\.transparent', 'Qt.GlobalColor.transparent'),
]
for old, new in replacements:
content = re.sub(old, new, content)
# Добавляем импорт QPainter если нужно
if 'QPainter' not in content and any('RenderHint' in content for _ in []):
if 'from PySide6.QtGui import' in content:
content = content.replace(
'from PySide6.QtGui import',
'from PySide6.QtGui import QPainter, '
)
else:
content = 'from PySide6.QtGui import QPainter\n' + content
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
print(f"Fixed: {filepath}")
# Проходим по всем файлам
for root, dirs, files in os.walk('.'):
for file in files:
if file.endswith('.py'):
filepath = os.path.join(root, file)
try:
fix_pyside6_syntax(filepath)
except Exception as e:
print(f"Error fixing {filepath}: {e}")
print("Done!")

44
info/nuitka compile.txt Normal file
View file

@ -0,0 +1,44 @@
pip install nuitka
python -m nuitka --standalone --onefile --enable-plugin=pyside6 --windows-console-mode=disable --windows-icon-from-ico=sun.ico main.py
Что дают метаданные FITS?
Метаданные FITS (Flexible Image Transport System) — это стандарт в астрономии. Они дают вам:
Точную научную информацию:
Точное время съемки с микросекундной точностью
Длину волны и спектральный диапазон
Температуру плазмы, которую вы наблюдаете
Астрометрические данные:
Координаты центра Солнца на снимке
Масштаб (сколько угловых секунд в пикселе)
Угол поворота изображения
Контроль качества:
Информацию о том, был ли снимок испорчен помехами
Уровень обработки данных
Практическое использование:
Вы можете измерить реальные размеры солнечных пятен в километрах
Точно определить, в какой момент произошла вспышка
Сравнивать снимки с разных инструментов, приводя их к одной системе координат
SOLID архитектура с четким разделением на Model-View-Controller
✅ Профессиональный UI на PySide6 с темной темой
✅ Многослойность с чекбоксами видимости и прозрачностью
✅ Просмотрщик метаданных FITS
✅ Таймлапс с прогрессом в фоне и выбором FPS
✅ Умный Canvas с зумом и панорамированием
✅ Выбор папки сохранения
✅ Неблокирующие операции (потоки для таймлапса)

BIN
main.exe Normal file

Binary file not shown.

43
main.py Normal file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""
Helioviewer Solar Viewer - Профессиональное приложение для просмотра снимков Солнца
"""
import sys
import os
from pathlib import Path
# Добавляем путь к модулям
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import Qt
from controllers.app_controller import AppController
def main():
"""Точка входа в приложение"""
# Включаем High DPI поддержку
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
app = QApplication(sys.argv)
app.setApplicationName("Helioviewer Solar Viewer")
app.setOrganizationName("SolarViewer")
# Устанавливаем темную тему через QSS
app.setStyle("Fusion")
# Создаем контроллер (он создаст модель и представление)
controller = AppController()
# Показываем главное окно
controller.show_main_window()
sys.exit(app.exec())
# ИСПРАВЛЕНО: было if __name__ "__main__": , правильно:
if __name__ == "__main__":
main()

13
models/__init__.py Normal file
View file

@ -0,0 +1,13 @@
# Модели для Helioviewer приложения
from models.api_model import HelioviewerAPI
from models.image_model import ImageModel, ImageLayer
from models.timelapse_model import TimelapseModel, TimelapseConfig, TimelapseStatus
__all__ = [
'HelioviewerAPI',
'ImageModel',
'ImageLayer',
'TimelapseModel',
'TimelapseConfig',
'TimelapseStatus'
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

122
models/api_model.py Normal file
View file

@ -0,0 +1,122 @@
"""
Модель для работы с Helioviewer API
Single Responsibility: только загрузка данных из API
"""
import requests
from datetime import datetime
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from pathlib import Path
@dataclass
class SolarImage:
"""DTO для солнечного снимка"""
source_id: int
date: datetime
wavelength: str
observatory: str
instrument: str
filepath: Optional[Path] = None
metadata: Optional[Dict] = None
class HelioviewerAPI:
"""Клиент для работы с Helioviewer API"""
BASE_URL = "https://api.helioviewer.org/v1/"
# Предустановленные источники (можно расширять)
SOURCES = {
14: {"name": "AIA 335", "wavelength": "335 Å", "observatory": "SDO", "instrument": "AIA", "color": "#FFD700"},
13: {"name": "AIA 304", "wavelength": "304 Å", "observatory": "SDO", "instrument": "AIA", "color": "#FF4500"},
12: {"name": "AIA 211", "wavelength": "211 Å", "observatory": "SDO", "instrument": "AIA", "color": "#00FF00"},
11: {"name": "AIA 193", "wavelength": "193 Å", "observatory": "SDO", "instrument": "AIA", "color": "#00BFFF"},
10: {"name": "AIA 171", "wavelength": "171 Å", "observatory": "SDO", "instrument": "AIA", "color": "#87CEEB"},
9: {"name": "AIA 131", "wavelength": "131 Å", "observatory": "SDO", "instrument": "AIA", "color": "#FF1493"},
8: {"name": "AIA 94", "wavelength": "94 Å", "observatory": "SDO", "instrument": "AIA", "color": "#9400D3"},
4: {"name": "LASCO C2", "wavelength": "White Light", "observatory": "SOHO", "instrument": "LASCO",
"color": "#FFFFFF"},
5: {"name": "LASCO C3", "wavelength": "White Light", "observatory": "SOHO", "instrument": "LASCO",
"color": "#FFFFFF"},
}
@classmethod
def get_available_sources(cls) -> Dict[int, Dict]:
"""Возвращает список доступных источников"""
return cls.SOURCES
@classmethod
def download_image(cls, source_id: int, date: datetime, save_path: Path) -> Optional[Path]:
"""
Скачивает изображение с API Helioviewer
Args:
source_id: ID источника
date: Дата и время снимка
save_path: Путь для сохранения
Returns:
Path к сохраненному файлу или None при ошибке
"""
formatted_date = date.strftime("%Y-%m-%dT%H:%M:%SZ")
url = f"{cls.BASE_URL}getJP2Image/"
params = {'sourceId': source_id, 'date': formatted_date}
try:
response = requests.get(url, params=params, timeout=30)
response.raise_for_status()
# Создаем имя файла
source_info = cls.SOURCES.get(source_id, {})
filename = f"solar_{source_id}_{date.strftime('%Y%m%d_%H%M%S')}.jp2"
filepath = save_path / filename
# Сохраняем
with open(filepath, 'wb') as f:
f.write(response.content)
return filepath
except Exception as e:
print(f"Ошибка скачивания: {e}")
return None
@classmethod
def download_timelapse_images(cls, source_id: int, start_date: datetime,
end_date: datetime, save_path: Path,
progress_callback=None) -> List[Path]:
"""
Скачивает серию изображений для таймлапса
Args:
source_id: ID источника
start_date: Начальная дата
end_date: Конечная дата
save_path: Папка для сохранения
progress_callback: Функция для обновления прогресса
Returns:
Список путей к скачанным файлам
"""
downloaded_files = []
# Генерируем даты (каждый день в 12:00 UTC)
current_date = start_date.replace(hour=12, minute=0, second=0)
delta = end_date - start_date
total_days = delta.days + 1
for i in range(total_days):
if progress_callback:
progress_callback(i + 1, total_days, current_date)
filepath = cls.download_image(source_id, current_date, save_path)
if filepath:
downloaded_files.append(filepath)
# Переходим к следующему дню
from datetime import timedelta
current_date += timedelta(days=1)
return downloaded_files

111
models/image_model.py Normal file
View file

@ -0,0 +1,111 @@
"""
Модель для управления слоями изображений
"""
from PySide6.QtCore import QObject, Signal
from typing import List, Optional, Dict
from pathlib import Path
from dataclasses import dataclass
from datetime import datetime
@dataclass
class ImageLayer:
"""Модель слоя изображения"""
id: int
name: str
filepath: Path
source_id: int
date: datetime
wavelength: str
visible: bool = True
opacity: float = 1.0
image_data: Optional[any] = None
metadata: Optional[Dict] = None
class ImageModel(QObject):
"""Модель для хранения и управления слоями изображений"""
# Сигналы для оповещения View
layer_added = Signal(object) # ImageLayer
layer_removed = Signal(int) # layer_id
layer_visibility_changed = Signal(int, bool) # layer_id, visible
layer_opacity_changed = Signal(int, float) # layer_id, opacity
layer_selected = Signal(int) # layer_id
def __init__(self):
super().__init__()
self._layers: List[ImageLayer] = []
self._next_id = 1
self._selected_layer_id: Optional[int] = None
def add_layer(self, filepath: Path, source_id: int, date: datetime,
wavelength: str, image_data: any, metadata: Dict = None) -> int:
"""Добавляет новый слой"""
layer = ImageLayer(
id=self._next_id,
name=f"{wavelength} - {date.strftime('%Y-%m-%d %H:%M')}",
filepath=filepath,
source_id=source_id,
date=date,
wavelength=wavelength,
image_data=image_data,
metadata=metadata
)
self._layers.append(layer)
self._next_id += 1
self.layer_added.emit(layer)
return layer.id
def remove_layer(self, layer_id: int):
"""Удаляет слой"""
self._layers = [l for l in self._layers if l.id != layer_id]
self.layer_removed.emit(layer_id)
if self._selected_layer_id == layer_id:
self._selected_layer_id = None
def set_layer_visibility(self, layer_id: int, visible: bool):
"""Изменяет видимость слоя"""
for layer in self._layers:
if layer.id == layer_id:
layer.visible = visible
self.layer_visibility_changed.emit(layer_id, visible)
break
def set_layer_opacity(self, layer_id: int, opacity: float):
"""Изменяет прозрачность слоя"""
for layer in self._layers:
if layer.id == layer_id:
layer.opacity = opacity
self.layer_opacity_changed.emit(layer_id, opacity) # ← ЭМИИМ СИГНАЛ
break
def select_layer(self, layer_id: int):
"""Выбирает слой"""
self._selected_layer_id = layer_id
self.layer_selected.emit(layer_id)
def get_selected_layer(self) -> Optional[ImageLayer]:
"""Возвращает выбранный слой"""
if self._selected_layer_id:
for layer in self._layers:
if layer.id == self._selected_layer_id:
return layer
return None
def get_visible_layers(self) -> List[ImageLayer]:
"""Возвращает все видимые слои"""
return [layer for layer in self._layers if layer.visible]
def get_all_layers(self) -> List[ImageLayer]:
"""Возвращает все слои"""
return self._layers.copy()
def clear(self):
"""Очищает все слои"""
layer_ids = [layer.id for layer in self._layers]
self._layers.clear()
for layer_id in layer_ids:
self.layer_removed.emit(layer_id)

227
models/timelapse_model.py Normal file
View file

@ -0,0 +1,227 @@
"""
Модель для управления процессом создания таймлапса
Single Responsibility: только данные и состояние процесса таймлапса
"""
from PySide6.QtCore import QObject, Signal, QThread
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Optional, Callable
from dataclasses import dataclass, field
from enum import Enum
class TimelapseStatus(Enum):
"""Статус процесса создания таймлапса"""
IDLE = "idle"
PREPARING = "preparing"
DOWNLOADING = "downloading"
PROCESSING = "processing"
CREATING_VIDEO = "creating_video"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
@dataclass
class TimelapseConfig:
"""Конфигурация таймлапса"""
source_id: int
start_date: datetime
end_date: datetime
output_path: Path
fps: int = 10
quality: int = 90 # качество видео (0-100)
include_metadata: bool = True
output_format: str = "mp4" # mp4, gif, webm
@dataclass
class TimelapseProgress:
"""Прогресс создания таймлапса"""
status: TimelapseStatus
current: int = 0
total: int = 0
message: str = ""
current_date: Optional[datetime] = None
downloaded_files: List[Path] = field(default_factory=list)
class TimelapseModel(QObject):
"""
Модель для управления созданием таймлапса
Хранит состояние и предоставляет интерфейс для контроллера
"""
# Сигналы для оповещения о изменениях
progress_updated = Signal(TimelapseProgress)
status_changed = Signal(TimelapseStatus)
log_message = Signal(str)
def __init__(self):
super().__init__()
self._current_progress = TimelapseProgress(status=TimelapseStatus.IDLE)
self._config: Optional[TimelapseConfig] = None
self._is_cancelled = False
def configure(self, config: TimelapseConfig) -> bool:
"""
Настраивает таймлапс
Args:
config: Конфигурация таймлапса
Returns:
True если конфигурация валидна
"""
# Валидация параметров
if config.start_date >= config.end_date:
self.log_message.emit("Ошибка: Дата начала должна быть раньше даты окончания")
return False
if config.fps < 1 or config.fps > 60:
self.log_message.emit("Ошибка: FPS должен быть в диапазоне 1-60")
return False
if not config.output_path.parent.exists():
self.log_message.emit(f"Ошибка: Папка {config.output_path.parent} не существует")
return False
self._config = config
self._is_cancelled = False
# Рассчитываем общее количество кадров
delta = config.end_date - config.start_date
total_frames = delta.days + 1
self._current_progress = TimelapseProgress(
status=TimelapseStatus.PREPARING,
total=total_frames,
message=f"Подготовка к созданию таймлапса из {total_frames} кадров"
)
self.log_message.emit(f"Настроен таймлапс: {total_frames} кадров, {config.fps} FPS")
return True
def get_total_frames(self) -> int:
"""Возвращает общее количество кадров"""
if self._config:
delta = self._config.end_date - self._config.start_date
return delta.days + 1
return 0
def update_progress(self, current_frame: int, total_frames: int,
message: str = "", current_date: datetime = None):
"""
Обновляет прогресс создания таймлапса
Args:
current_frame: Текущий кадр
total_frames: Всего кадров
message: Сообщение о прогрессе
current_date: Текущая обрабатываемая дата
"""
self._current_progress.current = current_frame
self._current_progress.total = total_frames
self._current_progress.message = message or self._current_progress.message
self._current_progress.current_date = current_date or self._current_progress.current_date
# Вычисляем процент
if total_frames > 0:
percent = (current_frame / total_frames) * 100
status_text = f"{percent:.1f}%"
self.progress_updated.emit(self._current_progress)
def set_status(self, status: TimelapseStatus, message: str = ""):
"""
Устанавливает статус процесса
Args:
status: Новый статус
message: Сообщение (опционально)
"""
self._current_progress.status = status
if message:
self._current_progress.message = message
self.status_changed.emit(status)
self.progress_updated.emit(self._current_progress)
self.log_message.emit(message or f"Статус: {status.value}")
def add_downloaded_file(self, filepath: Path):
"""Добавляет скачанный файл в список"""
self._current_progress.downloaded_files.append(filepath)
def get_downloaded_files(self) -> List[Path]:
"""Возвращает список скачанных файлов"""
return self._current_progress.downloaded_files.copy()
def cancel(self):
"""Отменяет создание таймлапса"""
self._is_cancelled = True
self.set_status(TimelapseStatus.CANCELLED, "Создание таймлапса отменено пользователем")
def is_cancelled(self) -> bool:
"""Проверяет, отменен ли процесс"""
return self._is_cancelled
def get_config(self) -> Optional[TimelapseConfig]:
"""Возвращает конфигурацию таймлапса"""
return self._config
def reset(self):
"""Сбрасывает состояние модели"""
self._config = None
self._is_cancelled = False
self._current_progress = TimelapseProgress(status=TimelapseStatus.IDLE)
self.progress_updated.emit(self._current_progress)
def generate_date_sequence(self) -> List[datetime]:
"""
Генерирует последовательность дат для таймлапса
Returns:
Список дат для каждого кадра
"""
if not self._config:
return []
dates = []
current = self._config.start_date.replace(hour=12, minute=0, second=0)
while current <= self._config.end_date:
dates.append(current)
current += timedelta(days=1)
return dates
def get_estimated_size(self) -> int:
"""
Оценивает примерный размер видео в байтах
Returns:
Оценочный размер или 0 если невозможно оценить
"""
if not self._config:
return 0
# Приблизительная оценка: ~500KB на кадр для HD видео
total_frames = self.get_total_frames()
estimated_bytes = total_frames * 500 * 1024
# Корректируем в зависимости от FPS и качества
estimated_bytes = estimated_bytes * (self._config.fps / 30) * (self._config.quality / 100)
return int(estimated_bytes)
def get_formatted_estimated_size(self) -> str:
"""Возвращает отформатированную оценку размера"""
size_bytes = self.get_estimated_size()
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
else:
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"

10
utils/__init__.py Normal file
View file

@ -0,0 +1,10 @@
# Утилиты для Helioviewer приложения
from utils.image_processor import ImageProcessor
from utils.metadata_parser import MetadataParser
from utils.video_creator import VideoCreator
__all__ = [
'ImageProcessor',
'MetadataParser',
'VideoCreator'
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

267
utils/image_processor.py Normal file
View file

@ -0,0 +1,267 @@
"""
Утилиты для обработки изображений: нормализация, преобразование цветов и т.д.
"""
import numpy as np
from typing import Optional
from pathlib import Path
# Пытаемся импортировать доступные библиотеки для JP2
JP2_AVAILABLE = False
JP2_METHOD = None
JP2_ERROR = None
# Приоритет: imagecodecs > PIL > glymur
try:
import imagecodecs
import tifffile
JP2_AVAILABLE = True
JP2_METHOD = "imagecodecs"
print("✓ Используется imagecodecs для JP2 (рекомендуемый метод)")
except ImportError as e:
JP2_ERROR = str(e)
try:
from PIL import Image
JP2_AVAILABLE = True
JP2_METHOD = "pil"
print("✓ Используется PIL для JP2 (ограниченная поддержка)")
except ImportError:
try:
import glymur
from glymur import Jp2k
JP2_AVAILABLE = True
JP2_METHOD = "glymur"
print("✓ Используется Glymur для JP2")
except ImportError:
print("✗ Нет доступных библиотек для JP2")
print(" Установите: pip install imagecodecs")
class ImageProcessor:
"""Обработчик изображений для солнечных снимков"""
@staticmethod
def load_jp2(filepath: str) -> Optional[np.ndarray]:
"""
Загружает JP2 файл и возвращает numpy array
Args:
filepath: Путь к JP2 файлу
Returns:
numpy array с изображением (нормализованный 8-бит)
"""
if not JP2_AVAILABLE:
print(f"Ошибка: Нет доступных библиотек для JP2. Файл: {filepath}")
print(f" Причина: {JP2_ERROR}")
return None
try:
img_data = None
if JP2_METHOD == "imagecodecs":
# Метод через imagecodecs (работает без внешнего OpenJPEG)
import imagecodecs
with open(filepath, 'rb') as f:
data = f.read()
img_data = imagecodecs.jpeg2k_decode(data)
elif JP2_METHOD == "pil":
# Метод через PIL (базовая поддержка)
from PIL import Image
with Image.open(filepath) as img:
img_data = np.array(img)
elif JP2_METHOD == "glymur":
# Метод через Glymur
from glymur import Jp2k
jp2 = Jp2k(filepath)
img_data = jp2[:]
if img_data is None:
print(f"Не удалось загрузить: {filepath}")
return None
# Нормализуем 16-битные данные в 8-бит
if img_data.dtype == np.uint16:
img_data = ImageProcessor.percent_normalize(img_data)
elif img_data.dtype == np.uint8:
pass # уже 8-бит
else:
# Конвертируем в uint8
if img_data.max() > 0:
img_data = ((img_data - img_data.min()) / (img_data.max() - img_data.min()) * 255).astype(np.uint8)
else:
img_data = np.zeros_like(img_data, dtype=np.uint8)
# Если изображение черно-белое, дублируем в RGB
if len(img_data.shape) == 2:
img_data = np.stack([img_data] * 3, axis=2)
elif len(img_data.shape) == 3 and img_data.shape[2] == 1:
img_data = np.concatenate([img_data] * 3, axis=2)
return img_data
except Exception as e:
print(f"Ошибка загрузки JP2 ({JP2_METHOD}): {e}")
return None
@staticmethod
def percent_normalize(img_16bit: np.ndarray, low_percent: float = 0.5, high_percent: float = 99.5) -> np.ndarray:
"""
Процентная нормализация для астрономических изображений
Args:
img_16bit: 16-битное изображение
low_percent: Нижний процент отсечения
high_percent: Верхний процент отсечения
Returns:
8-битное нормализованное изображение
"""
# Вычисляем перцентили
low_val = np.percentile(img_16bit, low_percent)
high_val = np.percentile(img_16bit, high_percent)
# Защита от одинаковых значений
if high_val <= low_val:
high_val = low_val + 1
# Клиппируем и нормализуем
img_clipped = np.clip(img_16bit, low_val, high_val)
img_normalized = ((img_clipped - low_val) / (high_val - low_val) * 255).astype(np.uint8)
return img_normalized
@staticmethod
def apply_color_map(img_8bit: np.ndarray, color_map: str = "default") -> np.ndarray:
"""
Применяет цветовую карту к черно-белому изображению
Args:
img_8bit: 8-битное черно-белое изображение
color_map: Название цветовой карты
Returns:
RGB изображение
"""
# Встроенные цветовые карты для разных спектров
color_maps = {
"AIA 335": "hot", # золотистый
"AIA 304": "magma", # красный
"AIA 193": "plasma", # пурпурный
"AIA 171": "viridis", # зеленый
"AIA 211": "inferno", # оранжевый
"AIA 131": "cool", # синий
"LASCO C2": "gray", # серый
"default": "gray"
}
# Выбираем цветовую карту
cmap_name = color_maps.get(color_map, color_maps["default"])
# Если нет matplotlib, используем простую цветовую карту
try:
import matplotlib.pyplot as plt
cmap = plt.get_cmap(cmap_name)
colored = (cmap(img_8bit / 255.0)[:, :, :3] * 255).astype(np.uint8)
return colored
except ImportError:
# Упрощенная цветовая карта (красный-зеленый-синий)
if cmap_name == "hot":
r = img_8bit
g = np.clip((img_8bit.astype(int) - 85) * 3, 0, 255).astype(np.uint8)
b = np.clip((img_8bit.astype(int) - 170) * 3, 0, 255).astype(np.uint8)
return np.stack([r, g, b], axis=2)
elif cmap_name == "viridis":
r = np.clip(img_8bit * 0.2, 0, 255).astype(np.uint8)
g = np.clip(img_8bit * 0.6, 0, 255).astype(np.uint8)
b = np.clip(img_8bit * 0.9, 0, 255).astype(np.uint8)
return np.stack([r, g, b], axis=2)
else:
return np.stack([img_8bit] * 3, axis=2)
@staticmethod
def composite_layers(layers_data: list, opacities: list) -> np.ndarray:
"""
Композитинг нескольких слоев изображений
Args:
layers_data: Список numpy массивов изображений
opacities: Список прозрачностей (0-1)
Returns:
Скомпозированное изображение
"""
if not layers_data:
return None
# Приводим все к одному размеру
min_h = min(img.shape[0] for img in layers_data)
min_w = min(img.shape[1] for img in layers_data)
resized_layers = []
for img in layers_data:
if img.shape[0] != min_h or img.shape[1] != min_w:
from PIL import Image
pil_img = Image.fromarray(img)
pil_img = pil_img.resize((min_w, min_h), Image.Resampling.LANCZOS)
resized_layers.append(np.array(pil_img).astype(float))
else:
resized_layers.append(img.astype(float))
# Композитим
result = resized_layers[0] * opacities[0]
for i in range(1, len(resized_layers)):
result += resized_layers[i] * opacities[i]
# Нормализуем
result = np.clip(result, 0, 255).astype(np.uint8)
return result
@staticmethod
def create_test_image(width: int = 1024, height: int = 1024) -> np.ndarray:
"""
Создает тестовое изображение Солнца для отладки
Args:
width: Ширина изображения
height: Высота изображения
Returns:
RGB изображение
"""
# Создаем черный фон
img = np.zeros((height, width, 3), dtype=np.uint8)
# Рисуем круг Солнца
y, x = np.ogrid[:height, :width]
center_y, center_x = height // 2, width // 2
radius = min(height, width) // 3
mask = (x - center_x)**2 + (y - center_y)**2 <= radius**2
# Градиент для Солнца
distances = np.sqrt((x - center_x)**2 + (y - center_y)**2)
gradient = 1 - (distances / radius)
gradient = np.clip(gradient, 0, 1)
# Цвета для разных каналов
img[mask, 0] = (200 * gradient[mask]).astype(np.uint8) # Red
img[mask, 1] = (150 * gradient[mask]).astype(np.uint8) # Green
img[mask, 2] = (80 * gradient[mask]).astype(np.uint8) # Blue
# Добавляем солнечные пятна
spots = [
(center_x - 100, center_y - 50, 30),
(center_x + 80, center_y + 40, 25),
(center_x - 30, center_y + 90, 20),
(center_x + 120, center_y - 80, 15),
]
for sx, sy, r in spots:
spot_mask = (x - sx)**2 + (y - sy)**2 <= r**2
img[spot_mask] = [50, 30, 20]
return img

225
utils/metadata_parser.py Normal file
View file

@ -0,0 +1,225 @@
"""
Парсер для извлечения FITS-метаданных из JP2 файлов
"""
import xml.etree.ElementTree as ET
from typing import Dict, Optional
import struct
class MetadataParser:
"""Парсер FITS-метаданных из JP2 файлов"""
@staticmethod
def extract_metadata(filepath: str) -> Optional[Dict[str, str]]:
"""
Извлекает FITS-метаданные из JP2 файла
Args:
filepath: Путь к JP2 файлу
Returns:
Словарь с метаданными или None
"""
try:
# Пытаемся прочитать JP2 файл как бинарный и найти XML
with open(filepath, 'rb') as f:
data = f.read()
# Ищем XML данные в файле (JP2 может содержать XML в специальных боксах)
# Простой способ: ищем теги XML
xml_start = data.find(b'<?xml')
if xml_start == -1:
xml_start = data.find(b'<fits')
if xml_start != -1:
# Ищем конец XML
xml_end = 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

89
utils/video_creator.py Normal file
View file

@ -0,0 +1,89 @@
"""
Утилиты для создания видео из последовательности изображений
"""
import cv2
import numpy as np
from pathlib import Path
from typing import List
from utils.image_processor import ImageProcessor
class VideoCreator:
"""Создатель видео из серии изображений"""
@staticmethod
def create_timelapse(image_paths: List[Path], output_path: Path, fps: int = 10) -> Path:
"""
Создает видео из последовательности изображений
Args:
image_paths: Список путей к изображениям
output_path: Путь для сохранения видео
fps: Кадров в секунду
Returns:
Путь к созданному видео
"""
if not image_paths:
raise ValueError("Нет изображений для создания видео")
# Загружаем первое изображение для определения размера
first_img = ImageProcessor.load_jp2(str(image_paths[0]))
if first_img is None:
raise ValueError("Не удалось загрузить первое изображение")
height, width = first_img.shape[:2]
# Определяем кодек и создаем VideoWriter
output_str = str(output_path)
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
video_writer = cv2.VideoWriter(output_str, fourcc, fps, (width, height))
# Добавляем все изображения
for img_path in image_paths:
# Загружаем изображение
img_data = ImageProcessor.load_jp2(str(img_path))
if img_data is not None:
# Конвертируем RGB в BGR для OpenCV
img_bgr = cv2.cvtColor(img_data, cv2.COLOR_RGB2BGR)
video_writer.write(img_bgr)
video_writer.release()
return output_path
@staticmethod
def create_gif(image_paths: List[Path], output_path: Path, duration: int = 200) -> Path:
"""
Создает GIF анимацию из изображений
Args:
image_paths: Список путей к изображениям
output_path: Путь для сохранения GIF
duration: Длительность кадра в миллисекундах
Returns:
Путь к созданному GIF
"""
from PIL import Image
images = []
for img_path in image_paths:
img_data = ImageProcessor.load_jp2(str(img_path))
if img_data is not None:
pil_image = Image.fromarray(img_data)
images.append(pil_image)
if images:
# Сохраняем GIF
images[0].save(
output_path,
save_all=True,
append_images=images[1:],
duration=duration,
loop=0
)
return output_path

16
views/__init__.py Normal file
View file

@ -0,0 +1,16 @@
# views/__init__.py
from views.main_window import MainWindow
from views.layer_widget import LayerWidget
from views.control_panel import ControlPanel
from views.canvas_widget import SolarCanvas
from views.timelapse_dialog import TimelapseDialog
from views.metadata_viewer import MetadataViewer # ← должен импортировать класс
__all__ = [
'MainWindow',
'LayerWidget',
'ControlPanel',
'SolarCanvas',
'TimelapseDialog',
'MetadataViewer'
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

149
views/canvas_widget.py Normal file
View file

@ -0,0 +1,149 @@
"""
Виджет для отображения и манипуляции солнечными изображениями
Поддерживает: зум, панорамирование, композитинг слоев
"""
from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem
from PySide6.QtCore import Qt, QRectF, QPointF, Signal
from PySide6.QtGui import QPixmap, QImage, QWheelEvent, QMouseEvent, QPainter
import numpy as np
class SolarCanvas(QGraphicsView):
"""
Кастомный Canvas для отображения солнечных изображений
Поддерживает масштабирование, панорамирование и наложение слоев
"""
zoom_changed = Signal(float)
def __init__(self, controller):
super().__init__()
self.controller = controller
self.scene = QGraphicsScene(self)
self.setScene(self.scene)
# Настройки отображения - ИСПРАВЛЕНО: используем QPainter
self.setRenderHint(QPainter.RenderHint.Antialiasing)
self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
self.setDragMode(self.DragMode.ScrollHandDrag)
self.setTransformationAnchor(self.ViewportAnchor.AnchorUnderMouse)
self.setResizeAnchor(self.ViewportAnchor.AnchorUnderMouse)
# Переменные для зума
self.current_zoom = 1.0
self.min_zoom = 0.1
self.max_zoom = 10.0
self.zoom_factor = 1.1
# Словарь для хранения слоев (QGraphicsPixmapItem)
self.layer_items = {}
# Фон для лучшего контраста (черный для астрономических изображений)
self.setBackgroundBrush(Qt.GlobalColor.black)
# Дополнительные настройки для плавности
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
def set_image(self, layer_id: int, image_data: np.ndarray):
"""
Устанавливает изображение для слоя
Args:
layer_id: ID слоя
image_data: numpy массив с изображением
"""
# Конвертируем numpy array в QImage
height, width = image_data.shape[:2]
if len(image_data.shape) == 2:
# Черно-белое изображение
bytes_per_line = width
qimage = QImage(image_data.data, width, height, bytes_per_line, QImage.Format.Format_Grayscale8)
else:
# RGB изображение
bytes_per_line = 3 * width
qimage = QImage(image_data.data, width, height, bytes_per_line, QImage.Format.Format_RGB888)
# Конвертируем в QPixmap
pixmap = QPixmap.fromImage(qimage)
# Создаем или обновляем графический элемент
if layer_id in self.layer_items:
self.layer_items[layer_id].setPixmap(pixmap)
else:
item = self.scene.addPixmap(pixmap)
self.layer_items[layer_id] = item
# Центрируем изображение при первом добавлении
if len(self.layer_items) == 1:
self.centerOn(item)
self.fitInView(item, Qt.AspectRatioMode.KeepAspectRatio)
def set_layer_visibility(self, layer_id: int, visible: bool):
"""Устанавливает видимость слоя"""
if layer_id in self.layer_items:
self.layer_items[layer_id].setVisible(visible)
def set_layer_opacity(self, layer_id: int, opacity: float):
"""Устанавливает прозрачность слоя"""
if layer_id in self.layer_items:
self.layer_items[layer_id].setOpacity(opacity)
def remove_layer(self, layer_id: int):
"""Удаляет слой"""
if layer_id in self.layer_items:
self.scene.removeItem(self.layer_items[layer_id])
del self.layer_items[layer_id]
def clear_all_layers(self):
"""Очищает все слои"""
for item in self.layer_items.values():
self.scene.removeItem(item)
self.layer_items.clear()
def wheelEvent(self, event: QWheelEvent):
"""Обработка колесика мыши для зума"""
# Определяем направление зума
zoom_in = event.angleDelta().y() > 0
# Вычисляем новый уровень зума
if zoom_in:
new_zoom = self.current_zoom * self.zoom_factor
else:
new_zoom = self.current_zoom / self.zoom_factor
# Ограничиваем зум
if self.min_zoom <= new_zoom <= self.max_zoom:
self.current_zoom = new_zoom
self.scale(self.zoom_factor if zoom_in else 1/self.zoom_factor,
self.zoom_factor if zoom_in else 1/self.zoom_factor)
self.zoom_changed.emit(self.current_zoom)
def mousePressEvent(self, event: QMouseEvent):
"""Обработка нажатия мыши для панорамирования"""
if event.button() == Qt.MouseButton.MiddleButton:
self.setDragMode(self.DragMode.ScrollHandDrag)
# Создаем фейковое событие с левой кнопкой
fake_event = QMouseEvent(
event.type(), event.pos(), Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton, event.modifiers()
)
super().mousePressEvent(fake_event)
else:
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent):
"""Обработка отпускания мыши"""
if event.button() == Qt.MouseButton.MiddleButton:
self.setDragMode(self.DragMode.NoDrag)
super().mouseReleaseEvent(event)
def reset_view(self):
"""Сбрасывает зум и позицию"""
if self.layer_items:
first_item = next(iter(self.layer_items.values()))
self.fitInView(first_item, Qt.AspectRatioMode.KeepAspectRatio)
self.current_zoom = 1.0
self.zoom_changed.emit(self.current_zoom)

159
views/control_panel.py Normal file
View file

@ -0,0 +1,159 @@
"""
Панель управления - выбор даты, спектра, загрузка изображений
"""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
QComboBox, QDateEdit, QTimeEdit, QPushButton,
QLabel, QProgressBar, QFrame
)
from PySide6.QtCore import Qt, QDateTime, Signal
from datetime import datetime
class ControlPanel(QWidget):
"""Панель управления для выбора параметров загрузки"""
load_image_requested = Signal(int, datetime) # source_id, date
def __init__(self, controller):
super().__init__()
self.controller = controller
self.init_ui()
def init_ui(self):
"""Инициализация UI"""
layout = QVBoxLayout(self)
layout.setSpacing(10)
layout.setContentsMargins(5, 5, 5, 5)
# Группа выбора спектра
spectrum_group = QGroupBox("🌞 Спектральный канал")
spectrum_layout = QVBoxLayout(spectrum_group)
self.spectrum_combo = QComboBox()
self.spectrum_combo.setMinimumHeight(30)
self.populate_spectrums()
spectrum_layout.addWidget(self.spectrum_combo)
layout.addWidget(spectrum_group)
# Группа выбора даты и времени
date_group = QGroupBox("📅 Дата и время (UTC)")
date_layout = QVBoxLayout(date_group)
# Дата
date_label = QLabel("Дата:")
date_layout.addWidget(date_label)
self.date_edit = QDateEdit()
self.date_edit.setDateTime(QDateTime.currentDateTime())
self.date_edit.setCalendarPopup(True)
self.date_edit.setMinimumHeight(30)
date_layout.addWidget(self.date_edit)
# Время
time_label = QLabel("Время:")
date_layout.addWidget(time_label)
self.time_edit = QTimeEdit()
self.time_edit.setTime(QDateTime.currentDateTime().time())
self.time_edit.setMinimumHeight(30)
date_layout.addWidget(self.time_edit)
# Кнопки быстрого выбора
quick_layout = QHBoxLayout()
now_button = QPushButton("Сейчас")
now_button.setMinimumHeight(30)
now_button.clicked.connect(self.set_now)
quick_layout.addWidget(now_button)
today_button = QPushButton("Сегодня")
today_button.setMinimumHeight(30)
today_button.clicked.connect(self.set_today)
quick_layout.addWidget(today_button)
date_layout.addLayout(quick_layout)
layout.addWidget(date_group)
# Кнопка загрузки
self.load_button = QPushButton("📥 Загрузить изображение")
self.load_button.setMinimumHeight(40)
self.load_button.clicked.connect(self.request_load_image)
self.load_button.setStyleSheet("""
QPushButton {
background-color: #2ecc71;
color: white;
font-weight: bold;
font-size: 14px;
padding: 10px;
border-radius: 5px;
}
QPushButton:hover {
background-color: #27ae60;
}
QPushButton:disabled {
background-color: #555;
}
""")
layout.addWidget(self.load_button)
# Прогресс бар (для загрузки)
self.progress_bar = QProgressBar()
self.progress_bar.setVisible(False)
self.progress_bar.setMinimumHeight(20)
layout.addWidget(self.progress_bar)
# Информационная метка
self.info_label = QLabel("Выберите спектральный канал и дату")
self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.info_label.setStyleSheet("color: #888; font-size: 11px; padding: 5px;")
layout.addWidget(self.info_label)
def populate_spectrums(self):
"""Заполняет список доступных спектров"""
from models.api_model import HelioviewerAPI
sources = HelioviewerAPI.get_available_sources()
for source_id, info in sources.items():
display_text = f"{info['observatory']} - {info['name']} ({info['wavelength']})"
self.spectrum_combo.addItem(display_text, source_id)
def get_selected_source_id(self) -> int:
"""Возвращает ID выбранного источника"""
return self.spectrum_combo.currentData()
def get_selected_datetime(self) -> datetime:
"""Возвращает выбранную дату и время"""
qdatetime = QDateTime(self.date_edit.date(), self.time_edit.time())
return qdatetime.toPython()
def set_now(self):
"""Устанавливает текущее время"""
now = QDateTime.currentDateTime()
self.date_edit.setDate(now.date())
self.time_edit.setTime(now.time())
def set_today(self):
"""Устанавливает сегодняшнюю дату"""
self.date_edit.setDate(QDateTime.currentDateTime().date())
def request_load_image(self):
"""Запрашивает загрузку изображения"""
source_id = self.get_selected_source_id()
date = self.get_selected_datetime()
self.load_image_requested.emit(source_id, date)
def show_progress(self, show: bool):
"""Показывает/скрывает прогресс бар"""
self.progress_bar.setVisible(show)
self.load_button.setEnabled(not show)
self.info_label.setText("Загрузка..." if show else "Готово")
def update_progress(self, value: int, maximum: int):
"""Обновляет прогресс"""
self.progress_bar.setRange(0, maximum)
self.progress_bar.setValue(value)
self.info_label.setText(f"Загрузка: {int(value/maximum*100)}%")

219
views/layer_widget.py Normal file
View file

@ -0,0 +1,219 @@
"""
Виджет для управления слоями изображений
"""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
QHBoxLayout, QCheckBox, QSlider, QLabel, QPushButton,
QFrame
)
from PySide6.QtCore import Qt, Signal, QSize
class LayerWidgetItem(QWidget):
"""Виджет для отдельного слоя в списке"""
visibility_toggled = Signal(int, bool)
opacity_changed = Signal(int, float)
def __init__(self, layer_id: int, name: str, visible: bool = True):
super().__init__()
self.layer_id = layer_id
self.init_ui(name, visible)
def init_ui(self, name: str, visible: bool):
layout = QHBoxLayout(self)
layout.setContentsMargins(8, 6, 8, 6)
layout.setSpacing(8)
# Чекбокс видимости
self.visibility_checkbox = QCheckBox()
self.visibility_checkbox.setChecked(visible)
self.visibility_checkbox.setFixedSize(22, 22)
self.visibility_checkbox.toggled.connect(self.on_visibility_toggled)
layout.addWidget(self.visibility_checkbox)
# Название слоя
self.name_label = QLabel(name)
self.name_label.setStyleSheet("font-size: 11px; font-weight: 500;")
self.name_label.setWordWrap(True)
layout.addWidget(self.name_label, 2)
# Слайдер прозрачности
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
self.opacity_slider.setRange(0, 100)
self.opacity_slider.setValue(100)
self.opacity_slider.setFixedWidth(100)
self.opacity_slider.valueChanged.connect(self.on_opacity_changed)
layout.addWidget(self.opacity_slider)
# Метка прозрачности
self.opacity_label = QLabel("100%")
self.opacity_label.setFixedWidth(35)
self.opacity_label.setStyleSheet("font-size: 10px; font-family: monospace;")
layout.addWidget(self.opacity_label)
# Кнопка удаления
remove_button = QPushButton("")
remove_button.setFixedSize(26, 26)
remove_button.setStyleSheet("""
QPushButton {
background-color: #e74c3c;
color: white;
border-radius: 13px;
font-size: 12px;
font-weight: bold;
}
QPushButton:hover {
background-color: #c0392b;
}
""")
remove_button.clicked.connect(lambda: self.visibility_toggled.emit(self.layer_id, False))
layout.addWidget(remove_button)
def on_visibility_toggled(self, checked):
self.visibility_toggled.emit(self.layer_id, checked)
def on_opacity_changed(self, value):
opacity = value / 100.0
self.opacity_label.setText(f"{value}%")
self.opacity_changed.emit(self.layer_id, opacity)
def set_visible(self, visible: bool):
self.visibility_checkbox.setChecked(visible)
def set_opacity(self, opacity: float):
value = int(opacity * 100)
self.opacity_slider.setValue(value)
self.opacity_label.setText(f"{value}%")
class LayerWidget(QWidget):
"""Виджет для отображения списка слоев"""
def __init__(self, controller):
super().__init__()
self.controller = controller
self.layer_widgets = {}
self.init_ui()
def init_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
# Список слоев
self.list_widget = QListWidget()
self.list_widget.setStyleSheet("""
QListWidget {
border: 1px solid #555;
border-radius: 5px;
background-color: #2b2b2b;
outline: none;
}
QListWidget::item {
border-bottom: 1px solid #444;
}
QListWidget::item:selected {
background-color: #3c5a8c;
}
QListWidget::item:hover {
background-color: #3c3c3c;
}
""")
layout.addWidget(self.list_widget)
# Кнопка очистки
clear_button = QPushButton("🗑️ Очистить все слои")
clear_button.setMinimumHeight(32)
clear_button.setStyleSheet("""
QPushButton {
background-color: #555;
color: white;
padding: 5px;
border-radius: 4px;
}
QPushButton:hover {
background-color: #e74c3c;
}
""")
clear_button.clicked.connect(self.clear_all_layers)
layout.addWidget(clear_button)
# Пустое состояние
self.empty_label = QLabel("📭 Нет загруженных слоев\n\nНажмите 'Загрузить изображение'")
self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.empty_label.setStyleSheet("color: #888; padding: 30px; background-color: #2b2b2b; border-radius: 5px;")
self.empty_label.setVisible(True)
layout.addWidget(self.empty_label)
def add_layer(self, layer_id: int, name: str, visible: bool = True):
"""Добавляет новый слой в список"""
self.empty_label.setVisible(False)
self.list_widget.setVisible(True)
item = QListWidgetItem()
item.setSizeHint(QSize(0, 55))
self.list_widget.addItem(item)
widget = LayerWidgetItem(layer_id, name, visible)
widget.visibility_toggled.connect(self.on_visibility_toggled)
widget.opacity_changed.connect(self.on_opacity_changed)
self.list_widget.setItemWidget(item, widget)
self.layer_widgets[layer_id] = (item, widget)
self.list_widget.setCurrentItem(item)
self.list_widget.scrollToItem(item)
def remove_layer(self, layer_id: int):
"""Удаляет слой из списка"""
if layer_id in self.layer_widgets:
item, _ = self.layer_widgets[layer_id]
row = self.list_widget.row(item)
self.list_widget.takeItem(row)
del self.layer_widgets[layer_id]
if len(self.layer_widgets) == 0:
self.empty_label.setVisible(True)
self.list_widget.setVisible(False)
def update_layers(self, layers):
"""Обновляет весь список слоев"""
self.clear_all_layers(keep_controller=False)
for layer in layers:
self.add_layer(layer.id, layer.name, layer.visible)
if layers:
self.empty_label.setVisible(False)
self.list_widget.setVisible(True)
else:
self.empty_label.setVisible(True)
self.list_widget.setVisible(False)
def clear_all_layers(self, keep_controller: bool = True):
"""Очищает список слоев"""
self.list_widget.clear()
self.layer_widgets.clear()
if keep_controller:
self.controller.clear_all_layers()
self.empty_label.setVisible(True)
self.list_widget.setVisible(False)
def on_visibility_toggled(self, layer_id: int, visible: bool):
self.controller.set_layer_visibility(layer_id, visible)
def on_opacity_changed(self, layer_id: int, opacity: float):
self.controller.set_layer_opacity(layer_id, opacity)
def get_selected_layer_id(self) -> int:
current_item = self.list_widget.currentItem()
if current_item:
for layer_id, (item, _) in self.layer_widgets.items():
if item == current_item:
return layer_id
return None
def get_layers_count(self) -> int:
return len(self.layer_widgets)

251
views/main_window.py Normal file
View file

@ -0,0 +1,251 @@
"""
Главное окно приложения - содержит меню, панели и canvas
"""
from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QSplitter, QMenuBar, QMenu, QStatusBar, QMessageBox,
QFileDialog, QToolBar, QFrame, QScrollArea, QLabel
)
from PySide6.QtCore import Qt, QSize
from PySide6.QtGui import QAction, QKeySequence
from views.layer_widget import LayerWidget
from views.canvas_widget import SolarCanvas
from views.control_panel import ControlPanel
from views.timelapse_dialog import TimelapseDialog
from views.metadata_viewer import MetadataViewer
class MainWindow(QMainWindow):
"""Главное окно приложения"""
def __init__(self, controller):
super().__init__()
self.controller = controller
self.init_ui()
def init_ui(self):
"""Инициализация пользовательского интерфейса"""
self.setWindowTitle("Helioviewer Solar Viewer - Профессиональный просмотрщик снимков Солнца")
self.setMinimumSize(1200, 800)
# Центральный виджет
central_widget = QWidget()
self.setCentralWidget(central_widget)
# Основной layout
main_layout = QHBoxLayout(central_widget)
main_layout.setContentsMargins(0, 0, 0, 0)
# Создаем сплиттер для левой панели и canvas
splitter = QSplitter(Qt.Orientation.Horizontal)
main_layout.addWidget(splitter)
# Левая панель (с прокруткой)
left_panel = self.create_left_panel_with_scroll()
splitter.addWidget(left_panel)
# Устанавливаем начальную ширину сплиттера
splitter.setSizes([400, self.width() - 400])
# Правая область (canvas)
right_area = self.create_right_area()
splitter.addWidget(right_area)
# Создаем меню
self.create_menu_bar()
# Создаем статус бар
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_bar.showMessage("Готов к работе")
# Добавляем тулбар для быстрого доступа
self.create_toolbar()
def create_left_panel_with_scroll(self) -> QWidget:
"""
Создает левую панель с прокруткой, где все виджеты равномерно распределяют пространство
"""
# Внешний контейнер для скролла
scroll_container = QWidget()
scroll_layout = QVBoxLayout(scroll_container)
scroll_layout.setContentsMargins(0, 0, 0, 0)
# Создаем QScrollArea
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True) # Позволяет виджету изменять размер
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setStyleSheet("""
QScrollArea {
border: none;
background-color: #2b2b2b;
}
QScrollBar:vertical {
background-color: #2b2b2b;
width: 12px;
border-radius: 6px;
}
QScrollBar::handle:vertical {
background-color: #555;
border-radius: 6px;
min-height: 20px;
}
QScrollBar::handle:vertical:hover {
background-color: #777;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0px;
}
""")
# Внутренний контейнер для всех виджетов
content_widget = QWidget()
content_widget.setStyleSheet("background-color: #2b2b2b;")
layout = QVBoxLayout(content_widget)
layout.setSpacing(10)
layout.setContentsMargins(10, 10, 10, 10)
# Панель управления (выбор даты, спектра)
self.control_panel = ControlPanel(self.controller)
layout.addWidget(self.control_panel, 1) # stretch factor = 1
# Разделитель
layout.addWidget(self.create_separator())
# Заголовок слоев
title_layers = self.create_section_title("📁 Слои изображений")
layout.addWidget(title_layers)
# Виджет слоев - будет занимать столько места, сколько нужно
self.layer_widget = LayerWidget(self.controller)
layout.addWidget(self.layer_widget, 2) # stretch factor = 2 (больше места)
# Разделитель
layout.addWidget(self.create_separator())
# Заголовок метаданных
title_metadata = self.create_section_title("📊 Метаданные FITS")
layout.addWidget(title_metadata)
# Просмотрщик метаданных
self.metadata_viewer = MetadataViewer()
layout.addWidget(self.metadata_viewer, 1) # stretch factor = 1
# Растягивающийся спейсер внизу (опционально)
layout.addStretch()
# Устанавливаем content_widget в scroll_area
scroll_area.setWidget(content_widget)
# Добавляем scroll_area в контейнер
scroll_layout.addWidget(scroll_area)
return scroll_container
def create_separator(self) -> QFrame:
"""Создает линию-разделитель"""
separator = QFrame()
separator.setFrameShape(QFrame.Shape.HLine)
separator.setFrameShadow(QFrame.Shadow.Sunken)
separator.setStyleSheet("background-color: #555; max-height: 1px;")
return separator
def create_section_title(self, title: str) -> QLabel:
"""Создает заголовок секции"""
label = QLabel(title)
label.setStyleSheet("""
font-weight: bold;
font-size: 14px;
padding: 8px 5px;
background-color: #3c3c3c;
border-radius: 4px;
margin-top: 5px;
margin-bottom: 5px;
""")
label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
return label
def create_right_area(self) -> QWidget:
"""Создает правую область с canvas"""
area = QWidget()
layout = QVBoxLayout(area)
layout.setContentsMargins(0, 0, 0, 0)
# Canvas для отображения изображений
self.canvas = SolarCanvas(self.controller)
layout.addWidget(self.canvas)
return area
def create_menu_bar(self):
"""Создает меню приложения"""
menubar = self.menuBar()
# Меню Файл
file_menu = menubar.addMenu("📁 Файл")
# Действия для меню Файл
self.setup_action = QAction("⚙️ Настройки", self)
self.exit_action = QAction("🚪 Выход", self)
self.exit_action.setShortcut(QKeySequence.StandardKey.Quit)
self.exit_action.triggered.connect(self.close)
file_menu.addAction(self.setup_action)
file_menu.addSeparator()
file_menu.addAction(self.exit_action)
# Меню Инструменты
tools_menu = menubar.addMenu("🛠️ Инструменты")
self.timelapse_action = QAction("🎬 Таймлапс...", self)
self.timelapse_action.triggered.connect(self.open_timelapse_dialog)
tools_menu.addAction(self.timelapse_action)
# Меню Справка
help_menu = menubar.addMenu("❓ Справка")
about_action = QAction(" О программе", self)
about_action.triggered.connect(self.show_about)
help_menu.addAction(about_action)
def create_toolbar(self):
"""Создает панель инструментов"""
toolbar = QToolBar("Быстрый доступ")
self.addToolBar(toolbar)
toolbar.setIconSize(QSize(24, 24))
def open_timelapse_dialog(self):
"""Открывает диалог создания таймлапса"""
dialog = TimelapseDialog(self.controller, self)
dialog.exec()
def show_about(self):
"""Показывает информацию о программе"""
QMessageBox.about(
self,
"О программе",
"Helioviewer Solar Viewer v1.0\n\n"
"Профессиональный просмотрщик снимков Солнца\n"
"Использует данные Helioviewer API\n\n"
"Возможности:\n"
"• Загрузка снимков в различных спектрах\n"
"• Многослойный режим с наложением\n"
"• Создание таймлапс-анимаций\n"
"• Просмотр FITS-метаданных\n\n"
"© 2024 SolarViewer Team"
)
def update_status(self, message: str, timeout: int = 3000):
"""Обновляет статус в статус-баре"""
self.status_bar.showMessage(message, timeout)
def update_layer_list(self, layers):
"""Обновляет список слоев"""
self.layer_widget.update_layers(layers)
def get_control_panel(self):
"""Возвращает панель управления"""
return self.control_panel

126
views/metadata_viewer.py Normal file
View file

@ -0,0 +1,126 @@
"""
Виджет для отображения FITS-метаданных из JP2 файлов
"""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem,
QGroupBox, QLabel, QScrollArea
)
from PySide6.QtCore import Qt
class MetadataViewer(QWidget):
"""Просмотрщик метаданных FITS"""
def __init__(self):
super().__init__()
self.init_ui()
def init_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Используем QScrollArea для метаданных
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setStyleSheet("""
QScrollArea {
border: 1px solid #555;
border-radius: 5px;
background-color: #2b2b2b;
}
""")
# Внутренний контейнер
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setContentsMargins(5, 5, 5, 5)
# Дерево для метаданных
self.tree = QTreeWidget()
self.tree.setHeaderLabels(["Параметр", "Значение"])
self.tree.setAlternatingRowColors(True)
self.tree.setIndentation(10)
self.tree.setMinimumHeight(150)
self.tree.setStyleSheet("""
QTreeWidget {
background-color: #2b2b2b;
alternate-background-color: #252525;
border: none;
}
QTreeWidget::item {
padding: 3px;
}
""")
content_layout.addWidget(self.tree)
# Информационная метка
self.info_label = QLabel("Выберите слой для просмотра метаданных")
self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.info_label.setStyleSheet("color: #888; padding: 20px;")
self.info_label.setWordWrap(True)
content_layout.addWidget(self.info_label)
scroll_area.setWidget(content_widget)
layout.addWidget(scroll_area)
def display_metadata(self, metadata: dict):
"""Отображает метаданные в дереве"""
self.tree.clear()
if not metadata:
self.info_label.setVisible(True)
self.tree.setVisible(False)
self.info_label.setText("Метаданные не найдены")
return
self.info_label.setVisible(False)
self.tree.setVisible(True)
# Группировка метаданных
categories = {
"📡 Инструмент": ["Телескоп", "Инструмент", "Детектор", "Обсерватория"],
"🌊 Спектр": ["Длина волны", "Единица длины волны"],
"📅 Время": ["Дата наблюдения", "Начало экспозиции", "Конец экспозиции", "Время экспозиции (сек)"],
"📐 Геометрия": ["Центр X (пикс)", "Центр Y (пикс)", "Шаг пикселя X (arcsec)", "Шаг пикселя Y (arcsec)",
"Угол поворота (град)"],
"📊 Изображение": ["Ширина (пикс)", "Высота (пикс)", "Бит на пиксель"],
"📝 Другое": []
}
# Сортируем метаданные
for category, keys in categories.items():
category_items = []
for key in keys:
if key in metadata and metadata[key]:
category_items.append((key, metadata[key]))
# Для категории "Другое" собираем остальные ключи
if category == "📝 Другое":
all_keys = [k for sublist in categories.values() for k in sublist if k != "Другое"]
for key, value in metadata.items():
if key not in all_keys and value:
category_items.append((key, value))
if category_items:
category_item = QTreeWidgetItem(self.tree)
category_item.setText(0, category)
category_item.setExpanded(True)
for key, value in category_items:
param_item = QTreeWidgetItem(category_item)
param_item.setText(0, key)
param_item.setText(1, str(value))
self.tree.expandAll()
self.tree.resizeColumnToContents(0)
self.tree.resizeColumnToContents(1)
def clear(self):
"""Очищает отображение метаданных"""
self.tree.clear()
self.info_label.setVisible(True)
self.tree.setVisible(False)
self.info_label.setText("Выберите слой для просмотра метаданных")

370
views/timelapse_dialog.py Normal file
View file

@ -0,0 +1,370 @@
"""
Диалог для создания таймлапс-анимации
"""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox,
QComboBox, QDateEdit, QPushButton, QLabel,
QProgressBar, QFileDialog, QMessageBox, QCheckBox,
QTextEdit
)
from PySide6.QtCore import Qt, QDateTime, QSettings, QThread, Signal
from pathlib import Path
from datetime import datetime, timedelta
import tempfile
import shutil
class TimelapseWorker(QThread):
progress = Signal(int, int, str)
log = Signal(str)
finished = Signal(bool, str)
def __init__(self, source_id, start_date, end_date, output_path, fps=10):
super().__init__()
self.source_id = source_id
self.start_date = start_date
self.end_date = end_date
self.output_path = output_path
self.fps = fps
self._is_cancelled = False
def cancel(self):
self._is_cancelled = True
def run(self):
from models.api_model import HelioviewerAPI
from utils.video_creator import VideoCreator
try:
# Создаем временную папку
temp_dir = Path(tempfile.mkdtemp(prefix="helioviewer_timelapse_"))
self.log.emit(f"Создана временная папка: {temp_dir}")
# Генерируем даты - ИСПРАВЛЕНО
dates = []
# Проверяем тип и преобразуем в datetime если нужно
from datetime import datetime as dt
if isinstance(self.start_date, dt):
current = self.start_date.replace(hour=12, minute=0, second=0)
else:
# Если это date, конвертируем в datetime
current = dt.combine(self.start_date, dt.min.time()).replace(hour=12)
# Проверяем конец
if isinstance(self.end_date, dt):
end = self.end_date
else:
end = dt.combine(self.end_date, dt.max.time())
while current <= end:
dates.append(current)
current += timedelta(days=1)
total = len(dates)
self.log.emit(f"Всего файлов в очереди: {total}")
downloaded = []
for i, date in enumerate(dates):
if self._is_cancelled:
self.log.emit("Отменено пользователем")
shutil.rmtree(temp_dir, ignore_errors=True)
self.finished.emit(False, "Отменено")
return
current_num = i + 1
percent = int(current_num / total * 100)
self.progress.emit(current_num, total, f"Скачивание {current_num}/{total} ({percent}%)")
self.log.emit(f"Скачивание {current_num}/{total}: {date.strftime('%Y-%m-%d')}")
filepath = HelioviewerAPI.download_image(self.source_id, date, temp_dir)
if filepath:
downloaded.append(filepath)
self.log.emit(f" ✓ Успешно")
else:
self.log.emit(f" ✗ Ошибка")
if self._is_cancelled:
self.log.emit("Отменено пользователем")
shutil.rmtree(temp_dir, ignore_errors=True)
self.finished.emit(False, "Отменено")
return
if not downloaded:
shutil.rmtree(temp_dir, ignore_errors=True)
self.finished.emit(False, "Не удалось скачать ни одного изображения")
return
self.log.emit(f"Создание видео из {len(downloaded)} кадров...")
self.progress.emit(total, total, "Создание видео...")
video_path = VideoCreator.create_timelapse(downloaded, self.output_path, self.fps)
# Очищаем временную папку
shutil.rmtree(temp_dir, ignore_errors=True)
self.log.emit(f"Временные файлы удалены")
self.finished.emit(True, str(video_path))
except Exception as e:
self.log.emit(f"Ошибка: {str(e)}")
self.finished.emit(False, str(e))
class TimelapseDialog(QDialog):
"""Диалог для создания таймлапса"""
def __init__(self, controller, parent=None):
super().__init__(parent)
self.controller = controller
self.settings = QSettings("SolarViewer", "Helioviewer")
self.worker = None
self.init_ui()
self.load_settings()
def init_ui(self):
"""Инициализация UI"""
self.setWindowTitle("Создание таймлапс-анимации")
self.setModal(True)
self.setMinimumWidth(500)
layout = QVBoxLayout(self)
# Группа выбора спектра
spectrum_group = QGroupBox("🌞 Спектральный канал")
spectrum_layout = QVBoxLayout(spectrum_group)
self.spectrum_combo = QComboBox()
self.populate_spectrums()
spectrum_layout.addWidget(self.spectrum_combo)
layout.addWidget(spectrum_group)
# Группа выбора дат
date_group = QGroupBox("📅 Период")
date_layout = QVBoxLayout(date_group)
# Начальная дата
start_layout = QHBoxLayout()
start_layout.addWidget(QLabel("Начало:"))
self.start_date = QDateEdit()
self.start_date.setDateTime(QDateTime.currentDateTime().addDays(-7))
self.start_date.setCalendarPopup(True)
start_layout.addWidget(self.start_date)
date_layout.addLayout(start_layout)
# Конечная дата
end_layout = QHBoxLayout()
end_layout.addWidget(QLabel("Конец:"))
self.end_date = QDateEdit()
self.end_date.setDateTime(QDateTime.currentDateTime())
self.end_date.setCalendarPopup(True)
end_layout.addWidget(self.end_date)
date_layout.addLayout(end_layout)
layout.addWidget(date_group)
# Параметры видео
video_group = QGroupBox("Параметры видео")
video_layout = QHBoxLayout(video_group)
video_layout.addWidget(QLabel("FPS:"))
self.fps_spin = QComboBox()
self.fps_spin.addItems(['5', '10', '15', '24', '30'])
self.fps_spin.setCurrentText('10')
video_layout.addWidget(self.fps_spin)
video_layout.addStretch()
layout.addWidget(video_group)
# Выбор папки сохранения
folder_group = QGroupBox("Папка сохранения")
folder_layout = QVBoxLayout(folder_group)
folder_select_layout = QHBoxLayout()
self.folder_path = QLabel("Не выбрана")
self.folder_path.setStyleSheet("color: gray;")
folder_select_layout.addWidget(self.folder_path, 1)
browse_button = QPushButton("Обзор...")
browse_button.clicked.connect(self.browse_folder)
folder_select_layout.addWidget(browse_button)
folder_layout.addLayout(folder_select_layout)
# Чекбокс "Запомнить путь"
self.remember_path_checkbox = QCheckBox("Запомнить этот путь")
self.remember_path_checkbox.setChecked(True)
folder_layout.addWidget(self.remember_path_checkbox)
layout.addWidget(folder_group)
# Прогресс бар
self.progress_bar = QProgressBar()
self.progress_bar.setVisible(False)
layout.addWidget(self.progress_bar)
# Лог сообщений
self.log_text = QTextEdit()
self.log_text.setMaximumHeight(150)
self.log_text.setReadOnly(True)
self.log_text.setVisible(False)
layout.addWidget(self.log_text)
# Кнопки
button_layout = QHBoxLayout()
self.create_button = QPushButton("Создать")
self.create_button.clicked.connect(self.start_timelapse)
button_layout.addWidget(self.create_button)
self.cancel_button = QPushButton("Отмена")
self.cancel_button.clicked.connect(self.cancel_timelapse)
self.cancel_button.setEnabled(False)
button_layout.addWidget(self.cancel_button)
close_button = QPushButton("Закрыть")
close_button.clicked.connect(self.reject)
button_layout.addWidget(close_button)
layout.addLayout(button_layout)
self.selected_folder = None
def populate_spectrums(self):
"""Заполняет список доступных спектров"""
from models.api_model import HelioviewerAPI
sources = HelioviewerAPI.get_available_sources()
for source_id, info in sources.items():
display_text = f"{info['observatory']} - {info['name']} ({info['wavelength']})"
self.spectrum_combo.addItem(display_text, source_id)
def browse_folder(self):
"""Выбор папки для сохранения"""
folder = QFileDialog.getExistingDirectory(self, "Выберите папку для сохранения")
if folder:
self.selected_folder = Path(folder)
self.folder_path.setText(str(self.selected_folder))
self.folder_path.setStyleSheet("color: green;")
if self.remember_path_checkbox.isChecked():
self.save_settings()
def save_settings(self):
"""Сохраняет настройки"""
if self.selected_folder:
self.settings.setValue("timelapse/last_folder", str(self.selected_folder))
self.settings.setValue("timelapse/remember_path", self.remember_path_checkbox.isChecked())
def load_settings(self):
"""Загружает настройки"""
remember = self.settings.value("timelapse/remember_path", True, type=bool)
self.remember_path_checkbox.setChecked(remember)
if remember:
last_folder = self.settings.value("timelapse/last_folder", "")
if last_folder and Path(last_folder).exists():
self.selected_folder = Path(last_folder)
self.folder_path.setText(str(self.selected_folder))
self.folder_path.setStyleSheet("color: green;")
def start_timelapse(self):
"""Запускает создание таймлапса"""
if not self.selected_folder:
QMessageBox.warning(self, "Внимание", "Выберите папку для сохранения")
return
source_id = self.spectrum_combo.currentData()
start_date = self.start_date.date().toPython()
end_date = self.end_date.date().toPython()
fps = int(self.fps_spin.currentText())
from datetime import datetime as dt
# Конвертируем date в datetime с правильным временем
start_datetime = dt(start_date.year, start_date.month, start_date.day, 12, 0, 0)
end_datetime = dt(end_date.year, end_date.month, end_date.day, 23, 59, 59)
days = (end_date - start_date).days + 1
filename = f"timelapse_{source_id}_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}.mp4"
output_path = self.selected_folder / filename
self.save_settings()
# Обновляем UI
self.create_button.setEnabled(False)
self.cancel_button.setEnabled(True)
self.progress_bar.setVisible(True)
self.log_text.setVisible(True)
self.progress_bar.setValue(0)
self.log_text.clear()
self.add_log(f"🚀 Запуск таймлапса: {days} файлов")
self.add_log(f"📁 Папка: {self.selected_folder}")
# Создаем и запускаем поток
self.worker = TimelapseWorker(source_id, start_datetime, end_datetime, output_path, fps)
self.worker.progress.connect(self.update_progress)
self.worker.log.connect(self.add_log)
self.worker.finished.connect(self.on_finished)
self.worker.start()
def cancel_timelapse(self):
"""Отмена создания таймлапса"""
if self.worker and self.worker.isRunning():
reply = QMessageBox.question(
self, "Отмена",
"Вы уверены? Все уже скачанные файлы будут удалены.",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.worker.cancel()
self.add_log("⏹️ Отмена процесса...")
self.cancel_button.setEnabled(False)
def update_progress(self, current, total, message):
"""Обновление прогресса"""
if total > 0:
percent = int((current / total) * 100)
self.progress_bar.setValue(percent)
def add_log(self, message):
"""Добавление сообщения в лог"""
timestamp = datetime.now().strftime("%H:%M:%S")
self.log_text.append(f"[{timestamp}] {message}")
self.log_text.verticalScrollBar().setValue(
self.log_text.verticalScrollBar().maximum()
)
def on_finished(self, success, message):
"""Обработка завершения"""
self.create_button.setEnabled(True)
self.cancel_button.setEnabled(False)
if success:
self.add_log(f"✅ ГОТОВО: {message}")
QMessageBox.information(self, "Готово", f"Таймлапс успешно создан!\n{message}")
self.accept()
else:
self.add_log(f"❌ Ошибка: {message}")
QMessageBox.critical(self, "Ошибка", message)
def closeEvent(self, event):
"""При закрытии окна"""
if self.worker and self.worker.isRunning():
reply = QMessageBox.question(
self, "Процесс выполняется",
"Создание таймлапса еще не завершено.\n\nЗакрыть окно?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.worker.cancel()
event.accept()
else:
event.ignore()
else:
event.accept()