Применяем визуальные эффекты к изображениям в Django

При написании собственного «инстаграма» появилась необходимость в наложении фильтров на изображение при аплоаде. Изначально, чтобы особо не нагружать сервер, было решено вынести процесс преобразования картинки на клиентскую сторону. Основная идея – загрузка изображения в канву, манипуляции над ним и выгрузка потока байт (результирующего изображения) на сервер. Для реализации была выбрана js-библиотека CamanJS , которая может работать как в браузере, так и на стороне сервера через NodeJS. Однако пришлось от нее отказаться из-за трех причин:

  • CamanJS не поддерживается мобильными браузерами (Safari, Chrome в частности);
  • CamanJS заставляет течь память в браузере (особенно при работе с крупными изображениями);
  • CamanJS сильно тормозит в Firefox при наложении фильтров.

Затем была предпринята попытка использовать CamanJS на стороне сервера. Результат опять оказался неудовлетворительным:

  • После преобразования изображение увеличивалось в 3-4 раза;
  • Преобразование изображения происходило совсем не быстро.

В итоге пришлось полностью отказаться от CamanJS.

Для обработки изображений на серверной стороне самым оптимальным вариантом оказался программный комплекс ImageMagick, который обладает довольно богатым функционалом и имеет множество расширений для различных языков программирования. Поскольку наш проект работает на django, то нас, прежде всего, интересовали python-расширения для ImageMagick – PythonMagick и Wanda. Как выяснилось, они поддерживают не все возможности ImageMagick, часть графических эффектов просто отсутствует, поэтому мы воспользовались прямым вызовом imagemagick через subprocess.
Применение эффектов происходит через специальные bash-скрипты, которые были получены с помощью очень полезных ресурсов http://www.fmwconcepts.com/imagemagick — здесь лежат сами скрипты с описанием, http://jqmagick.imagemagick.org – а здесь можно поэкспериментировать с различными эффектами и подобрать параметры.

Сначала заходим на http://jqmagick.imagemagick.org, аплоадим картинку, выбираем нужный эффект, подбираем параметры к нему. Если все красиво и все нас устраивает, копируем пример команды для выполнения скрипта с необходимыми параметрами из нижнего поля раздела «options» (по дефолту правая нижняя часть интрефейса jqmagick). Например:

bash scripts/vintage1.sh -b 0 -c 35 -s roundrectangle -T torn -I grunge -C white output/8526-603.jpg output/3347-9458.jpg

Интересующий нас скрипт называется vintage1.sh. Перемещаемся на http://www.fmwconcepts.com/imagemagick, находим нужный скрипт, скачиваем его и не забываем выставить флажок «на исполнение». И вот таким образом подбираем все нужные нам эффекты.
Теперь все готово для программной реализации.

Итак, задача:

Дать возможность пользователям (в нашем случае это — продавцы на e-commerce площадке по продаже уникальных товаров) загружать изображения на сервер, применять к ним эффекты из заранее подготовленного набора с возможностью отображения результата.

Загрузка изображения на сервер

Для загрузки изображения на сервер мы использовали js-библиотеку filereader.js. Использование и пример конфигурации можно найти в спецификации для этой библиотеки. Непосредственную отправку файла на сервер мы реализовали с помощью метода send() oбъекта XMLHttpRequest после того, как файл будет полностью выгружен в объект FileReader браузера. Для этого определяем опцию «load»:

var opts = {
        // … 
        load: function(e, file) {
            var xhr = new XMLHttpRequest();
            xhr.open('POST', '{% url upload_file %}', true);
            xhr.onload = function() {
                if (this.status == 200) {
                    // обрабатываем ответ от сервера после удачной загрузки
                    var resp = JSON.parse(this.response);
                    // сохраняем пути к оригинальному изображению и превью (для применения фильтров к нему)
                    filter_image = resp['image'];   
                    filter_thumb = resp['thumb'];
                }          
         };
            xhr.send(file);
         },
         //…
};

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

import os
import datetime
from PIL import Image
try:
    from cStringIO import StringIO
except ImportError:
    from StringIO import StringIO
import simplejson as json

from django.http import HttpResponse
from django.conf import settings

