190 lines
7.4 KiB
Python
190 lines
7.4 KiB
Python
"""Утилиты для обработки изображений (аватары).
|
||
|
||
Функции для нормализации и конвертации аватаров пользователей в формат,
|
||
совместимый с Active Directory.
|
||
"""
|
||
|
||
import io
|
||
|
||
try:
|
||
from PIL import Image
|
||
except ImportError:
|
||
Image = None # type: ignore
|
||
|
||
|
||
def _encode_jpeg(image, quality: int) -> bytes:
|
||
"""Кодирует JPEG, обходя ограничения буфера libjpeg для сложных фото."""
|
||
options = {
|
||
"format": "JPEG",
|
||
"quality": quality,
|
||
"subsampling": 0,
|
||
}
|
||
|
||
buffer = io.BytesIO()
|
||
try:
|
||
image.save(
|
||
buffer,
|
||
optimize=True,
|
||
progressive=True,
|
||
**options,
|
||
)
|
||
except OSError:
|
||
# Некоторые сборки libjpeg не могут оптимизировать высокоэнтропийное
|
||
# изображение через BytesIO. Обычный JPEG остаётся совместимым с AD.
|
||
buffer = io.BytesIO()
|
||
image.save(
|
||
buffer,
|
||
optimize=False,
|
||
progressive=False,
|
||
**options,
|
||
)
|
||
return buffer.getvalue()
|
||
|
||
|
||
def _best_jpeg_within_limit(
|
||
image,
|
||
*,
|
||
min_quality: int,
|
||
max_quality: int,
|
||
max_bytes: int,
|
||
) -> bytes | None:
|
||
"""Возвращает JPEG максимального качества в заданном лимите."""
|
||
best = None
|
||
low, high = min_quality, max_quality
|
||
|
||
while low <= high:
|
||
quality = (low + high) // 2
|
||
encoded = _encode_jpeg(image, quality)
|
||
if len(encoded) <= max_bytes:
|
||
best = encoded
|
||
low = quality + 1
|
||
else:
|
||
high = quality - 1
|
||
|
||
return best
|
||
|
||
|
||
def normalize_avatar_to_jpeg(
|
||
data: bytes, *, size_px: int = 384, max_kb: int = 100
|
||
) -> bytes:
|
||
"""Нормализует аватар в JPEG нужного размера и лимита.
|
||
|
||
ВАЖНО: Автоматически применяет EXIF-ориентацию перед обработкой.
|
||
Это предотвращает "переворачивание" фотографий, сделанных на телефоны
|
||
в портретной или перевёрнутой ориентации.
|
||
|
||
КАЧЕСТВО: Использует высокое разрешение (384px), progressive JPEG
|
||
и 4:4:4 subsampling для максимального качества лиц в пределах 100KB.
|
||
|
||
Args:
|
||
data (bytes): Входной образ (PNG/JPEG/...).
|
||
size_px (int): Максимальный размер изображения (по умолчанию 384).
|
||
Пропорции сохраняются, итоговое изображение может быть меньше.
|
||
max_kb (int): Максимальный размер файла в килобайтах
|
||
(ограничение AD ~100 KB).
|
||
|
||
Returns:
|
||
bytes: JPEG-данные, соответствующие ограничениям,
|
||
с правильной ориентацией.
|
||
|
||
Raises:
|
||
RuntimeError: Если Pillow недоступен или не удалось обработать
|
||
изображение.
|
||
ValueError: Если вход пустой.
|
||
"""
|
||
if not data:
|
||
raise ValueError("avatar: пустые данные")
|
||
if size_px <= 0:
|
||
raise ValueError("avatar: размер изображения должен быть положительным")
|
||
if max_kb <= 0:
|
||
raise ValueError("avatar: лимит размера должен быть положительным")
|
||
if Image is None:
|
||
raise RuntimeError(
|
||
"Pillow не установлен. Установите 'Pillow' для обработки аватаров."
|
||
)
|
||
|
||
try:
|
||
img = Image.open(io.BytesIO(data))
|
||
|
||
# ИСПРАВЛЕНИЕ: Применяем EXIF-ориентацию перед обработкой
|
||
# Это предотвращает "поворот" фотографий с телефонов
|
||
try:
|
||
# Получаем EXIF данные
|
||
exif = img.getexif()
|
||
# Тег 274 = Orientation
|
||
orientation = exif.get(0x0112) if exif else None
|
||
|
||
if orientation:
|
||
# Применяем поворот согласно EXIF
|
||
if orientation == 3:
|
||
img = img.rotate(180, expand=True)
|
||
elif orientation == 6:
|
||
img = img.rotate(270, expand=True)
|
||
elif orientation == 8:
|
||
img = img.rotate(90, expand=True)
|
||
except (AttributeError, KeyError, IndexError):
|
||
# Если EXIF недоступен или повреждён - продолжаем без ротации
|
||
pass
|
||
|
||
# Конвертируем в RGB (для JPEG)
|
||
if img.mode in ("RGBA", "LA", "P"):
|
||
# Создаем белый фон для прозрачных изображений
|
||
background = Image.new("RGB", img.size, (255, 255, 255))
|
||
if img.mode == "P":
|
||
img = img.convert("RGBA")
|
||
mask = img.split()[-1] if img.mode == "RGBA" else None
|
||
background.paste(img, mask=mask)
|
||
img = background
|
||
elif img.mode != "RGB":
|
||
img = img.convert("RGB")
|
||
|
||
# УЛУЧШЕНИЕ: Используем thumbnail для сохранения пропорций
|
||
# вместо resize, который растягивает изображение
|
||
img.thumbnail((size_px, size_px), Image.Resampling.LANCZOS)
|
||
|
||
max_bytes = max_kb * 1024
|
||
working = img
|
||
|
||
# Сначала сохраняем высокое качество. Если сложное изображение не
|
||
# помещается, уменьшаем разрешение, не нарушая жёсткий лимит LDAP.
|
||
while True:
|
||
best = _best_jpeg_within_limit(
|
||
working,
|
||
min_quality=75,
|
||
max_quality=98,
|
||
max_bytes=max_bytes,
|
||
)
|
||
if best is not None:
|
||
return best
|
||
|
||
if max(working.size) <= 64:
|
||
break
|
||
|
||
next_size = tuple(max(1, int(side * 0.85)) for side in working.size)
|
||
working = working.resize(next_size, Image.Resampling.LANCZOS)
|
||
|
||
# Аварийный диапазон качества нужен только для нетипично маленького
|
||
# лимита или высокоэнтропийного изображения.
|
||
while True:
|
||
best = _best_jpeg_within_limit(
|
||
working,
|
||
min_quality=20,
|
||
max_quality=74,
|
||
max_bytes=max_bytes,
|
||
)
|
||
if best is not None:
|
||
return best
|
||
|
||
if max(working.size) <= 1:
|
||
raise RuntimeError("avatar: невозможно уложить JPEG в заданный лимит")
|
||
|
||
next_size = tuple(max(1, int(side * 0.75)) for side in working.size)
|
||
working = working.resize(next_size, Image.Resampling.LANCZOS)
|
||
except Exception as exc: # pragma: no cover
|
||
raise RuntimeError(f"avatar: ошибка нормализации: {exc}") from exc
|
||
|
||
|
||
__all__ = [
|
||
"normalize_avatar_to_jpeg",
|
||
]
|