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 (документация)
This commit is contained in:
admin i 2025-12-16 14:55:39 +03:00
parent 15f3b7f567
commit 992dc6a13f
6 changed files with 511 additions and 13 deletions

View File

@ -0,0 +1,84 @@
# Тестовые скрипты для сжатия изображений
Скрипты для проверки функций сжатия аватаров с поддержкой EXIF-ориентации.
## Быстрые тесты (рекомендуется для Windows)
### 1. Тест LDAP-сжатия (384px, 100KB, HIGH QUALITY)
```bash
python test_ldap_simple.py path/to/photo.jpg
```
### 2. Тест обычного сжатия аватаров (512px, 500KB)
```bash
python test_avatar_simple.py path/to/photo.jpg
```
## Интерактивные тесты (с эмодзи, могут не работать в Windows cmd)
### 3. Полный тест LDAP
```bash
python test_ldap_image_compression.py
```
### 4. Полный тест аватаров
```bash
python test_image_compression.py
```
## Что проверяют скрипты
✅ **EXIF ориентация** - фото с телефонов не переворачиваются
✅ **Сжатие размера** - процент уменьшения файла
✅ **Разрешение** - размер в пикселях до и после
✅ **Лимиты** - соответствие ограничениям (100KB для LDAP, 500KB для системы)
## Примеры вывода
### test_ldap_simple.py:
```
======================================================================
LDAP Image Compression Test: IMG_1234.jpg
======================================================================
BEFORE processing:
Size: 2453.67 KB
Resolution: 3024x4032
Mode: RGB, Format: JPEG
EXIF Orientation: Rotated 90 CW
Processing with normalize_avatar_to_jpeg(size_px=384, max_kb=100)...
HIGH QUALITY: progressive JPEG, 4:4:4 subsampling, quality 75-98
Saved to: IMG_1234_ldap.jpg
AFTER processing (LDAP format):
Size: 68.45 KB
Resolution: 288x384
Mode: RGB, Format: JPEG
EXIF Orientation: Normal
LDAP Compliance Check:
[OK] File size: 68.45 KB (limit: 100 KB)
Statistics:
Size reduction: 97.2%
Before: 3024x4032 (2453.67 KB)
After: 288x384 (68.45 KB)
[OK] Orientation fixed automatically!
Was: Rotated 90 CW
Now: Normal (rotated in pixels)
======================================================================
```
## Требования
- Python 3.8+
- Django (настройки проекта)
- Pillow (PIL)
## Примечания
- Скрипты автоматически настраивают Django
- Обработанные файлы сохраняются рядом с оригиналом
- LDAP версия: суффикс `_ldap.jpg`
- Обычная версия: суффикс `_compressed.jpg`

View File

