Доброго времени суток уважаемый читатель. Хотелось бы немного поговорить об оптимизации наших с вам любимых WEB приложений, написанных на нашем горячо любимом и всеми уважаемом фреймворке Django. В частности речь в этой статье пойдёт об оптимизации изображений. А теперь по порядку.

А что там Google Lighthouse?

Если вы хоть раз нажимали правой кнопкой мыши на экране вкладки, открытой в Google Chrome, а затем щёлкали "Просмотреть код", то Вы могли видеть инструмент для анализа вашего приложение под названием Lighthouse:

Lighthouse собственной персоной.
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). Это сделано для того, что бы загрузив даже квадратное изображение вы получили на выходе прямоугольное без потери качества и искажения. Затем мы обрезаем изображение и сжимаем его в соответствии с параметрами макета. Выбираем формат выходного изображения и его качество. Получаем байтовое значение файла. Присваиваем ему имя и сохраняем файл. Вот что получается:

Входное квадратное изображение, с соотношением сторон 1:1
Входное квадратное изображение, с соотношением сторон 1:1
Выходное изображение с соотношением сторон 1:1.5 и разрешением 200 x 300 px.
Выходное изображение с соотношением сторон 1:1.5 и разрешением 200 x 300 px.

Функция написана, теперь надо понять как и когда ее вызвать. И для этого нам потребуется переопределить метод 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 кб.

Изображение 200 x 300 px в формате png
Изображение 200 x 300 px в формате png

Вам может показаться, что в реальных цифрах разница не так велика, и вы подумаете, что экономите всего 6 килобайт, но на деле экономия чуть больше 50% процентов, согласитесь не плохо? А если представить, что вы разрабатываете крупную площадки по размещению объявлений, то реальная экономия может оказаться очень весомой.

Надеюсь, этот материал окажется для кого-то полезным. Спасибо, что дочитали текст до конца!

