refactor: Move image processing configuration to settings

Refactored image processing system to use centralized configuration in settings.IMAGE_PROCESSING_CONFIG instead of hardcoded values.

Changes:
- Added IMAGE_PROCESSING_CONFIG to settings with configurable sizes, formats, and quality
- Rewrote ImageProcessor to use dynamic configuration from settings
- Added support for multiple image formats (JPEG, WebP, PNG)
- Updated _save_image_version() to handle different formats and quality levels
- Added original image scaling (max 2160×2160) and square aspect ratio
- Updated ImageService to work with different file extensions (.jpg, .webp, .png)
- All parameters now easily configurable without code changes

Configuration:
- Original: JPEG, quality 100, max 2160×2160 (always square)
- Large: WebP, quality 90, 1200×1200
- Medium: WebP, quality 85, 600×600
- Thumbnail: WebP, quality 80, 200×200

Benefits:
- Flexible and maintainable configuration
- Smaller file sizes (WebP for resized images)
- Maximum quality for originals (JPEG 100)
- Square aspect ratio for better consistency

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-22 23:34:14 +03:00
parent a9b16bf212
commit f12fd18190
4 changed files with 529 additions and 62 deletions

View File

@@ -1,55 +1,77 @@
"""
Утилита для обработки и изменения размера изображений товаров, комплектов и категорий.
Автоматически создает несколько версий (original, thumbnail, medium, large) при сохранении.
Конфигурация берется из settings.IMAGE_PROCESSING_CONFIG
"""
import os
import logging
from io import BytesIO
from PIL import Image
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.conf import settings
logger = logging.getLogger(__name__)
class ImageProcessor:
"""
Обработчик изображений с поддержкой создания нескольких размеров.
Сохраняет изображения в разные папки в зависимости от размера.
Обработчик изображений с поддержкой создания нескольких размеров и форматов.
Использует конфигурацию из settings.IMAGE_PROCESSING_CONFIG
"""
# Размеры изображений в пикселях
SIZES = {
'thumbnail': (150, 150),
'medium': (400, 400),
'large': (800, 800),
}
# Папки для сохранения (будут создаваться внутри products/, kits/, categories/)
SIZE_FOLDERS = {
'thumbnail': 'thumbnails',
'medium': 'medium',
'large': 'large',
'original': 'originals',
}
# Качество JPEG (0-100)
JPEG_QUALITY = 90
@staticmethod
def _get_config():
"""Получить конфигурацию из settings"""
return getattr(settings, 'IMAGE_PROCESSING_CONFIG', {})
@staticmethod
def process_image(image_file, base_path):
def _get_size_dimensions(size_key):
"""Получить размеры для заданного типа изображения"""
config = ImageProcessor._get_config()
formats = config.get('formats', {})
size_config = formats.get(size_key, {})
# Для оригинала используем max_width/max_height
if size_key == 'original':
return (size_config.get('max_width', 2160), size_config.get('max_height', 2160))
else:
return (size_config.get('width', 400), size_config.get('height', 400))
@staticmethod
def _get_format_config(size_key):
"""Получить конфигурацию формата для заданного типа изображения"""
config = ImageProcessor._get_config()
formats = config.get('formats', {})
return formats.get(size_key, {'format': 'JPEG', 'quality': 90})
@staticmethod
def _get_folder(size_key):
"""Получить папку для сохранения заданного типа изображения"""
config = ImageProcessor._get_config()
folders = config.get('folders', {})
return folders.get(size_key, size_key)
@staticmethod
def process_image(image_file, base_path, identifier=None):
"""
Обрабатывает загруженное изображение и создает несколько версий.
Args:
image_file: Загруженный файл изображения (InMemoryUploadedFile)
base_path: Базовый путь для сохранения (например, 'products', 'kits', 'categories')
identifier: (Optional) Идентификатор товара/категории (slug, SKU, имя)
для более понятного имени файла.
Пример: 'robot-50cm', 'bouquet-red', 'category-flowers'
Returns:
dict: Словарь с путями сохраненных файлов
{
'original': 'products/originals/image_12345.jpg',
'thumbnail': 'products/thumbnails/image_12345.jpg',
'medium': 'products/medium/image_12345.jpg',
'large': 'products/large/image_12345.jpg',
'original': 'products/originals/robot-50cm_1729611234567_original.jpg',
'large': 'products/large/robot-50cm_1729611234567_large.webp',
'medium': 'products/medium/robot-50cm_1729611234567_medium.webp',
'thumbnail': 'products/thumbnails/robot-50cm_1729611234567_thumbnail.webp',
}
Raises:
@@ -59,7 +81,7 @@ class ImageProcessor:
# Открываем изображение
img = Image.open(image_file)
# Конвертируем в RGB если необходимо (для JPEG)
# Конвертируем в RGB если необходимо (для JPEG/WebP)
if img.mode in ('RGBA', 'LA', 'P'):
# Создаем белый фон для прозрачных областей
background = Image.new('RGB', img.size, (255, 255, 255))
@@ -71,22 +93,28 @@ class ImageProcessor:
img = img.convert('RGB')
# Генерируем уникальное имя файла
original_name = image_file.name.split('.')[0]
filename = f"{original_name}_{ImageProcessor._generate_unique_id()}.jpg"
if identifier:
# Используем переданный идентификатор (slug) + timestamp для уникальности
base_filename = f"{identifier}_{ImageProcessor._generate_unique_id()}"
else:
# Если идентификатор не передан, используем исходное имя файла
original_name = image_file.name.split('.')[0]
base_filename = f"{original_name}_{ImageProcessor._generate_unique_id()}"
saved_paths = {}
# Сохраняем оригинал (без изменения размера, но в JPEG)
# Сохраняем оригинал (масштабируем если больше max_width/max_height)
original_path = ImageProcessor._save_image_version(
img, base_path, filename, 'original', resize=False
img, base_path, base_filename, 'original'
)
saved_paths['original'] = original_path
# Создаем и сохраняем остальные размеры
for size_key in ['thumbnail', 'medium', 'large']:
resized_img = ImageProcessor._resize_image(img, ImageProcessor.SIZES[size_key])
for size_key in ['large', 'medium', 'thumbnail']:
size_dims = ImageProcessor._get_size_dimensions(size_key)
resized_img = ImageProcessor._resize_image(img, size_dims)
size_path = ImageProcessor._save_image_version(
resized_img, base_path, filename, size_key, resize=False
resized_img, base_path, base_filename, size_key
)
saved_paths[size_key] = size_path
@@ -99,6 +127,8 @@ class ImageProcessor:
def _resize_image(img, size):
"""
Изменяет размер изображения с сохранением пропорций.
Если исходное изображение меньше целевого размера, добавляет белый фон.
Если больше - уменьшает с сохранением пропорций.
Args:
img: PIL Image object
@@ -107,56 +137,130 @@ class ImageProcessor:
Returns:
PIL Image object с новым размером
"""
# Вычисляем новый размер с сохранением пропорций
img.thumbnail(size, Image.Resampling.LANCZOS)
# Копируем изображение, чтобы не модифицировать оригинал
img_copy = img.copy()
# Вычисляем пропорции исходного изображения и целевого размера
img_aspect = img_copy.width / img_copy.height
target_aspect = size[0] / size[1]
# Определяем, какой размер будет ограничивающим при масштабировании
if img_aspect > target_aspect:
# Изображение шире - ограничиваемый размер это ширина
new_width = min(img_copy.width, size[0])
new_height = int(new_width / img_aspect)
else:
# Изображение выше - ограничиваемый размер это высота
new_height = min(img_copy.height, size[1])
new_width = int(new_height * img_aspect)
# Масштабируем только если необходимо (не увеличиваем маленькие изображения)
if img_copy.width > new_width or img_copy.height > new_height:
img_copy = img_copy.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Создаем новое изображение нужного размера с белым фоном
new_img = Image.new('RGB', size, (255, 255, 255))
# Центрируем исходное изображение
offset_x = (size[0] - img.width) // 2
offset_y = (size[1] - img.height) // 2
new_img.paste(img, (offset_x, offset_y))
# Центрируем исходное изображение на белом фоне
offset_x = (size[0] - img_copy.width) // 2
offset_y = (size[1] - img_copy.height) // 2
new_img.paste(img_copy, (offset_x, offset_y))
return new_img
@staticmethod
def _save_image_version(img, base_path, filename, size_key, resize=True):
def _save_image_version(img, base_path, base_filename, size_key):
"""
Сохраняет версию изображения.
Сохраняет версию изображения с информацией о размере в имени файла.
Использует формат и качество из конфигурации для каждого размера.
Args:
img: PIL Image object
base_path: Базовый путь (например, 'products')
filename: Имя файла
size_key: Ключ размера ('original', 'thumbnail', 'medium', 'large')
resize: Нужно ли изменять размер (для original=False)
base_filename: Базовое имя файла без расширения и размера
(например, 'robot-50cm_1729611234567')
size_key: Ключ размера ('original', 'large', 'medium', 'thumbnail')
Returns:
str: Путь сохраненного файла относительно MEDIA_ROOT
"""
# Получаем конфигурацию для этого размера
format_config = ImageProcessor._get_format_config(size_key)
image_format = format_config.get('format', 'JPEG')
quality = format_config.get('quality', 90)
# Определяем расширение файла в зависимости от формата
ext_map = {
'JPEG': 'jpg',
'WEBP': 'webp',
'PNG': 'png',
}
extension = ext_map.get(image_format, 'jpg')
# Создаем имя файла с указанием размера и расширением
filename = f"{base_filename}_{size_key}.{extension}"
# Создаем путь в правильной папке
folder = ImageProcessor.SIZE_FOLDERS[size_key]
folder = ImageProcessor._get_folder(size_key)
file_path = f"{base_path}/{folder}/{filename}"
# Сохраняем в памяти
img_io = BytesIO()
img.save(img_io, format='JPEG', quality=ImageProcessor.JPEG_QUALITY, optimize=True)
# Масштабируем оригинал если необходимо (для original размера)
if size_key == 'original':
max_size = ImageProcessor._get_size_dimensions('original')[0] # квадратный размер
# Если оригинал больше максимального размера, масштабируем
if img.width > max_size or img.height > max_size:
# Вычисляем новый размер с сохранением пропорций
scale_factor = min(max_size / img.width, max_size / img.height)
new_width = int(img.width * scale_factor)
new_height = int(img.height * scale_factor)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Делаем изображение квадратным: добавляем белый фон
size_obj = max(img.width, img.height)
# Но не больше max_size
if size_obj > max_size:
size_obj = max_size
square_img = Image.new('RGB', (size_obj, size_obj), (255, 255, 255))
offset_x = (size_obj - img.width) // 2
offset_y = (size_obj - img.height) // 2
square_img.paste(img, (offset_x, offset_y))
img = square_img
# Сохраняем с указанным форматом и качеством
save_kwargs = {'format': image_format, 'optimize': True}
# Качество поддерживается только для JPEG и WebP
if image_format in ('JPEG', 'WEBP'):
save_kwargs['quality'] = quality
img.save(img_io, **save_kwargs)
img_io.seek(0)
# Сохраняем в хранилище
saved_path = default_storage.save(file_path, ContentFile(img_io.getvalue()))
logger.info(f"Saved {image_format} image: {saved_path} (quality: {quality})")
return saved_path
@staticmethod
def delete_all_versions(base_path, original_image_path):
"""
Удаляет все версии изображения (original, thumbnail, medium, large).
Удаляет все версии изображения (original, large, medium, thumbnail).
Работает с форматом имен файлов:
- robot-50cm_1729611234567_original.jpg
- robot-50cm_1729611234567_large.webp
- robot-50cm_1729611234567_medium.webp
- robot-50cm_1729611234567_thumbnail.webp
Args:
base_path: Базовый путь (например, 'products')
original_image_path: Путь к оригинальному файлу
original_image_path: Путь к оригинальному файлу (из БД)
"""
if not original_image_path:
return
@@ -164,16 +268,44 @@ class ImageProcessor:
# Извлекаем имя файла из пути
filename = os.path.basename(str(original_image_path))
# Удаляем расширение и последний размер для получения base_filename
# Из 'robot-50cm_1729611234567_original.jpg' получаем 'robot-50cm_1729611234567'
# Важно: используем максимум 2 разделителя, т.к. в base_filename может быть _
parts = filename.rsplit('_', 1)
if len(parts) == 2:
base_filename = parts[0]
else:
# Если формат не совпадает, используем полное имя без расширения
base_filename = os.path.splitext(filename)[0]
config = ImageProcessor._get_config()
# Удаляем все версии
for size_key in ['original', 'thumbnail', 'medium', 'large']:
folder = ImageProcessor.SIZE_FOLDERS[size_key]
file_path = f"{base_path}/{folder}/{filename}"
for size_key in ['original', 'large', 'medium', 'thumbnail']:
format_config = ImageProcessor._get_format_config(size_key)
image_format = format_config.get('format', 'JPEG')
# Определяем расширение
ext_map = {
'JPEG': 'jpg',
'WEBP': 'webp',
'PNG': 'png',
}
extension = ext_map.get(image_format, 'jpg')
# Создаем имя файла для этого размера
size_filename = f"{base_filename}_{size_key}.{extension}"
folder = ImageProcessor._get_folder(size_key)
file_path = f"{base_path}/{folder}/{size_filename}"
try:
if default_storage.exists(file_path):
default_storage.delete(file_path)
except Exception:
pass # Игнорируем ошибки при удалении
logger.info(f"Deleted file: {file_path}")
else:
logger.warning(f"File not found: {file_path}")
except Exception as e:
logger.error(f"Error deleting {file_path}: {str(e)}", exc_info=True)
@staticmethod
def _generate_unique_id():
@@ -181,7 +313,7 @@ class ImageProcessor:
Генерирует уникальный ID для имени файла.
Returns:
str: Уникальный ID
str: Уникальный ID (timestamp + random)
"""
import time
import random