def upload_file(request):
    max_size = (2560, 2048)  # задаем максимальное разрешение для оригинала
    thumb_size = (325, 325)  #  максимальное разрешение для превью
    f_data = request.body
    fake_file = StringIO()
    fake_file.write(f_data)
    fake_file.seek(0)
    img = Image.open(fake_file)
    img.thumbnail(max_size, Image.ANTIALIAS)  # ужимаем при необходимости оригинал
    tmp_dir = settings.TEMP_IMG_DIR  # все временные изображения будем хранить в отведенном месте
    if not os.path.exists(tmp_dir):
        os.makedirs(tmp_dir)
    # директории с изображениями будут группироваться по датам
    inner_dir_name = datetime.datetime.now().strftime('%d.%m.%Y')  
    inner_dir = os.path.abspath(os.path.join(tmp_dir, inner_dir_name))
    if not os.path.exists(inner_dir):
        os.makedirs(inner_dir)
    tmp_file_name = generate_tmp_file_name()  # получение уникального имени для файла
    thumb_tmp_file_name = 'thumb_' + tmp_file_name  # для превью добавляем префикс
    output = os.path.abspath(os.path.join(inner_dir, tmp_file_name))
    output_thumb = os.path.abspath(os.path.join(inner_dir, thumb_tmp_file_name))
    if not img.mode == 'RGB':
        img = img.convert('RGB')
    img.save(output, "JPEG")  # преобразуем оригинал и сохраняем в jpeg для экономии места
    img.thumbnail(thumb_size, Image.ANTIALIAS)  
    img.save(output_thumb, "JPEG")  #  аналогично и для превью
    to_response = json.dumps({
        'image': ''.join([settings.MEDIA_URL, '/'.join([settings.TEMP_IMG_DIR_NAME,
                                                        innder_dir_name, tmp_file_name])]),
        'thumb': ''.join([settings.MEDIA_URL, '/'.join([settings.TEMP_IMG_DIR_NAME,
                                                        innder_dir_name, thumb_tmp_file_name])]),
    })
    return HttpResponse(to_response,  mimetype="application/json")

Сам TEMP_IMG_DIR в setting.py определяется так:

# Директория для хранения временных картинок
TEMP_IMG_DIR = os.path.abspath(os.path.join(MEDIA_ROOT, 'temp_img'))

Применение эффектов к изображению

Основная идея здесь заключается в том, что эффекты будут применяться не к оригинальному изображению, а к его превью, и лишь после финального нажатия на кнопку «Сохранить» выбранный эффект применится к самому изображению.
Функция применения эффекта на клиентской стороне:

function setFilter(filter_name) {
    result_filter = filter_name
    $.ajax({
        url: '{% url set_filter %}',
        method: 'POST',
        data: {
            'img_path': filter_thumb,    // отправляем путь к превью
            'filter_name': filter_name     // и наименование фильтра
        },
        success: function(response) {
            $('#result_img').attr('src', response);  // отображаем результат
        }
    })
}

На серверной стороне определяем команды для применения эффектов и сопоставляем их с названиями фильтров, здесь удобно использовать словарь:

FILTERS_COMMAND = {
    'f1': "bash_scripts/colortemp.sh -t 10950 {file_name} {output}",
    'f2': "bash_scripts/colortemp.sh -t 5736 {file_name} {output}",
    # …
    'f8': "bash_scripts/colorfilter.sh -c sepia -m 1 -d 28 {file_name} {output}",
    'f9': "bash_scripts/colorfilter.sh -c underwater -m 1 -d 20 {file_name} {output}"
}

Функция применения эффекта на стороне сервера:

import os
import sys
import subprocess

def apply_filter(img_path, filter_name, output=None):
    if output is None:
        output_file_name = ''.join([filter_name, '_', os.path.basename(img_path)])
        output_file = os.path.abspath(os.path.join(img_path.replace(os.path.basename(img_path), ''), output_file_name))
        if os.path.exists(output_file):
            return output_file
    else:
        output_file = output
    command = FILTERS_COMMAND[filter_name]
    # Используем bash, стало быть, скобки должны быть в виде:
    command = command.replace('(', '\(').replace(')', '\)')
    command = command.format(file_name=img_path, output=output_file)
    subprocess.check_output(command, shell=True, stderr=subprocess.STDOUT)
    return output_file

Функция apply_filter вызывается обработчик set_filter, который принимает 2 аргумента: путь к изображению и наименование фильтра, а возвращает — путь к измененному изображению.

После того, как пользователь определился с нужным эффектом, он нажимает кнопку «Сохранить», и вызывается функция set_filter, в качестве аргумента передается сохраненный ранее путь к оригинальному изображению и результирующий эффект.
Напоследок приложу скрин того, как все это выглядит у нас:
e1a409924889d0abd2f75c7815e55c4d