Доброго времени суток уважаемый читатель. Хотелось бы немного поговорить об оптимизации наших с вам любимых WEB приложений, написанных на нашем горячо любимом и всеми уважаемом фреймворке Django. В частности речь в этой статье пойдёт об оптимизации изображений. А теперь по порядку.
А что там Google Lighthouse?
Если вы хоть раз нажимали правой кнопкой мыши на экране вкладки, открытой в Google Chrome, а затем щёлкали "Просмотреть код", то Вы могли видеть инструмент для анализа вашего приложение под названием Lighthouse:
Проведя тест Вашего приложения вы можете увидеть следующее:
Ссылка Learn more about modern image formats. ведёт на эту страницу. Если коротко, то нам предлагают избавится от изображений в старых форматах и приобщиться к более современным, в частности к формату webp. Более подробно вы можете ознакомится самостоятельно, перейдя по ссылке.
Django, ты как там?
Коль скоро речь заходит о хранении изображений на стороне Back-end, то мы не будем томить и приведём пример хранения изображения в нашей базе данных, допустим у нас будет логотип компании, который нам необходимо сохранить в формате webp. Для этого напишем в файле models.py следующее:
class CompanyLogo(models.Model):
logo = models.ImageField(
upload_to='images/logo',
verbose_name='Лого'
)
А чего тут нового, спросите вы и будете абсолютно правы. Это вполне себе стандартный синтаксис модели Django. Давайте напишем функцию, которая на входе принимает файл изображения, допустим jpeg или png, а возвращает изображение в формате webp, да ещё и подрезанное, как того требует макет скажем в Figma. По желанию, вы можете хранить этот код в отдельном файле проекта и импортировать в случае необходимости, я же добавлю эту функцию в модель CompanyLogo.
from django.db import models
from io import BytesIO
from PIL import Image
from django.core.files import File
from django.core.files.base import ContentFile
class CompanyLogo(models.Model):
logo = models.ImageField(
upload_to='images/logo',
verbose_name='Лого'
)
def compress_logo(self, image):
im = Image.open(image)
width, height = im.size[0], int(im.size[0] * 1.5)
x, y = 0, int((im.size[1] - height) // 2)
area = (x, y, x+width, y+height)
im = im.crop((area))
im = im.resize((200, 300))
im_bytes = BytesIO()
im.save(fp=im_bytes, format="WEBP", quality=100)
image_content_file = ContentFile(content=im_bytes.getvalue())
name = image.name.split('.')[0] + '.WEBP'
new_image = File(image_content_file, name=name)
return new_image
Следует упомянуть, что для работы с полем ImageField в Django, требуется модуль Pillow,
которой нужно предварительно установить в нашу виртуальную среду pip install Pillow.
Наверное надо немного пояснить, что конкретно делает этот код. Он принимает на входе загруженное изображение, затем берёт за основу его ширину и вычисляет требуемую высоту, пропорционально соотношению сторон (300/200=1.5). Это сделано для того, что бы загрузив даже квадратное изображение вы получили на выходе прямоугольное без потери качества и искажения. Затем мы обрезаем изображение и сжимаем его в соответствии с параметрами макета. Выбираем формат выходного изображения и его качество. Получаем байтовое значение файла. Присваиваем ему имя и сохраняем файл. Вот что получается:
Функция написана, теперь надо понять как и когда ее вызвать. И для этого нам потребуется переопределить метод save класса модели, а именно:
def save(self, *args, **kwargs):
try:
this = CompanyLogo.objects.get(id=self.id)
if this.logo != self.logo:
this.logo.delete(save=False)
try:
new_logo = self.compress_logo(self.logo)
self.logo = new_logo
super(CompanyLogo, self).save(*args, **kwargs)
except ValueError:
super(CompanyLogo, self).save(*args, **kwargs)
else:
super(CompanyLogo, self).save(*args, **kwargs)
except CompanyLogo.DoesNotExist:
try:
new_logo = self.compress_logo(self.logo)
self.logo = new_logo
super(CompanyLogo, self).save(*args, **kwargs)
except ValueError:
super(CompanyLogo, self).save(*args, **kwargs)
Возможно код выглядит немного громоздко, но он работает, если у кого то будут идеи, как его переписать более лаконично я буду этому рад.
Так, что же делает этот код, по мимо того, что вызывает нашу функцию для сжатия изображения при сохранении записи в базе данных? Он позволяет при обновлении или удалении файла (допустим через Django admin), а так же при удалении записи из базы данных удалить физический файл с сервера (ведь все мы стараемся сберечь место на SSD), и если предположить, что у вас очень большой проект, который может содержать огромное количество подобных изображений, то момент с удалением ненужных файлов с сервера будет весьма и весьма полезным.
Что в итоге?
Тоже самое изображение, обработанное по алгоритму из функции compress_logo
но имеющее разрешение png занимает на диске примерно 12,2 кб., когда как изображение в формате webp занимает всего 6 кб.
Вам может показаться, что в реальных цифрах разница не так велика, и вы подумаете, что экономите всего 6 килобайт, но на деле экономия чуть больше 50% процентов, согласитесь не плохо? А если представить, что вы разрабатываете крупную площадки по размещению объявлений, то реальная экономия может оказаться очень весомой.
Надеюсь, этот материал окажется для кого-то полезным. Спасибо, что дочитали текст до конца!
Комментарии (27)
devozerov
26.09.2023 10:54+1Добрый день. Можно ли таким же образом сконвертировать PNG в SVG?
C0uchP0tat0 Автор
26.09.2023 10:54Добрый день. Думаю таким образом преобразовать не выйдет, Pillow не поддерживает подобных преобразований. Можно посмотреть в сторону другой библиотеки, допустим https://pypi.org/project/pypotrace/
Alexufo
26.09.2023 10:54+21) хранить картинки в базе - ужасная практика. Никогда так не делайте.
2) добавлять мусор к изображению для ....... для чего, чтобы потом опять обрезать?
3) конвертировать картинки на событии загрузки файла - плохая практика. Нужны другие размеры, форматы завтра, перезаливать будете, велосипедить конвертер?
Используйте готовые микросервисы для работы с изображениями, резайзами, кешированиями. Все это эволюционно уже зафиксировалось в продукт. посмотрите, хотя бы, какие задачи они решают, если сегодня они перед вам еще не стоят.
C0uchP0tat0 Автор
26.09.2023 10:54+1Здравствуйте!
Строго говоря изображения хранятся в папке указанной в settings.py (MEDIA_ROOT ), или Вы о чём то другом говорите, возможно есть другой способ хранения изображений?
Согласен, если допустим человек, который будет загружать контент будет сразу обрезать (подгонять) изображения под нужный формат то это конечно не требуется, а если допустим у нас окно прямоугольное, а изображение квадратное и мы его просто 'ресайзим' под наши пиксели, то будет искажение. Разве нет?
Полностью с Вами согласен. Зачастую информации мало по этому поводу.
Alexufo
26.09.2023 10:54+1а.. вы назвази базой данных папку на сервере.. стоить отметить, что методы удаления изображений часто даже не реализуют, как на хабре до сих пор (да да, плевать на террабайты лишние, как нибудь у них дойдут руки), риски бага слишком высоки, чем размер на диске. Функция удаления может снести кучу контента просто по законам мерфи. Легче удалять заданием по крону раз в какой-то период времени простым скриптом без логики, получив список адресов без наличия их например, в текстах статей.
Если льют квадрат, а у вас прямоугольник, то правила в ресайзерах обычно такие:
если ширина квадрата больше прямоугольника - ресайз до широкой стороны прямоугольника, что не влезло - сорян, обрезка. Если квадрат меньше - или вообще нет ресайза, или апскейл и такой же кроп.
Правила обрезки очень быстрой библиотеки
https://sharp.pixelplumbing.com/api-resize
Вообще можете это апи брать за инструкцию.
Один из микросервисов для изображений. Можно найти аналоги.
https://imgproxy.net/
Куда эффективнее картинки вынести на отдельный сервер а хранить только урлы, где нужно. Велосипеды с картинками сейчас не нужны. Лучше брать готовое из опенсорса.
Krouler7
26.09.2023 10:54Первое решается достаточно просто.
Необходимо создать функцию, которая будет возвращать путь+id(uuid4 лучше если ожидается огромное количество логотипов) и дать ее в атрибут upload_to.
def get_image_path(instance, filename): return 'logo/{0}_{1}'.format(instance.id, filename) ... logo = models.ImageField(upload_to=get_image_path,...)
В таком случае можно не волноваться при удалении что удалится что то не то.
А вообще, даже если использовать простой id, то, учитывая специфику инкрементации id(при удалении элемента инкрементация не сбрасывает счетчик), можно даже с простым id не беспокоиться что удалится не то.
UPD
Это верно для O2O связи. Для FK потребуется разветвление по папкам.
Alexufo
26.09.2023 10:54+1вы немного не поняли меня, если вообще операция delete есть в логике, вы увеличваете вероятность отказа всей системы. Не сегодня, завтра, после завтра кто-то что-то пропустит (как яндекс диск забыл поставить точку перед удалением и сносил винду от корня) и будут проблемы неясные. Прилетит не тот id или не от туда, всякое.
Если нужно удалять, лучше делать пометку в бд и удалять заданием по списку большому под контролем.
da_malcev
26.09.2023 10:54Заниматься конвертацией картинки в том же потоке не очень эффективно, да и хранить на сервере тоже не очень. Но если речь просто о пет проекте на дешевом впс, то тогда лучше вынести форматирование + сохранение картинки для модели в отдельную таску селери :)
C0uchP0tat0 Автор
26.09.2023 10:54Согласен с Вами. Что же будет являться лучшей практикой в этом случае?
hardtop
26.09.2023 10:54Отложенная задача - нет возможности сразу посмотреть результат. В одном случае Вы загрузили большую картинку 3000х4000, django ресайзнула её до 1500х2000. И клиент\администратор сразу видит результат. А если процесс обработки отложен на 10 мин? Что там получится? А администратор уже следующую карточку товара пишет...
SergeiMinaev
26.09.2023 10:54+1В целом, нужно хранить оригиналы фото, чтобы, при необходимости, пережать варианты. Попробуйте django-stdimage (или один из форков), там можно писать как-то так:
image = StdImageField(variations={'thumbnail': (100, 75), 'medium': (800,800)})
Для получения url:
object.image.medium.url
И если нужно будет изменить размеры, то есть
manage.py rendervariations
C0uchP0tat0 Автор
26.09.2023 10:54Спасибо за наводку. А на счёт хранения оригиналов, согласен с Вами, хотелось просто написать про сам процесс сжатия изображения. Но пожалуй комментаторы правы, нет смысла изобретать велосипед и проще пользоваться уже готовыми решениями.
burn_in_soul
26.09.2023 10:54def save(self, *args, **kwargs): try: this = CompanyLogo.objects.get(id=self.id) except CompanyLogo.DoesNotExist: self.logo = self.compress_logo(self.logo) else: if this.logo != self.logo: this.logo.delete(save=False) self.logo = self.compress_logo(self.logo) finally: super(CompanyLogo, self).save(*args, **kwargs)
Я думаю, подобный код будет понятней и короче (не запускал, может в чем-то не прав).
Обработку ValueError лучше оставить на стороне compress_logo.Возможно, стоит смотреть в сторону celery с пост сейв обработкой.
kAIST
26.09.2023 10:54django-imagekit решает множество проблем.
C0uchP0tat0 Автор
26.09.2023 10:54А с форматом webp работает? Мне кажется, что нет.
kAIST
26.09.2023 10:54+1Почему вам так кажется?
preview = ImageSpecField(source='file', processors=[ResizeToFit(150, 150)], format='WEBP', options={'quality': 80})
Он работает со всеми форматами, с которыми работает pillow. Плюс, вы получается возможность управлять стратегией генерации изображений - во время загрузки или по требованию.
Ну и при добавлении новых разрешений или изменении существующих, что как бы нередко бывает в проекте, вы избавляетесь от лишнего гемороя.
hardtop
26.09.2023 10:54Писать код ресайза в save() -- сомнительная идея.
Батарейка django_resized позволяет ресайзить большие картинки при загрузке:
image = ResizedImageField(size=[100, 100], crop=['top', 'left'], upload_to='whatever', force_format='PNG', scale=0.5)-
Принудительно кропить при загрузке -- обрежете голову. Уж лучше потом в шаблонах использовать sorl.thumbnail
C0uchP0tat0 Автор
26.09.2023 10:54А куда надо писать, как вы считаете?
Батарейки, вариант, но они с webp не работают.
-
Вы вероятно говорите, про эту библиотек? Она с webp не работает, на сколько мне известно. А подрезать в шаблонах, в любом случае вы будете получать снижение производительности приложения. Ведь картинка будет подгружаться целиком, а потом сжимать в шаблоне, малоэффективно.
hardtop
26.09.2023 10:54Посмотрите, как написаны штатные (ImageField) и нештатные (как пример, ResizedImageField) - они сами берут на себя ответственность за обработку картинок. Простая мысль: Модель "Компания" должна отвечать за Компанию. Т.е. сделать self.slug = slugify(self.name) - уместно, поскольку это относится к Компании. А обработку картинок надо выносить. Вы же не будете писать 1-3-5 методов, если помимо лого будут ещё картинки загружаться.
Батарейки работают. Смотрите зависимости. Большинство используют Pillow для картинок. Если Pillow умеет сохранять в webp - и их потомки тоже умеют.
-
Нет. Не не так. Картинка не будет подгружаться целиком.
{% thumbnail obj.image "400x400" crop="center" quality=95 as pixthumb %}
<img ... src="{{ pixthumb }}">{% endthumbnail %}
Тут при первом обращении сделается превью (ресайз, кроп, все дела), сохраниться на диск (в папку cache). Потом именно этот файл будет отдаваться по этому запросу.
Проверьте - сами увидите.
brotchen
В чём преимущество get над filter в случаях, когда мы не уверены, что объект есть? Мне кажется, можно сократить.
C0uchP0tat0 Автор
Идея с first() мне нравится. А вот остальной код сократить не получится, если допустим вы захотите удалить изображение из БД, но оставить запись, скажем у вас ещё есть поле title в модели и вы хотите его оставить, то изображение с сервера не удалится.
brotchen
Не очень понял. А в вашем коде за этот случай что отвечает?
И потом, если в модели удалили изображение, то self.logo будет None (или что-то в этом роде) и не будет совпадать с this.logo, и удаление запустится.
C0uchP0tat0 Автор
Да, согласен, отлично работает. Надо вместо
pass
добавитьsuper(CompanyLogo, self).save(*args, **kwargs)