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