@ -141,7 +141,10 @@ class UserService:
if dto.group_cns:
sync_user_groups_by_cns(conn, dn, set(dto.group_cns), do_write=True)
if dto.avatar_bytes:
avatar = normalize_avatar_to_jpeg(dto.avatar_bytes, max_kb=100)
# Увеличен размер до 384px для максимального качества в LDAP
avatar = normalize_avatar_to_jpeg(
dto.avatar_bytes, size_px=384, max_kb=100
)
if avatar:
modify_user_attrs(
conn, dn, {"thumbnailPhoto": avatar}, do_write=True
@ -636,7 +639,10 @@ class UserService:
# 5) Аватар
avatar_saved = False
if avatar_bytes:
avatar = normalize_avatar_to_jpeg(avatar_bytes, max_kb=100)
# Увеличен размер до 384px для максимального качества в LDAP
avatar = normalize_avatar_to_jpeg(
avatar_bytes, size_px=384, max_kb=100
)
if avatar:
modify_user_attrs(conn, dn, {"thumbnailPhoto": avatar}, do_write=True)
avatar_saved = True # Флаг для сохранения в БД

View File

@ -256,7 +256,10 @@ def export_users_update_profile(
avatar_bytes = avatar_field.read()
if avatar_bytes:
try:
avatar_bytes = normalize_avatar_to_jpeg(avatar_bytes)
# Увеличен размер до 384px для максимального качества в LDAP
avatar_bytes = normalize_avatar_to_jpeg(
avatar_bytes, size_px=384, max_kb=100
)
except Exception as exc:
logger.warning(
"[WARN] avatar: не удалось нормализовать для %s: %s",

View File

@ -13,18 +13,26 @@ except ImportError:
def normalize_avatar_to_jpeg(
data: bytes, *, size_px: int = 120, max_kb: int = 100
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): Квадратный размер целевого изображения (по умолчанию 120).
size_px (int): Максимальный размер изображения (по умолчанию 384).
Пропорции сохраняются, итоговое изображение может быть меньше.
max_kb (int): Максимальный размер файла в килобайтах
(ограничение AD ~100 KB).
Returns:
bytes: JPEG-данные, соответствующие ограничениям.
bytes: JPEG-данные, соответствующие ограничениям с правильной ориентацией.
Raises:
RuntimeError: Если Pillow недоступен или не удалось обработать
@ -40,16 +48,62 @@ def normalize_avatar_to_jpeg(
try:
img = Image.open(io.BytesIO(data))
img = img.convert("RGB")
img = img.resize((size_px, size_px))
# бинарный поиск по качеству для соблюдения max_kb
lo, hi = 50, 95
# ИСПРАВЛЕНИЕ: Применяем 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()
img.save(buf, format="JPEG", quality=mid, optimize=True)
# МАКСИМАЛЬНОЕ КАЧЕСТВО:
# - 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()
@ -58,9 +112,16 @@ def normalize_avatar_to_jpeg(
hi = mid - 1
if best is None:
# минимально возможное
# минимально возможное (с теми же настройками качества)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=50, optimize=True)
img.save(
buf,
format="JPEG",
quality=75,
optimize=True,
progressive=True,
subsampling=0,
)
best = buf.getvalue()
return best

View File

@ -0,0 +1,168 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Тестовый скрипт для проверки функции compress_avatar (система).
Упрощённая версия без эмодзи для Windows консоли.
Использование:
python test_avatar_simple.py <путь_к_файлу>
"""
import os
import sys
from pathlib import Path
# Добавляем путь к модулям Django
sys.path.insert(0, str(Path(__file__).parent))
# Настраиваем Django перед импортом
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
import django
django.setup()
try:
from PIL import Image
except ImportError:
print("ERROR: Pillow не установлен. Установите: pip install Pillow")
sys.exit(1)
from common.image_utils import compress_avatar
def get_image_info(image_path):
"""Получить информацию об изображении."""
try:
img = Image.open(image_path)
size = os.path.getsize(image_path)
# EXIF ориентация
orientation_code = None
orientation_name = "No EXIF"
try:
exif = img.getexif()
if exif:
orientation_code = exif.get(0x0112)
orientation_names = {
1: "Normal", 3: "Rotated 180",
6: "Rotated 90 CW", 8: "Rotated 270 CW"
}
orientation_name = orientation_names.get(orientation_code, f"Code {orientation_code}")
except:
pass
return {
'size': size,
'size_kb': size / 1024,
'dimensions': img.size,
'mode': img.mode,
'format': img.format,
'orientation_code': orientation_code,
'orientation_name': orientation_name
}
except Exception as e:
return {'error': str(e)}
def test_avatar_compression(input_path):
"""Тестировать сжатие аватара."""
print("\n" + "="*70)
print(f"Avatar Compression Test: {os.path.basename(input_path)}")
print("="*70)
# Информация ДО
print("\nBEFORE processing:")
info_before = get_image_info(input_path)
if 'error' in info_before:
print(f" ERROR: {info_before['error']}")
return False
print(f" Size: {info_before['size_kb']:.2f} KB ({info_before['size']:,} bytes)")
print(f" Resolution: {info_before['dimensions'][0]}x{info_before['dimensions'][1]}")
print(f" Mode: {info_before['mode']}, Format: {info_before['format']}")
print(f" EXIF Orientation: {info_before['orientation_name']}")
# Обработка
try:
with open(input_path, 'rb') as f:
original_data = f.read()
print("\nProcessing with compress_avatar(max_width=512, max_kb=500)...")
compressed_data = compress_avatar(original_data)
# Сохранение
output_path = input_path.replace('.', '_compressed.')
if not output_path.endswith('.jpg'):
output_path = os.path.splitext(output_path)[0] + '.jpg'
with open(output_path, 'wb') as f:
f.write(compressed_data)
print(f" Saved to: {output_path}")
except Exception as e:
print(f" ERROR: {e}")
import traceback
traceback.print_exc()
return False
# Информация ПОСЛЕ
print("\nAFTER processing:")
info_after = get_image_info(output_path)
if 'error' in info_after:
print(f" ERROR: {info_after['error']}")
return False
print(f" Size: {info_after['size_kb']:.2f} KB ({info_after['size']:,} bytes)")
print(f" Resolution: {info_after['dimensions'][0]}x{info_after['dimensions'][1]}")
print(f" Mode: {info_after['mode']}, Format: {info_after['format']}")
print(f" EXIF Orientation: {info_after['orientation_name']}")
# Статистика
size_reduction = ((info_before['size'] - info_after['size']) / info_before['size']) * 100
print(f"\nStatistics:")
print(f" Size reduction: {size_reduction:.1f}%")
print(f" Before: {info_before['dimensions'][0]}x{info_before['dimensions'][1]} ({info_before['size_kb']:.2f} KB)")
print(f" After: {info_after['dimensions'][0]}x{info_after['dimensions'][1]} ({info_after['size_kb']:.2f} KB)")
# Проверка ориентации
if info_before['orientation_code'] and info_before['orientation_code'] != 1:
if info_after['orientation_code'] is None or info_after['orientation_code'] == 1:
print(f" [OK] Orientation fixed automatically!")
print(f" Was: {info_before['orientation_name']}")
print(f" Now: Normal (rotated in pixels)")
else:
print(f" [WARN] EXIF orientation preserved: {info_after['orientation_name']}")
print("="*70)
return True
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: python test_avatar_simple.py <image_file>")
print("\nExample:")
print(" python test_avatar_simple.py photo.jpg")
sys.exit(1)
input_path = sys.argv[1]
if not os.path.exists(input_path):
print(f"ERROR: File not found: {input_path}")
sys.exit(1)
if not os.path.isfile(input_path):
print(f"ERROR: Not a file: {input_path}")
sys.exit(1)
try:
success = test_avatar_compression(input_path)
sys.exit(0 if success else 1)
except KeyboardInterrupt:
print("\n\nInterrupted by user")
sys.exit(130)
except Exception as e:
print(f"\nCRITICAL ERROR: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

176
backend/test_ldap_simple.py Normal file
View File

@ -0,0 +1,176 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Тестовый скрипт для проверки функции normalize_avatar_to_jpeg (LDAP).
Упрощённая версия без эмодзи для Windows консоли.
Использование:
python test_ldap_simple.py <путь_к_файлу>
"""
import os
import sys
from pathlib import Path
# Добавляем путь к модулям Django
sys.path.insert(0, str(Path(__file__).parent))
# Настраиваем Django перед импортом
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
import django
django.setup()
try:
from PIL import Image
except ImportError:
print("ERROR: Pillow не установлен. Установите: pip install Pillow")
sys.exit(1)
from employees.ldap.utils.image_utils import normalize_avatar_to_jpeg
def get_image_info(image_path):
"""Получить информацию об изображении."""
try:
img = Image.open(image_path)
size = os.path.getsize(image_path)
# EXIF ориентация
orientation_code = None
orientation_name = "No EXIF"
try:
exif = img.getexif()
if exif:
orientation_code = exif.get(0x0112)
orientation_names = {
1: "Normal", 3: "Rotated 180",
6: "Rotated 90 CW", 8: "Rotated 270 CW"
}
orientation_name = orientation_names.get(orientation_code, f"Code {orientation_code}")
except:
pass
return {
'size': size,
'size_kb': size / 1024,
'dimensions': img.size,
'mode': img.mode,
'format': img.format,
'orientation_code': orientation_code,
'orientation_name': orientation_name
}
except Exception as e:
return {'error': str(e)}
def test_ldap_compression(input_path):
"""Тестировать LDAP-сжатие одного файла."""
print("\n" + "="*70)
print(f"LDAP Image Compression Test: {os.path.basename(input_path)}")
print("="*70)
# Информация ДО
print("\nBEFORE processing:")
info_before = get_image_info(input_path)
if 'error' in info_before:
print(f" ERROR: {info_before['error']}")
return False
print(f" Size: {info_before['size_kb']:.2f} KB")
print(f" Resolution: {info_before['dimensions'][0]}x{info_before['dimensions'][1]}")
print(f" Mode: {info_before['mode']}, Format: {info_before['format']}")
print(f" EXIF Orientation: {info_before['orientation_name']}")
# Обработка
try:
with open(input_path, 'rb') as f:
original_data = f.read()
print("\nProcessing with normalize_avatar_to_jpeg(size_px=384, max_kb=100)...")
print(" HIGH QUALITY: progressive JPEG, 4:4:4 subsampling, quality 75-98")
compressed_data = normalize_avatar_to_jpeg(
original_data, size_px=384, max_kb=100
)
# Сохранение
output_path = input_path.replace('.', '_ldap.')
if not output_path.endswith('.jpg'):
output_path = os.path.splitext(output_path)[0] + '.jpg'
with open(output_path, 'wb') as f:
f.write(compressed_data)
print(f" Saved to: {output_path}")
except Exception as e:
print(f" ERROR: {e}")
import traceback
traceback.print_exc()
return False
# Информация ПОСЛЕ
print("\nAFTER processing (LDAP format):")
info_after = get_image_info(output_path)
if 'error' in info_after:
print(f" ERROR: {info_after['error']}")
return False
print(f" Size: {info_after['size_kb']:.2f} KB")
print(f" Resolution: {info_after['dimensions'][0]}x{info_after['dimensions'][1]}")
print(f" Mode: {info_after['mode']}, Format: {info_after['format']}")
print(f" EXIF Orientation: {info_after['orientation_name']}")
# Проверка лимитов
size_ok = info_after['size_kb'] <= 100
print(f"\nLDAP Compliance Check:")
print(f" [{'OK' if size_ok else 'FAIL'}] File size: {info_after['size_kb']:.2f} KB (limit: 100 KB)")
# Статистика
size_reduction = ((info_before['size'] - info_after['size']) / info_before['size']) * 100
print(f"\nStatistics:")
print(f" Size reduction: {size_reduction:.1f}%")
print(f" Before: {info_before['dimensions'][0]}x{info_before['dimensions'][1]} ({info_before['size_kb']:.2f} KB)")
print(f" After: {info_after['dimensions'][0]}x{info_after['dimensions'][1]} ({info_after['size_kb']:.2f} KB)")
# Проверка ориентации
if info_before['orientation_code'] and info_before['orientation_code'] != 1:
if info_after['orientation_code'] is None or info_after['orientation_code'] == 1:
print(f" [OK] Orientation fixed automatically!")
print(f" Was: {info_before['orientation_name']}")
print(f" Now: Normal (rotated in pixels)")
else:
print(f" [WARN] EXIF orientation preserved: {info_after['orientation_name']}")
print("="*70)
return True
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: python test_ldap_simple.py <image_file>")
print("\nExample:")
print(" python test_ldap_simple.py photo.jpg")
sys.exit(1)
input_path = sys.argv[1]
if not os.path.exists(input_path):
print(f"ERROR: File not found: {input_path}")
sys.exit(1)
if not os.path.isfile(input_path):
print(f"ERROR: Not a file: {input_path}")
sys.exit(1)
try:
success = test_ldap_compression(input_path)
sys.exit(0 if success else 1)
except KeyboardInterrupt:
print("\n\nInterrupted by user")
sys.exit(130)
except Exception as e:
print(f"\nCRITICAL ERROR: {e}")
import traceback
traceback.print_exc()
sys.exit(1)