View File

@@ -12,22 +12,58 @@ class ImageService:
Динамически строит URL на основе пути к оригинальному файлу.
"""
# Папки для разных размеров
SIZE_FOLDERS = {
'thumbnail': 'thumbnails',
'medium': 'medium',
'large': 'large',
'original': 'originals',
}
@staticmethod
def _get_config():
"""Получить конфигурацию из settings"""
return getattr(settings, 'IMAGE_PROCESSING_CONFIG', {})
@staticmethod
def _get_size_folders():
"""Получить папки для разных размеров из конфигурации"""
config = ImageService._get_config()
return config.get('folders', {
'thumbnail': 'thumbnails',
'medium': 'medium',
'large': 'large',
'original': 'originals',
})
@staticmethod
def _get_format_config(size_key):
"""Получить конфигурацию формата для заданного типа изображения"""
config = ImageService._get_config()
formats = config.get('formats', {})
return formats.get(size_key, {'format': 'JPEG', 'quality': 90})
@staticmethod
def _get_file_extension(size_key):
"""Получить расширение файла для заданного типа изображения"""
format_config = ImageService._get_format_config(size_key)
image_format = format_config.get('format', 'JPEG')
ext_map = {
'JPEG': 'jpg',
'WEBP': 'webp',
'PNG': 'png',
}
return ext_map.get(image_format, 'jpg')
@staticmethod
def get_url(original_image_path, size='medium'):
"""
Получает URL изображения нужного размера.
Работает с новым форматом имён файлов с поддержкой разных расширений:
- robot-50cm_1729611234567_original.jpg (JPEG, оригинал)
- robot-50cm_1729611234567_large.webp (WebP)
- robot-50cm_1729611234567_medium.webp (WebP)
- robot-50cm_1729611234567_thumbnail.webp (WebP)
Args:
original_image_path: Путь к оригинальному файлу (из models.image)
size: Размер ('original', 'thumbnail', 'medium', 'large')
Обычно это путь к файлу 'original'
Пример: products/originals/robot-50cm_1729611234567_original.jpg
size: Размер ('original', 'large', 'medium', 'thumbnail')
По умолчанию 'medium'
Returns:
@@ -48,8 +84,28 @@ class ImageService:
else:
base_path = 'products'
# Проверяем новый формат имени файла с расширением
# Поддерживаем jpg, webp, png расширения
if filename.endswith(('.jpg', '.webp', '.png')):
# Определяем расширение файла
file_ext = os.path.splitext(filename)[1] # .jpg, .webp и т.д.
filename_without_ext = filename[:-(len(file_ext))] # Имя без расширения
# Разделяем по последнему _ для получения base_filename и size_key
parts_of_name = filename_without_ext.rsplit('_', 1)
if len(parts_of_name) == 2:
base_filename, file_size_key = parts_of_name
# Это новый формат с явным указанием размера в имени
# Получаем расширение для целевого размера
target_ext = ImageService._get_file_extension(size)
# Меняем размер в имени файла и расширение
filename = f"{base_filename}_{size}.{target_ext}"
# Иначе оставляем как есть
# Строим новый путь
folder = ImageService.SIZE_FOLDERS.get(size, 'medium')
size_folders = ImageService._get_size_folders()
folder = size_folders.get(size, 'medium')
new_path = f"{base_path}/{folder}/{filename}"
# Возвращаем URL