fuck yeah!
This commit is contained in:
parent
ccb53d9091
commit
da10f5e132
44 changed files with 3260 additions and 448 deletions
78
.idea/workspace.xml
generated
78
.idea/workspace.xml
generated
|
|
@ -1,28 +1,72 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="01167edf-a4fe-4b9b-bd49-22d768ae9f8b" name="Changes" comment="" />
|
<list default="true" id="01167edf-a4fe-4b9b-bd49-22d768ae9f8b" name="Changes" comment="working">
|
||||||
|
<change afterPath="$PROJECT_DIR$/controllers/__init__.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/controllers/app_controller.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/controllers/layer_controller.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/controllers/timlapse_controller.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/info/nuitka compile.txt" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/models/__init__.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/models/api_model.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/models/image_model.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/models/timelapse_model.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/utils/__init__.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/utils/image_processor.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/utils/metadata_parser.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/utils/video_creator.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/views/__init__.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/views/canvas_widget.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/views/control_panel.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/views/layer_widget.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/views/main_window.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/views/metadata_viewer.py" afterDir="false" />
|
||||||
|
<change afterPath="$PROJECT_DIR$/views/timelapse_dialog.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/main.py" afterDir="false" />
|
||||||
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectColorInfo"><![CDATA[{
|
<component name="FileTemplateManagerImpl">
|
||||||
"associatedIndex": 1
|
<option name="RECENT_TEMPLATES">
|
||||||
}]]></component>
|
<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">{
|
||||||
|
"associatedIndex": 1
|
||||||
|
}</component>
|
||||||
<component name="ProjectId" id="3EwIJwKPDl7xmBDbQQN0GJ515Pu" />
|
<component name="ProjectId" id="3EwIJwKPDl7xmBDbQQN0GJ515Pu" />
|
||||||
|
<component name="ProjectLevelVcsManager">
|
||||||
|
<ConfirmationsSetting value="2" id="Add" />
|
||||||
|
</component>
|
||||||
<component name="ProjectViewState">
|
<component name="ProjectViewState">
|
||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
<option name="showLibraryContents" value="true" />
|
<option name="showLibraryContents" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PropertiesComponent"><![CDATA[{
|
<component name="PropertiesComponent">{
|
||||||
"keyToString": {
|
"keyToString": {
|
||||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"Python.fix_imports.executor": "Run",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"Python.main.executor": "Run",
|
||||||
"vue.rearranger.settings.migration": "true"
|
"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": "dev-ui",
|
||||||
|
"ignore.virus.scanning.warn.message": "true",
|
||||||
|
"nodejs_package_manager_path": "npm",
|
||||||
|
"vue.rearranger.settings.migration": "true"
|
||||||
}
|
}
|
||||||
}]]></component>
|
}</component>
|
||||||
<component name="SharedIndexes">
|
<component name="SharedIndexes">
|
||||||
<attachedChunks>
|
<attachedChunks>
|
||||||
<set>
|
<set>
|
||||||
|
|
@ -38,11 +82,21 @@
|
||||||
<option name="number" value="Default" />
|
<option name="number" value="Default" />
|
||||||
<option name="presentableId" value="Default" />
|
<option name="presentableId" value="Default" />
|
||||||
<updated>1781083136733</updated>
|
<updated>1781083136733</updated>
|
||||||
<workItem from="1781083137783" duration="1000" />
|
<workItem from="1781083137783" duration="433000" />
|
||||||
|
<workItem from="1781093057561" duration="1654000" />
|
||||||
|
<workItem from="1781095333974" duration="5303000" />
|
||||||
</task>
|
</task>
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
<option name="version" value="3" />
|
<option name="version" value="3" />
|
||||||
</component>
|
</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="1781100058327" 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>
|
</project>
|
||||||
10
controllers/__init__.py
Normal file
10
controllers/__init__.py
Normal 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'
|
||||||
|
]
|
||||||
BIN
controllers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
controllers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
controllers/__pycache__/app_controller.cpython-312.pyc
Normal file
BIN
controllers/__pycache__/app_controller.cpython-312.pyc
Normal file
Binary file not shown.
BIN
controllers/__pycache__/layer_controller.cpython-312.pyc
Normal file
BIN
controllers/__pycache__/layer_controller.cpython-312.pyc
Normal file
Binary file not shown.
BIN
controllers/__pycache__/timelapse_controller.cpython-312.pyc
Normal file
BIN
controllers/__pycache__/timelapse_controller.cpython-312.pyc
Normal file
Binary file not shown.
191
controllers/app_controller.py
Normal file
191
controllers/app_controller.py
Normal 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
|
||||||
384
controllers/layer_controller.py
Normal file
384
controllers/layer_controller.py
Normal 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()
|
||||||
117
controllers/timelapse_controller.py
Normal file
117
controllers/timelapse_controller.py
Normal 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
64
fix_imports.py
Normal 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
44
info/nuitka compile.txt
Normal 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 с зумом и панорамированием
|
||||||
|
✅ Выбор папки сохранения
|
||||||
|
✅ Неблокирующие операции (потоки для таймлапса)
|
||||||
466
main.py
466
main.py
|
|
@ -1,449 +1,43 @@
|
||||||
import tkinter as tk
|
#!/usr/bin/env python3
|
||||||
from tkinter import ttk, messagebox, filedialog
|
"""
|
||||||
import requests
|
Helioviewer Solar Viewer - Профессиональное приложение для просмотра снимков Солнца
|
||||||
from datetime import datetime
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
import os
|
import os
|
||||||
import json
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Добавляем путь к модулям
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
# --- Модель (Model) ---
|
from PySide6.QtWidgets import QApplication
|
||||||
class HelioviewerModel:
|
from PySide6.QtCore import Qt
|
||||||
"""
|
from controllers.app_controller import AppController
|
||||||
Модель отвечает за взаимодействие с Helioviewer API.
|
|
||||||
Она не зависит от интерфейса и содержит только бизнес-логику.
|
|
||||||
"""
|
|
||||||
BASE_URL = "https://api.helioviewer.org/v1/"
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.datasources = {} # Словарь для хранения доступных источников: {source_id: описание}
|
|
||||||
# Используем предустановленный список популярных каналов вместо динамической загрузки
|
|
||||||
self.init_default_sources()
|
|
||||||
|
|
||||||
def init_default_sources(self):
|
def main():
|
||||||
"""Инициализирует список популярных источников данных."""
|
"""Точка входа в приложение"""
|
||||||
self.datasources = {
|
# Включаем High DPI поддержку
|
||||||
# SDO (Solar Dynamics Observatory)
|
QApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||||
14: "🌞 SDO - AIA 335 (Fe XVI, корона, 2 млн K)",
|
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||||
13: "🔥 SDO - AIA 304 (He II, хромосфера, протуберанцы)",
|
)
|
||||||
12: "🌊 SDO - AIA 211 (Fe XIV, активные области)",
|
|
||||||
11: "💥 SDO - AIA 193 (Fe XII, корональные выбросы массы)",
|
|
||||||
10: "🌀 SDO - AIA 171 (Fe IX, спокойная корона, петли)",
|
|
||||||
9: "⚡ SDO - AIA 131 (Fe VIII, вспышечная плазма)",
|
|
||||||
8: "❄️ SDO - AIA 94 (Fe XVIII, горячие вспышки)",
|
|
||||||
|
|
||||||
# SOHO (Solar and Heliospheric Observatory)
|
app = QApplication(sys.argv)
|
||||||
0: "👁️ SOHO - EIT 171 (Fe IX/X)",
|
app.setApplicationName("Helioviewer Solar Viewer")
|
||||||
2: "🟡 SOHO - EIT 284 (Fe XV)",
|
app.setOrganizationName("SolarViewer")
|
||||||
4: "🌑 SOHO - LASCO C2 (коронограф, видимый свет)",
|
|
||||||
5: "🌘 SOHO - LASCO C3 (коронограф, широкое поле)",
|
|
||||||
|
|
||||||
# STEREO A и B
|
# Устанавливаем темную тему через QSS
|
||||||
16: "⭐ STEREO A - EUVI 195",
|
app.setStyle("Fusion")
|
||||||
17: "⭐ STEREO A - EUVI 171",
|
|
||||||
18: "⭐ STEREO A - EUVI 304",
|
|
||||||
19: "⭐ STEREO A - COR1",
|
|
||||||
20: "⭐ STEREO A - COR2",
|
|
||||||
|
|
||||||
# Дополнительные каналы
|
# Создаем контроллер (он создаст модель и представление)
|
||||||
1: "📊 SOHO - MDI (магнитограмма)",
|
controller = AppController()
|
||||||
3: "🌡️ SOHO - EIT 304 (He II)",
|
|
||||||
}
|
|
||||||
|
|
||||||
def load_datasources_from_api(self):
|
# Показываем главное окно
|
||||||
"""Пытается загрузить актуальный список источников из API (экспериментально)."""
|
controller.show_main_window()
|
||||||
url = f"{self.BASE_URL}getDataSources/"
|
|
||||||
try:
|
|
||||||
response = requests.get(url, timeout=10)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
# API может вернуть JSON или строку
|
sys.exit(app.exec())
|
||||||
if response.headers.get('content-type', '').startswith('application/json'):
|
|
||||||
data = response.json()
|
|
||||||
if isinstance(data, list):
|
|
||||||
new_sources = {}
|
|
||||||
for source in data:
|
|
||||||
if isinstance(source, dict) and 'sourceId' in source:
|
|
||||||
source_id = source.get('sourceId')
|
|
||||||
# Формируем читаемое описание
|
|
||||||
name = f"{source.get('observatory', '?')} - {source.get('instrument', '?')}"
|
|
||||||
measurement = source.get('measurement', '')
|
|
||||||
if measurement:
|
|
||||||
name += f" - {measurement}"
|
|
||||||
new_sources[source_id] = name
|
|
||||||
|
|
||||||
if new_sources:
|
|
||||||
self.datasources = new_sources
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Не удалось загрузить источники из API: {e}")
|
|
||||||
|
|
||||||
return False
|
# ИСПРАВЛЕНО: было if __name__ "__main__": , правильно:
|
||||||
|
|
||||||
def get_jp2_image(self, source_id, date, jpip_link=False):
|
|
||||||
"""
|
|
||||||
Получает JP2 изображение или JPIP ссылку на него.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
source_id (int): ID источника данных.
|
|
||||||
date (datetime): Желаемая дата и время снимка.
|
|
||||||
jpip_link (bool): Если True, возвращает JPIP-ссылку (строку).
|
|
||||||
Если False, возвращает бинарные данные изображения.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bytes or str: Данные изображения или JPIP-ссылка. None в случае ошибки.
|
|
||||||
"""
|
|
||||||
# Форматируем дату в ISO 8601 UTC, как требует API
|
|
||||||
formatted_date = date.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
|
|
||||||
if jpip_link:
|
|
||||||
# Для JPIP ссылки используем отдельный эндпоинт
|
|
||||||
url = f"{self.BASE_URL}getJPIPClosest/"
|
|
||||||
params = {
|
|
||||||
'sourceId': source_id,
|
|
||||||
'date': formatted_date
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# Для скачивания изображения
|
|
||||||
url = f"{self.BASE_URL}getJP2Image/"
|
|
||||||
params = {
|
|
||||||
'sourceId': source_id,
|
|
||||||
'date': formatted_date
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.get(url, params=params, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
if jpip_link:
|
|
||||||
# Возвращаем текст (JPIP ссылку)
|
|
||||||
return response.text.strip()
|
|
||||||
else:
|
|
||||||
# Возвращаем бинарные данные изображения
|
|
||||||
return response.content
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
print(f"Ошибка при загрузке: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_available_sources(self):
|
|
||||||
"""Возвращает словарь доступных источников данных."""
|
|
||||||
return self.datasources
|
|
||||||
|
|
||||||
|
|
||||||
# --- Представление (View) ---
|
|
||||||
class HelioviewerView:
|
|
||||||
"""
|
|
||||||
Представление отвечает за графический интерфейс пользователя (GUI).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, root, controller):
|
|
||||||
self.controller = controller
|
|
||||||
self.root = root
|
|
||||||
self.root.title("Helioviewer Солнечный загрузчик")
|
|
||||||
self.root.geometry("900x700")
|
|
||||||
self.root.resizable(True, True)
|
|
||||||
|
|
||||||
# Устанавливаем иконку (опционально)
|
|
||||||
try:
|
|
||||||
self.root.iconbitmap(default='icon.ico')
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Стили
|
|
||||||
style = ttk.Style()
|
|
||||||
style.theme_use('clam')
|
|
||||||
|
|
||||||
# Настройка цветов (темная тема для астрономического приложения)
|
|
||||||
self.root.configure(bg='#2b2b2b')
|
|
||||||
style.configure('TLabel', background='#2b2b2b', foreground='white')
|
|
||||||
style.configure('TFrame', background='#2b2b2b')
|
|
||||||
style.configure('TLabelframe', background='#2b2b2b', foreground='white')
|
|
||||||
style.configure('TLabelframe.Label', background='#2b2b2b', foreground='white')
|
|
||||||
style.configure('TButton', background='#3c3c3c', foreground='white')
|
|
||||||
style.configure('TCombobox', fieldbackground='#3c3c3c', foreground='white')
|
|
||||||
|
|
||||||
# Основной фрейм с прокруткой
|
|
||||||
self.canvas = tk.Canvas(root, bg='#2b2b2b', highlightthickness=0)
|
|
||||||
scrollbar = ttk.Scrollbar(root, orient="vertical", command=self.canvas.yview)
|
|
||||||
self.scrollable_frame = ttk.Frame(self.canvas)
|
|
||||||
|
|
||||||
self.scrollable_frame.bind(
|
|
||||||
"<Configure>",
|
|
||||||
lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))
|
|
||||||
)
|
|
||||||
|
|
||||||
self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
|
|
||||||
self.canvas.configure(yscrollcommand=scrollbar.set)
|
|
||||||
|
|
||||||
# Показываем прокрутку только если нужно
|
|
||||||
self.canvas.pack(side="left", fill="both", expand=True)
|
|
||||||
scrollbar.pack(side="right", fill="y")
|
|
||||||
|
|
||||||
self.canvas.bind_all("<MouseWheel>", self._on_mousewheel)
|
|
||||||
|
|
||||||
main_frame = self.scrollable_frame
|
|
||||||
|
|
||||||
# Заголовок
|
|
||||||
title_label = ttk.Label(main_frame, text="🌞 Helioviewer Солнечный загрузчик",
|
|
||||||
font=('Arial', 16, 'bold'))
|
|
||||||
title_label.pack(pady=10)
|
|
||||||
|
|
||||||
# 1. Фрейм для выбора источника данных
|
|
||||||
source_frame = ttk.LabelFrame(main_frame, text="📡 1. Выбор спектрального канала", padding="10")
|
|
||||||
source_frame.pack(fill=tk.X, pady=(0, 10), padx=10)
|
|
||||||
|
|
||||||
# Создаем фрейм с прокруткой для списка каналов
|
|
||||||
source_list_frame = ttk.Frame(source_frame)
|
|
||||||
source_list_frame.pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
# Текстовая метка
|
|
||||||
ttk.Label(source_list_frame, text="Доступные инструменты и спектры:").pack(anchor=tk.W, pady=(0, 5))
|
|
||||||
|
|
||||||
# Список с прокруткой для выбора канала
|
|
||||||
list_frame = ttk.Frame(source_list_frame)
|
|
||||||
list_frame.pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
scrollbar_list = ttk.Scrollbar(list_frame)
|
|
||||||
scrollbar_list.pack(side=tk.RIGHT, fill=tk.Y)
|
|
||||||
|
|
||||||
self.source_listbox = tk.Listbox(list_frame, yscrollcommand=scrollbar_list.set,
|
|
||||||
height=10, bg='#3c3c3c', fg='white',
|
|
||||||
selectmode=tk.SINGLE, font=('Consolas', 9))
|
|
||||||
self.source_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
||||||
scrollbar_list.config(command=self.source_listbox.yview)
|
|
||||||
|
|
||||||
# Привязываем выбор
|
|
||||||
self.source_listbox.bind('<<ListboxSelect>>', self.on_source_selected)
|
|
||||||
|
|
||||||
# Кнопка обновления
|
|
||||||
refresh_button = ttk.Button(source_frame, text="🔄 Обновить список",
|
|
||||||
command=self.controller.refresh_sources)
|
|
||||||
refresh_button.pack(pady=(10, 0))
|
|
||||||
|
|
||||||
# 2. Фрейм для выбора даты
|
|
||||||
date_frame = ttk.LabelFrame(main_frame, text="📅 2. Выбор даты и времени (UTC)", padding="10")
|
|
||||||
date_frame.pack(fill=tk.X, pady=(0, 10), padx=10)
|
|
||||||
|
|
||||||
# Дата
|
|
||||||
date_inner_frame = ttk.Frame(date_frame)
|
|
||||||
date_inner_frame.pack(fill=tk.X, pady=5)
|
|
||||||
ttk.Label(date_inner_frame, text="Дата (ГГГГ-ММ-ДД):").pack(side=tk.LEFT, padx=(0, 5))
|
|
||||||
self.date_entry = ttk.Entry(date_inner_frame, width=15)
|
|
||||||
self.date_entry.pack(side=tk.LEFT, padx=(0, 10))
|
|
||||||
self.date_entry.insert(0, datetime.now().strftime("%Y-%m-%d"))
|
|
||||||
|
|
||||||
# Кнопка "Сегодня"
|
|
||||||
today_button = ttk.Button(date_inner_frame, text="Сегодня",
|
|
||||||
command=self.set_today_date)
|
|
||||||
today_button.pack(side=tk.LEFT)
|
|
||||||
|
|
||||||
# Время
|
|
||||||
time_inner_frame = ttk.Frame(date_frame)
|
|
||||||
time_inner_frame.pack(fill=tk.X, pady=5)
|
|
||||||
ttk.Label(time_inner_frame, text="Время (ЧЧ:ММ:СС):").pack(side=tk.LEFT, padx=(0, 5))
|
|
||||||
self.time_entry = ttk.Entry(time_inner_frame, width=15)
|
|
||||||
self.time_entry.pack(side=tk.LEFT)
|
|
||||||
self.time_entry.insert(0, "12:00:00")
|
|
||||||
|
|
||||||
# Кнопка "Сейчас"
|
|
||||||
now_button = ttk.Button(time_inner_frame, text="Сейчас",
|
|
||||||
command=self.set_now_time)
|
|
||||||
now_button.pack(side=tk.LEFT, padx=(10, 0))
|
|
||||||
|
|
||||||
ttk.Label(time_inner_frame, text=" (Время в UTC)",
|
|
||||||
font=('TkDefaultFont', 8, 'italic')).pack(side=tk.LEFT, padx=(5, 0))
|
|
||||||
|
|
||||||
# 3. Фрейм для кнопок действий
|
|
||||||
action_frame = ttk.LabelFrame(main_frame, text="⚡ 3. Действия", padding="10")
|
|
||||||
action_frame.pack(fill=tk.X, pady=(0, 10), padx=10)
|
|
||||||
|
|
||||||
button_frame = ttk.Frame(action_frame)
|
|
||||||
button_frame.pack()
|
|
||||||
|
|
||||||
self.download_button = ttk.Button(button_frame, text="💾 Скачать изображение (JP2)",
|
|
||||||
command=self.controller.download_image,
|
|
||||||
width=25)
|
|
||||||
self.download_button.pack(side=tk.LEFT, padx=5)
|
|
||||||
|
|
||||||
self.get_jpip_button = ttk.Button(button_frame, text="🔗 Получить JPIP ссылку",
|
|
||||||
command=self.controller.get_jpip_link,
|
|
||||||
width=25)
|
|
||||||
self.get_jpip_button.pack(side=tk.LEFT, padx=5)
|
|
||||||
|
|
||||||
# 4. Текстовая область для вывода информации
|
|
||||||
info_frame = ttk.LabelFrame(main_frame, text="📝 4. Информация / JPIP ссылка", padding="10")
|
|
||||||
info_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10), padx=10)
|
|
||||||
|
|
||||||
self.info_text = tk.Text(info_frame, height=10, wrap=tk.WORD,
|
|
||||||
bg='#1e1e1e', fg='#00ff00',
|
|
||||||
selectbackground='#004400')
|
|
||||||
scrollbar_info = ttk.Scrollbar(info_frame, orient=tk.VERTICAL, command=self.info_text.yview)
|
|
||||||
self.info_text.configure(yscrollcommand=scrollbar_info.set)
|
|
||||||
scrollbar_info.pack(side=tk.RIGHT, fill=tk.Y)
|
|
||||||
self.info_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
# Статус бар
|
|
||||||
self.status_var = tk.StringVar()
|
|
||||||
self.status_var.set("🌙 Готов. Выберите спектральный канал и дату.")
|
|
||||||
status_bar = ttk.Label(root, textvariable=self.status_var, relief=tk.SUNKEN,
|
|
||||||
anchor=tk.W, padding=(5, 3))
|
|
||||||
status_bar.pack(side=tk.BOTTOM, fill=tk.X)
|
|
||||||
|
|
||||||
# Выделенный ID источника
|
|
||||||
self.selected_source_id = None
|
|
||||||
|
|
||||||
def _on_mousewheel(self, event):
|
|
||||||
self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
|
||||||
|
|
||||||
def on_source_selected(self, event):
|
|
||||||
selection = self.source_listbox.curselection()
|
|
||||||
if selection:
|
|
||||||
item_text = self.source_listbox.get(selection[0])
|
|
||||||
if ":" in item_text:
|
|
||||||
try:
|
|
||||||
self.selected_source_id = int(item_text.split(":", 1)[0])
|
|
||||||
self.update_status(f"Выбран канал: {item_text}")
|
|
||||||
except ValueError:
|
|
||||||
self.selected_source_id = None
|
|
||||||
|
|
||||||
def set_sources(self, sources_dict):
|
|
||||||
"""Обновляет список источников."""
|
|
||||||
self.source_listbox.delete(0, tk.END)
|
|
||||||
if not sources_dict:
|
|
||||||
self.source_listbox.insert(tk.END, "Нет доступных источников")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Сортируем по ID
|
|
||||||
for src_id in sorted(sources_dict.keys()):
|
|
||||||
display_text = f"{src_id}: {sources_dict[src_id]}"
|
|
||||||
self.source_listbox.insert(tk.END, display_text)
|
|
||||||
|
|
||||||
if self.source_listbox.size() > 0:
|
|
||||||
self.source_listbox.selection_set(0)
|
|
||||||
self.on_source_selected(None)
|
|
||||||
|
|
||||||
def set_today_date(self):
|
|
||||||
self.date_entry.delete(0, tk.END)
|
|
||||||
self.date_entry.insert(0, datetime.now().strftime("%Y-%m-%d"))
|
|
||||||
|
|
||||||
def set_now_time(self):
|
|
||||||
self.time_entry.delete(0, tk.END)
|
|
||||||
self.time_entry.insert(0, datetime.now().strftime("%H:%M:%S"))
|
|
||||||
|
|
||||||
def get_selected_source_id(self):
|
|
||||||
return self.selected_source_id
|
|
||||||
|
|
||||||
def get_selected_datetime(self):
|
|
||||||
"""Возвращает datetime объект из введенных пользователем даты и времени."""
|
|
||||||
date_str = self.date_entry.get()
|
|
||||||
time_str = self.time_entry.get()
|
|
||||||
datetime_str = f"{date_str} {time_str}"
|
|
||||||
try:
|
|
||||||
return datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S")
|
|
||||||
except ValueError:
|
|
||||||
messagebox.showerror("Ошибка ввода",
|
|
||||||
"Неверный формат даты или времени.\nИспользуйте ГГГГ-ММ-ДД и ЧЧ:ММ:СС.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def show_info(self, message, is_error=False):
|
|
||||||
"""Показывает сообщение в текстовой области."""
|
|
||||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
||||||
if is_error:
|
|
||||||
self.info_text.insert(tk.END, f"[{timestamp}] ❌ {message}\n", 'error')
|
|
||||||
self.info_text.tag_config('error', foreground='red')
|
|
||||||
else:
|
|
||||||
self.info_text.insert(tk.END, f"[{timestamp}] ✅ {message}\n", 'success')
|
|
||||||
self.info_text.tag_config('success', foreground='#00ff00')
|
|
||||||
self.info_text.see(tk.END)
|
|
||||||
|
|
||||||
def clear_info(self):
|
|
||||||
"""Очищает текстовую область."""
|
|
||||||
self.info_text.delete(1.0, tk.END)
|
|
||||||
|
|
||||||
def update_status(self, message):
|
|
||||||
"""Обновляет текст в статус-баре."""
|
|
||||||
self.status_var.set(message)
|
|
||||||
|
|
||||||
def ask_save_filename(self, default_name="helioviewer_image.jp2"):
|
|
||||||
"""Открывает диалог для выбора пути сохранения файла."""
|
|
||||||
filetypes = [("JPEG2000 files", "*.jp2"), ("All files", "*.*")]
|
|
||||||
return filedialog.asksaveasfilename(defaultextension=".jp2",
|
|
||||||
filetypes=filetypes,
|
|
||||||
initialfile=default_name)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Контроллер (Controller) ---
|
|
||||||
class HelioviewerController:
|
|
||||||
"""
|
|
||||||
Контроллер связывает Model и View.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, root):
|
|
||||||
self.model = HelioviewerModel()
|
|
||||||
self.view = HelioviewerView(root, self)
|
|
||||||
self.refresh_sources() # Инициализируем список источников
|
|
||||||
|
|
||||||
def refresh_sources(self):
|
|
||||||
"""Обновляет список источников."""
|
|
||||||
self.view.update_status("🔄 Загрузка списка источников...")
|
|
||||||
sources = self.model.get_available_sources()
|
|
||||||
self.view.set_sources(sources)
|
|
||||||
self.view.update_status(f"✅ Загружено {len(sources)} спектральных каналов")
|
|
||||||
self.view.show_info(f"Загружено {len(sources)} источников данных")
|
|
||||||
|
|
||||||
def _perform_action(self, get_jpip):
|
|
||||||
"""Общая логика для скачивания или получения ссылки."""
|
|
||||||
source_id = self.view.get_selected_source_id()
|
|
||||||
if source_id is None:
|
|
||||||
messagebox.showwarning("Нет источника", "Пожалуйста, выберите спектральный канал.")
|
|
||||||
return
|
|
||||||
|
|
||||||
date_time = self.view.get_selected_datetime()
|
|
||||||
if date_time is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
action_name = "JPIP ссылки" if get_jpip else "изображения"
|
|
||||||
self.view.update_status(f"📡 Запрос {action_name} для канала {source_id}...")
|
|
||||||
self.view.show_info(f"Запрашиваю {action_name} для {date_time} (канал {source_id})")
|
|
||||||
|
|
||||||
result = self.model.get_jp2_image(source_id, date_time, jpip_link=get_jpip)
|
|
||||||
|
|
||||||
if result is None:
|
|
||||||
self.view.update_status(f"❌ Не удалось получить {action_name}.")
|
|
||||||
self.view.show_info(f"Не удалось получить {action_name}", is_error=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
if get_jpip:
|
|
||||||
# Показываем JPIP ссылку
|
|
||||||
self.view.show_info(f"JPIP ссылка получена:\n{result}")
|
|
||||||
self.view.update_status("✅ JPIP ссылка получена. Можно вставить в JHelioviewer")
|
|
||||||
else:
|
|
||||||
# Сохраняем изображение
|
|
||||||
default_name = f"solar_{source_id}_{date_time.strftime('%Y%m%d_%H%M%S')}.jp2"
|
|
||||||
filename = self.view.ask_save_filename(default_name)
|
|
||||||
if filename:
|
|
||||||
try:
|
|
||||||
with open(filename, 'wb') as f:
|
|
||||||
f.write(result)
|
|
||||||
file_size = len(result) / 1024 # размер в КБ
|
|
||||||
self.view.show_info(f"Изображение сохранено: {filename}\nРазмер: {file_size:.1f} KB")
|
|
||||||
self.view.update_status(f"✅ Изображение сохранено: {os.path.basename(filename)}")
|
|
||||||
except IOError as e:
|
|
||||||
self.view.show_info(f"Ошибка сохранения: {e}", is_error=True)
|
|
||||||
self.view.update_status("❌ Ошибка сохранения")
|
|
||||||
else:
|
|
||||||
self.view.update_status("Сохранение отменено")
|
|
||||||
|
|
||||||
def download_image(self):
|
|
||||||
"""Скачивает изображение (JP2)"""
|
|
||||||
self._perform_action(get_jpip=False)
|
|
||||||
|
|
||||||
def get_jpip_link(self):
|
|
||||||
"""Получает JPIP ссылку"""
|
|
||||||
self._perform_action(get_jpip=True)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Точка входа в программу ---
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
root = tk.Tk()
|
main()
|
||||||
app = HelioviewerController(root)
|
|
||||||
root.mainloop()
|
|
||||||
13
models/__init__.py
Normal file
13
models/__init__.py
Normal 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'
|
||||||
|
]
|
||||||
BIN
models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/api_model.cpython-312.pyc
Normal file
BIN
models/__pycache__/api_model.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/image_model.cpython-312.pyc
Normal file
BIN
models/__pycache__/image_model.cpython-312.pyc
Normal file
Binary file not shown.
BIN
models/__pycache__/timelapse_model.cpython-312.pyc
Normal file
BIN
models/__pycache__/timelapse_model.cpython-312.pyc
Normal file
Binary file not shown.
122
models/api_model.py
Normal file
122
models/api_model.py
Normal 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
111
models/image_model.py
Normal 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
227
models/timelapse_model.py
Normal 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"
|
||||||
BIN
tl/timelapse_14_20260401_to_20260610.mp4
Normal file
BIN
tl/timelapse_14_20260401_to_20260610.mp4
Normal file
Binary file not shown.
BIN
tl/timelapse_14_20260603_to_20260610.mp4
Normal file
BIN
tl/timelapse_14_20260603_to_20260610.mp4
Normal file
Binary file not shown.
10
utils/__init__.py
Normal file
10
utils/__init__.py
Normal 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'
|
||||||
|
]
|
||||||
BIN
utils/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
utils/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/image_processor.cpython-312.pyc
Normal file
BIN
utils/__pycache__/image_processor.cpython-312.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/metadata_parser.cpython-312.pyc
Normal file
BIN
utils/__pycache__/metadata_parser.cpython-312.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/video_creator.cpython-312.pyc
Normal file
BIN
utils/__pycache__/video_creator.cpython-312.pyc
Normal file
Binary file not shown.
267
utils/image_processor.py
Normal file
267
utils/image_processor.py
Normal 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
225
utils/metadata_parser.py
Normal 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
89
utils/video_creator.py
Normal 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
16
views/__init__.py
Normal 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'
|
||||||
|
]
|
||||||
BIN
views/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
views/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
views/__pycache__/canvas_widget.cpython-312.pyc
Normal file
BIN
views/__pycache__/canvas_widget.cpython-312.pyc
Normal file
Binary file not shown.
BIN
views/__pycache__/control_panel.cpython-312.pyc
Normal file
BIN
views/__pycache__/control_panel.cpython-312.pyc
Normal file
Binary file not shown.
BIN
views/__pycache__/layer_widget.cpython-312.pyc
Normal file
BIN
views/__pycache__/layer_widget.cpython-312.pyc
Normal file
Binary file not shown.
BIN
views/__pycache__/main_window.cpython-312.pyc
Normal file
BIN
views/__pycache__/main_window.cpython-312.pyc
Normal file
Binary file not shown.
BIN
views/__pycache__/metadata_viewer.cpython-312.pyc
Normal file
BIN
views/__pycache__/metadata_viewer.cpython-312.pyc
Normal file
Binary file not shown.
BIN
views/__pycache__/timelapse_dialog.cpython-312.pyc
Normal file
BIN
views/__pycache__/timelapse_dialog.cpython-312.pyc
Normal file
Binary file not shown.
149
views/canvas_widget.py
Normal file
149
views/canvas_widget.py
Normal 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
159
views/control_panel.py
Normal 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
219
views/layer_widget.py
Normal 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
251
views/main_window.py
Normal 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
126
views/metadata_viewer.py
Normal 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
370
views/timelapse_dialog.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue