fuck yeah!
This commit is contained in:
parent
ccb53d9091
commit
da10f5e132
44 changed files with 3260 additions and 448 deletions
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue