267 lines
10 KiB
Python
267 lines
10 KiB
Python
|
|
"""
|
|||
|
|
Утилиты для обработки изображений: нормализация, преобразование цветов и т.д.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
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
|