diff --git a/backend/TEST_IMAGE_SCRIPTS.md b/backend/TEST_IMAGE_SCRIPTS.md new file mode 100644 index 00000000..1c47f554 --- /dev/null +++ b/backend/TEST_IMAGE_SCRIPTS.md @@ -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` diff --git a/backend/employees/ldap/services/user_service.py b/backend/employees/ldap/services/user_service.py index 1685e764..7a911a45 100644 --- a/backend/employees/ldap/services/user_service.py +++ b/backend/employees/ldap/services/user_service.py @@ -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 # Флаг для сохранения в БД diff --git a/backend/employees/ldap/sync_service.py b/backend/employees/ldap/sync_service.py index 5a690a61..bbbd47d9 100644 --- a/backend/employees/ldap/sync_service.py +++ b/backend/employees/ldap/sync_service.py @@ -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", diff --git a/backend/employees/ldap/utils/image_utils.py b/backend/employees/ldap/utils/image_utils.py index 2296a33f..7cc89461 100644 --- a/backend/employees/ldap/utils/image_utils.py +++ b/backend/employees/ldap/utils/image_utils.py @@ -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 diff --git a/backend/test_avatar_simple.py b/backend/test_avatar_simple.py new file mode 100644 index 00000000..b4c4faea --- /dev/null +++ b/backend/test_avatar_simple.py @@ -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 ") + 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) diff --git a/backend/test_ldap_simple.py b/backend/test_ldap_simple.py new file mode 100644 index 00000000..fdbed769 --- /dev/null +++ b/backend/test_ldap_simple.py @@ -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 ") + 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)