Комментарии (27)


  1. brotchen
    26.09.2023 10:54
    +1

    В чём преимущество get над filter в случаях, когда мы не уверены, что объект есть? Мне кажется, можно сократить.

        def save(self, *args, **kwargs):
            try:
                this = CompanyLogo.objects.filter(id=self.id).first()
                if this and this.logo != self.logo:
                    self.logo = self.compress_logo(self.logo)
                    this.logo.delete(save=False)                
            except ValueError:
                pass
            super(CompanyLogo, self).save(*args, **kwargs)


    1. C0uchP0tat0 Автор
      26.09.2023 10:54

      Идея с first() мне нравится. А вот остальной код сократить не получится, если допустим вы захотите удалить изображение из БД, но оставить запись, скажем у вас ещё есть поле title в модели и вы хотите его оставить, то изображение с сервера не удалится.


      1. brotchen
        26.09.2023 10:54
        +1

        Не очень понял. А в вашем коде за этот случай что отвечает?

        И потом, если в модели удалили изображение, то self.logo будет None (или что-то в этом роде) и не будет совпадать с this.logo, и удаление запустится.


        1. C0uchP0tat0 Автор
          26.09.2023 10:54

          Да, согласен, отлично работает. Надо вместо pass добавить super(CompanyLogo, self).save(*args, **kwargs)


  1. devozerov
    26.09.2023 10:54
    +1

    Добрый день. Можно ли таким же образом сконвертировать PNG в SVG?


    1. C0uchP0tat0 Автор
      26.09.2023 10:54

      Добрый день. Думаю таким образом преобразовать не выйдет, Pillow не поддерживает подобных преобразований. Можно посмотреть в сторону другой библиотеки, допустим https://pypi.org/project/pypotrace/


    1. Alexufo
      26.09.2023 10:54
      +2

      пиксели в вектора? Вам векторизатор нужен, а не конвертор.


  1. Alexufo
    26.09.2023 10:54
    +2

    1) хранить картинки в базе - ужасная практика. Никогда так не делайте.

    2) добавлять мусор к изображению для ....... для чего, чтобы потом опять обрезать?

    3) конвертировать картинки на событии загрузки файла - плохая практика. Нужны другие размеры, форматы завтра, перезаливать будете, велосипедить конвертер?

    Используйте готовые микросервисы для работы с изображениями, резайзами, кешированиями. Все это эволюционно уже зафиксировалось в продукт. посмотрите, хотя бы, какие задачи они решают, если сегодня они перед вам еще не стоят.


    1. C0uchP0tat0 Автор
      26.09.2023 10:54
      +1

      Здравствуйте!

      1. Строго говоря изображения хранятся в папке указанной в settings.py (MEDIA_ROOT ), или Вы о чём то другом говорите, возможно есть другой способ хранения изображений?

      2. Согласен, если допустим человек, который будет загружать контент будет сразу обрезать (подгонять) изображения под нужный формат то это конечно не требуется, а если допустим у нас окно прямоугольное, а изображение квадратное и мы его просто 'ресайзим' под наши пиксели, то будет искажение. Разве нет?

      3. Полностью с Вами согласен. Зачастую информации мало по этому поводу.


      1. Alexufo
        26.09.2023 10:54
        +1

        а.. вы назвази базой данных папку на сервере.. стоить отметить, что методы удаления изображений часто даже не реализуют, как на хабре до сих пор (да да, плевать на террабайты лишние, как нибудь у них дойдут руки), риски бага слишком высоки, чем размер на диске. Функция удаления может снести кучу контента просто по законам мерфи. Легче удалять заданием по крону раз в какой-то период времени простым скриптом без логики, получив список адресов без наличия их например, в текстах статей.

        Если льют квадрат, а у вас прямоугольник, то правила в ресайзерах обычно такие:
        если ширина квадрата больше прямоугольника - ресайз до широкой стороны прямоугольника, что не влезло - сорян, обрезка. Если квадрат меньше - или вообще нет ресайза, или апскейл и такой же кроп.

        Правила обрезки очень быстрой библиотеки
        https://sharp.pixelplumbing.com/api-resize
        Вообще можете это апи брать за инструкцию.

        Один из микросервисов для изображений. Можно найти аналоги.
        https://imgproxy.net/
        Куда эффективнее картинки вынести на отдельный сервер а хранить только урлы, где нужно. Велосипеды с картинками сейчас не нужны. Лучше брать готовое из опенсорса.


        1. C0uchP0tat0 Автор
          26.09.2023 10:54

          Понял, спасибо за информацию.


        1. aduchi
          26.09.2023 10:54

          какие есть микросервисы опенсорсные хорошие?


          1. Alexufo
            26.09.2023 10:54

            imgproxy - MIT
            я не особо ковырялся искал сравнивал, но этот марсианский


    1. 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 потребуется разветвление по папкам.


      1. Alexufo
        26.09.2023 10:54
        +1

        вы немного не поняли меня, если вообще операция delete есть в логике, вы увеличваете вероятность отказа всей системы. Не сегодня, завтра, после завтра кто-то что-то пропустит (как яндекс диск забыл поставить точку перед удалением и сносил винду от корня) и будут проблемы неясные. Прилетит не тот id или не от туда, всякое.
        Если нужно удалять, лучше делать пометку в бд и удалять заданием по списку большому под контролем.


  1. da_malcev
    26.09.2023 10:54

    Заниматься конвертацией картинки в том же потоке не очень эффективно, да и хранить на сервере тоже не очень. Но если речь просто о пет проекте на дешевом впс, то тогда лучше вынести форматирование + сохранение картинки для модели в отдельную таску селери :)


    1. C0uchP0tat0 Автор
      26.09.2023 10:54

      Согласен с Вами. Что же будет являться лучшей практикой в этом случае?


    1. hardtop
      26.09.2023 10:54

      Отложенная задача - нет возможности сразу посмотреть результат. В одном случае Вы загрузили большую картинку 3000х4000, django ресайзнула её до 1500х2000. И клиент\администратор сразу видит результат. А если процесс обработки отложен на 10 мин? Что там получится? А администратор уже следующую карточку товара пишет...


  1. 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


    1. C0uchP0tat0 Автор
      26.09.2023 10:54

      Спасибо за наводку. А на счёт хранения оригиналов, согласен с Вами, хотелось просто написать про сам процесс сжатия изображения. Но пожалуй комментаторы правы, нет смысла изобретать велосипед и проще пользоваться уже готовыми решениями.


  1. burn_in_soul
    26.09.2023 10:54

    def 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 с пост сейв обработкой.


  1. kAIST
    26.09.2023 10:54

    django-imagekit решает множество проблем.


    1. C0uchP0tat0 Автор
      26.09.2023 10:54

      А с форматом webp работает? Мне кажется, что нет.


      1. kAIST
        26.09.2023 10:54
        +1

        Почему вам так кажется?

        preview = ImageSpecField(source='file',
                                     processors=[ResizeToFit(150, 150)],
                                     format='WEBP',
                                     options={'quality': 80})

        Он работает со всеми форматами, с которыми работает pillow. Плюс, вы получается возможность управлять стратегией генерации изображений - во время загрузки или по требованию.

        Ну и при добавлении новых разрешений или изменении существующих, что как бы нередко бывает в проекте, вы избавляетесь от лишнего гемороя.


  1. hardtop
    26.09.2023 10:54

    1. Писать код ресайза в save() -- сомнительная идея.

    2. Батарейка django_resized позволяет ресайзить большие картинки при загрузке:
      image = ResizedImageField(size=[100, 100], crop=['top', 'left'], upload_to='whatever', force_format='PNG', scale=0.5)

    3. Принудительно кропить при загрузке -- обрежете голову. Уж лучше потом в шаблонах использовать sorl.thumbnail



    1. C0uchP0tat0 Автор
      26.09.2023 10:54

      1. А куда надо писать, как вы считаете?

      2. Батарейки, вариант, но они с webp не работают.

      3. Вы вероятно говорите, про эту библиотек? Она с webp не работает, на сколько мне известно. А подрезать в шаблонах, в любом случае вы будете получать снижение производительности приложения. Ведь картинка будет подгружаться целиком, а потом сжимать в шаблоне, малоэффективно.


      1. hardtop
        26.09.2023 10:54

        1. Посмотрите, как написаны штатные (ImageField) и нештатные (как пример, ResizedImageField) - они сами берут на себя ответственность за обработку картинок. Простая мысль: Модель "Компания" должна отвечать за Компанию. Т.е. сделать self.slug = slugify(self.name) - уместно, поскольку это относится к Компании. А обработку картинок надо выносить. Вы же не будете писать 1-3-5 методов, если помимо лого будут ещё картинки загружаться.

        2. Батарейки работают. Смотрите зависимости. Большинство используют Pillow для картинок. Если Pillow умеет сохранять в webp - и их потомки тоже умеют.

        3. Нет. Не не так. Картинка не будет подгружаться целиком.

          {% thumbnail obj.image "400x400" crop="center" quality=95 as pixthumb %}
          <img ... src="{{ pixthumb }}">

          {% endthumbnail %}


          Тут при первом обращении сделается превью (ресайз, кроп, все дела), сохраниться на диск (в папку cache). Потом именно этот файл будет отдаваться по этому запросу.


          Проверьте - сами увидите.