Добавлена автоматическая оптимизация аватаров: сжатие до 512x512 (до 500KB) + удаление старых файлов
This commit is contained in:
parent
b636e13382
commit
61070160c8
122
backend/common/image_utils.py
Normal file
122
backend/common/image_utils.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""Утилиты для обработки изображений.
|
||||
|
||||
Функции для сжатия и оптимизации изображений при загрузке.
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
Image = None
|
||||
|
||||
|
||||
def compress_image(
|
||||
data: bytes,
|
||||
*,
|
||||
max_width: int = 512,
|
||||
max_height: int = 512,
|
||||
max_kb: int = 500,
|
||||
quality: int = 85,
|
||||
format: str = "JPEG",
|
||||
) -> bytes:
|
||||
"""Сжимает изображение с сохранением пропорций.
|
||||
|
||||
Функция оптимизирована для аватаров, которые будут использоваться
|
||||
в системах распознавания лиц. Размер 512x512 обеспечивает достаточное
|
||||
качество для нейросетей при разумном размере файла.
|
||||
|
||||
Args:
|
||||
data: Входные данные изображения (любой формат, поддерживаемый PIL)
|
||||
max_width: Максимальная ширина в пикселях (по умолчанию 512)
|
||||
max_height: Максимальная высота в пикселях (по умолчанию 512)
|
||||
max_kb: Максимальный размер файла в килобайтах (по умолчанию 500)
|
||||
quality: Начальное качество JPEG (по умолчанию 85)
|
||||
format: Формат выходного файла (по умолчанию JPEG)
|
||||
|
||||
Returns:
|
||||
bytes: Сжатые данные изображения
|
||||
|
||||
Raises:
|
||||
RuntimeError: Если Pillow не установлен или ошибка обработки
|
||||
ValueError: Если входные данные пусты
|
||||
"""
|
||||
if not data:
|
||||
raise ValueError("Пустые данные изображения")
|
||||
|
||||
if Image is None:
|
||||
raise RuntimeError(
|
||||
"Pillow не установлен. Выполните: pip install Pillow"
|
||||
)
|
||||
|
||||
try:
|
||||
# Открываем изображение
|
||||
img = Image.open(io.BytesIO(data))
|
||||
|
||||
# Конвертируем в 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")
|
||||
|
||||
# Изменяем размер с сохранением пропорций
|
||||
img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Бинарный поиск по качеству для соблюдения max_kb
|
||||
lo, hi = 50, quality
|
||||
best = None
|
||||
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format=format, quality=mid, optimize=True)
|
||||
size_kb = len(buf.getvalue()) / 1024
|
||||
|
||||
if size_kb <= max_kb:
|
||||
best = buf.getvalue()
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid - 1
|
||||
|
||||
# Если не удалось уложиться в лимит, используем минимальное качество
|
||||
if best is None:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format=format, quality=50, optimize=True)
|
||||
best = buf.getvalue()
|
||||
|
||||
return best
|
||||
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Ошибка обработки изображения: {exc}") from exc
|
||||
|
||||
|
||||
def compress_avatar(data: bytes) -> bytes:
|
||||
"""Сжимает аватар пользователя с оптимальными параметрами.
|
||||
|
||||
Специализированная версия compress_image для аватаров:
|
||||
- Размер 512x512 (достаточно для распознавания лиц)
|
||||
- Максимум 500KB (баланс между качеством и размером)
|
||||
- Качество 85 (хорошее качество для лиц)
|
||||
|
||||
Args:
|
||||
data: Входные данные изображения
|
||||
|
||||
Returns:
|
||||
bytes: Сжатые данные аватара
|
||||
"""
|
||||
return compress_image(
|
||||
data,
|
||||
max_width=512,
|
||||
max_height=512,
|
||||
max_kb=500,
|
||||
quality=85,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["compress_image", "compress_avatar"]
|
||||
180
backend/employees/management/commands/optimize_avatars.py
Normal file
180
backend/employees/management/commands/optimize_avatars.py
Normal file
@ -0,0 +1,180 @@
|
||||
"""
|
||||
Management команда для очистки и оптимизации аватаров сотрудников.
|
||||
|
||||
Функции:
|
||||
1. Удаление неиспользуемых файлов аватаров
|
||||
2. Сжатие существующих аватаров до оптимального размера
|
||||
"""
|
||||
|
||||
import os
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.files.base import ContentFile
|
||||
from employees.models import Employee
|
||||
from common.image_utils import compress_avatar
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Очистка неиспользуемых аватаров и оптимизация существующих'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--compress',
|
||||
action='store_true',
|
||||
help='Сжать существующие аватары',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--cleanup',
|
||||
action='store_true',
|
||||
help='Удалить неиспользуемые файлы',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Показать что будет сделано без реального выполнения',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
do_compress = options['compress']
|
||||
do_cleanup = options['cleanup']
|
||||
|
||||
# Если не указаны опции, делаем всё
|
||||
if not do_compress and not do_cleanup:
|
||||
do_compress = True
|
||||
do_cleanup = True
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
'РЕЖИМ ПРОСМОТРА - изменения не применяются'
|
||||
)
|
||||
)
|
||||
|
||||
# Получаем все используемые файлы аватаров
|
||||
employees = Employee.objects.exclude(avatar='').exclude(avatar=None)
|
||||
used_files = set()
|
||||
|
||||
for emp in employees:
|
||||
if emp.avatar:
|
||||
try:
|
||||
used_files.add(os.path.basename(emp.avatar.path))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.stdout.write(
|
||||
f'Найдено {len(used_files)} используемых аватаров'
|
||||
)
|
||||
|
||||
# Очистка неиспользуемых файлов
|
||||
if do_cleanup:
|
||||
self.stdout.write('\n=== ОЧИСТКА НЕИСПОЛЬЗУЕМЫХ ФАЙЛОВ ===')
|
||||
avatar_dir = os.path.join('media', 'users', 'avatars')
|
||||
|
||||
if not os.path.exists(avatar_dir):
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'Директория {avatar_dir} не найдена')
|
||||
)
|
||||
else:
|
||||
deleted_count = 0
|
||||
freed_mb = 0
|
||||
|
||||
for filename in os.listdir(avatar_dir):
|
||||
filepath = os.path.join(avatar_dir, filename)
|
||||
|
||||
if not os.path.isfile(filepath):
|
||||
continue
|
||||
|
||||
if filename not in used_files:
|
||||
size_mb = os.path.getsize(filepath) / (1024 * 1024)
|
||||
freed_mb += size_mb
|
||||
|
||||
self.stdout.write(
|
||||
f' Удаление: {filename} ({size_mb:.2f} MB)'
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
try:
|
||||
os.remove(filepath)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f' Ошибка удаления: {e}'
|
||||
)
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nУдалено файлов: {deleted_count}, '
|
||||
f'освобождено: {freed_mb:.2f} MB'
|
||||
)
|
||||
)
|
||||
|
||||
# Сжатие существующих аватаров
|
||||
if do_compress:
|
||||
self.stdout.write('\n=== СЖАТИЕ СУЩЕСТВУЮЩИХ АВАТАРОВ ===')
|
||||
compressed_count = 0
|
||||
saved_mb = 0
|
||||
|
||||
for emp in employees:
|
||||
if not emp.avatar:
|
||||
continue
|
||||
|
||||
try:
|
||||
original_path = emp.avatar.path
|
||||
original_size = os.path.getsize(original_path)
|
||||
|
||||
# Читаем оригинальный файл
|
||||
with open(original_path, 'rb') as f:
|
||||
original_data = f.read()
|
||||
|
||||
# Сжимаем
|
||||
compressed_data = compress_avatar(original_data)
|
||||
compressed_size = len(compressed_data)
|
||||
|
||||
# Проверяем, дало ли сжатие выигрыш
|
||||
if compressed_size < original_size * 0.95: # минимум 5%
|
||||
size_diff = original_size - compressed_size
|
||||
saved = size_diff / (1024 * 1024)
|
||||
saved_mb += saved
|
||||
|
||||
self.stdout.write(
|
||||
f' Сжатие: {emp.get_full_name()} '
|
||||
f'({original_size/1024:.1f}KB → '
|
||||
f'{compressed_size/1024:.1f}KB, '
|
||||
f'экономия {saved*1024:.1f}KB)'
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
# Сохраняем сжатый файл
|
||||
filename = os.path.basename(original_path)
|
||||
if not filename.lower().endswith('.jpg'):
|
||||
filename = filename.rsplit('.', 1)[0] + '.jpg'
|
||||
|
||||
emp.avatar.save(
|
||||
filename,
|
||||
ContentFile(compressed_data),
|
||||
save=True
|
||||
)
|
||||
compressed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f' Ошибка сжатия для {emp.get_full_name()}: {e}'
|
||||
)
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nСжато файлов: {compressed_count}, '
|
||||
f'сэкономлено: {saved_mb:.2f} MB'
|
||||
)
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
'\nДля применения изменений запустите без --dry-run'
|
||||
)
|
||||
)
|
||||
@ -1,5 +1,6 @@
|
||||
# users/signals.py
|
||||
import os
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
@ -10,10 +11,41 @@ from bots.models import BotSubscriber
|
||||
|
||||
|
||||
@receiver(pre_save, sender=Employee)
|
||||
def delete_old_avatar_on_change(sender, instance: Employee, **kwargs):
|
||||
"""Удаляем старый файл аватара при замене на новый или при удалении."""
|
||||
def compress_and_cleanup_avatar(sender, instance: Employee, **kwargs):
|
||||
"""Сжимает новый аватар и удаляет старый файл при замене."""
|
||||
# Импортируем здесь, чтобы избежать циклических импортов
|
||||
from common.image_utils import compress_avatar
|
||||
|
||||
# Проверяем, загружен ли новый файл аватара
|
||||
if instance.avatar and hasattr(instance.avatar, 'file'):
|
||||
try:
|
||||
# Читаем данные нового аватара
|
||||
instance.avatar.seek(0)
|
||||
original_data = instance.avatar.read()
|
||||
|
||||
# Сжимаем изображение
|
||||
compressed_data = compress_avatar(original_data)
|
||||
|
||||
# Если сжатие дало результат меньшего размера, заменяем
|
||||
if len(compressed_data) < len(original_data):
|
||||
# Получаем имя файла
|
||||
filename = instance.avatar.name.split('/')[-1]
|
||||
if not filename.lower().endswith('.jpg'):
|
||||
# Меняем расширение на .jpg
|
||||
filename = filename.rsplit('.', 1)[0] + '.jpg'
|
||||
|
||||
# Заменяем файл на сжатый
|
||||
instance.avatar.save(
|
||||
filename,
|
||||
ContentFile(compressed_data),
|
||||
save=False
|
||||
)
|
||||
except Exception as e:
|
||||
# Логируем ошибку, но не прерываем сохранение
|
||||
print(f"Error compressing avatar: {e}")
|
||||
|
||||
# Удаляем старый файл если он был заменен
|
||||
if instance.pk is None:
|
||||
# Новый пользователь - нечего удалять
|
||||
return
|
||||
|
||||
try:
|
||||
@ -28,7 +60,6 @@ def delete_old_avatar_on_change(sender, instance: Employee, **kwargs):
|
||||
try:
|
||||
os.remove(old.avatar.path)
|
||||
except Exception as e:
|
||||
# Логируем ошибку, но не прерываем сохранение
|
||||
print(f"Error deleting old avatar: {e}")
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user