Corp/backend/employees/ldap/utils/image_utils.py
Игорь Надеин 992dc6a13f feat: maximize LDAP avatar quality (384px, progressive JPEG, 4:4:4)
- Увеличено разрешение: 120px -> 384px (+900% пикселей)
- Повышено качество JPEG: 50-95 -> 75-98
- Добавлен progressive JPEG для лучшего сжатия
- Включён subsampling 4:4:4 (без потери цветности)
- Добавлена обработка EXIF ориентации
- Добавлено сохранение пропорций (thumbnail вместо resize)
- Обработка прозрачности PNG

Улучшения в:
- employees/ldap/utils/image_utils.py
- employees/ldap/services/user_service.py
- employees/ldap/sync_service.py

Добавлены тестовые скрипты:
- test_ldap_simple.py (быстрый тест)
- test_avatar_simple.py (тест системного сжатия)
- TEST_IMAGE_SCRIPTS.md (документация)
2025-12-16 14:55:58 +03:00

135 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Утилиты для обработки изображений (аватары).
Функции для нормализации и конвертации аватаров пользователей в формат,
совместимый с Active Directory.
"""
import io
try:
from PIL import Image
except ImportError:
Image = None # type: ignore
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 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_kb
# КАЧЕСТВО: Повышен минимум до 75, используется progressive и 4:4:4
lo, hi = 75, 98
best = None
while lo <= hi:
mid = (lo + hi) // 2
buf = io.BytesIO()
# МАКСИМАЛЬНОЕ КАЧЕСТВО:
# - progressive: лучше сжатие, плавная загрузка
# - subsampling=0: 4:4:4 (без потери цветности, лучше для лиц)
# - optimize: оптимизация таблиц Хаффмана
img.save(
buf,
format="JPEG",
quality=mid,
optimize=True,
progressive=True,
subsampling=0, # 4:4:4 - без потери цветности
)
kb = len(buf.getvalue()) // 1024
if kb <= max_kb:
best = buf.getvalue()
lo = mid + 1
else:
hi = mid - 1
if best is None:
# минимально возможное (с теми же настройками качества)
buf = io.BytesIO()
img.save(
buf,
format="JPEG",
quality=75,
optimize=True,
progressive=True,
subsampling=0,
)
best = buf.getvalue()
return best
except Exception as exc: # pragma: no cover
raise RuntimeError(f"avatar: ошибка нормализации: {exc}") from exc
__all__ = [
"normalize_avatar_to_jpeg",
]