Добавлена автоматическая оптимизация аватаров: сжатие до 512x512 (до 500KB) + удаление старых файлов

This commit is contained in:
admin i 2025-11-30 10:56:52 +03:00
parent b636e13382
commit 61070160c8
3 changed files with 337 additions and 4 deletions

View 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"]

View 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'
)
)

View File

@ -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}")