Pull to refresh

Ресайз картинок в браузере. Все очень плохо

Reading time 10 min
Views 104K
Если вы когда-нибудь сталкивались с задачей ресайза картинок в браузере, то вы наверное знаете, что это очень просто. В любом современном браузере есть такой элемент, как холст (<canvas>). На него можно нанести изображение нужных размеров. Пять строчек кода и картинка готова:

function resize(img, w, h) {
  var canvas = document.createElement('canvas');
  canvas.width = w;
  canvas.height = h;
  canvas.getContext('2d').drawImage(img, 0, 0, w, h);
  return canvas;
}

Из холста картинку можно сохранить в JPEG и, например, отправить на сервер. Можно было на этом закончить статью, но сперва давайте взглянем на результат. Если вы поставите рядом такой холст и обычный элемент <img>, в который загружена та же картинка (исходник, 4 Мб), то вы увидите разницу.

img

По какой-то причине все современные браузеры: и десктопные, и мобильные — используют для рисования на холсте дешевый метод аффинных преобразований. Различия методов ресайза изображений я уже описывал в соответствующей статье. Напомню суть метода аффинных преобразований. В нем для расчета каждой точки конечного изображения интерполируются 4 точки исходного. Это значит, что при уменьшении изображения более чем в 2 раза в исходном изображении образуются дыры — пиксели, которые совсем не учитываются в конечном. Именно из-за этих неучтенных пикселей страдает качество.

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

Бесспорно, этот метод дает намного лучший результат, ведь все точки исходного изображения учитываются в конечном. Другой вопрос, как именно они учитываются. Это уже зависит от размера шага, размера начального и размера конечного изображения. Например, если взять размер шага ровно 2, эти уменьшения будут эквивалентны суперсемплингу. А вот последний шаг — как повезет. Если совсем повезет, то последний шаг тоже будет равен 2. Но может совсем не повезти, когда на последнем шаге изображение нужно будет уменьшить на один пиксель, и картинка получится мыльная. Сравните, отличие размера всего в один пиксель, а какова разница (исходник, 4 Мб):

img

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

Все что нужно теперь — загрузить полноразмерную картинку в холст. Так бы мог выглядеть идеальный случай. Реализацию resizePixels я оставлю за кадром.

function resizeImage(image, width, height) {
  var cIn = document.createElement('canvas');
  cIn.width = image.width;
  cIn.height = image.height;
  var ctxIn = cIn.getContext('2d');

  ctxIn.drawImage(image, 0, 0);
  var dataIn = ctxIn.getImageData(0, 0, image.width, image.heigth);
  var dataOut = ctxIn.createImageData(width, heigth);
  resizePixels(dataIn, dataOut);

  var cOut = document.createElement('canvas');
  cOut.width = width;
  cOut.height = height;
  cOut.getContext('2d').putImageData(dataOut, 0, 0);
  return cOut;
}

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

Давайте поговорим, для чего вообще может понадобиться ресайз на клиенте. У меня была задача уменьшить размер выбранных фотографий перед отправкой на сервер, таким образом экономя трафик пользователя. Это наиболее актуально на мобильных устройствах с медленным соединением и платным трафиком. А какие фотографии чаще всего загружают на таких устройствах? Снятые на камеры этих мобильных устройств. Разрешение камеры, например, Айфона — 8 мегапикселей. Но с помощью неё можно снять панораму в 25 мегапикселей (на iPhone 6 даже больше). На Андроидах и Виндусфонах разрешения камер бывают еще выше. И тут мы сталкиваемся с ограничениями этих мобильных устройств. К сожалению, в iOS нельзя создать холст больше 5 мегапикселей.

Эпл можно понять, им приходится следить за нормальной работой своих устройств с ограниченными ресурсами. В самом деле, в представленной выше функции вся картинка будет занимать память три раза! Один раз — буфер, связанный с объектом Image, куда распаковывается изображение, второй раз — пиксели холста, и третий — типизированный массив в ImageData. Для картинки в 8 мегапикселей понадобится 8 × 3 × 4 = 96 мегабайт памяти, для 25 мегапикселей — 300.

Но в процессе тестирования я сталкивался с проблемами не только в iOS. Хром на Маке с некоторой вероятностью начинал рисовать вместо одного большого изображения несколько маленьких, а под Виндой просто выдавал белый лист.

Но раз нельзя получить все пиксели сразу, может можно получить их по частям? Можно подгружать картинку в холст по кускам, ширина которых равна ширине исходного изображения, а высота намного меньше. Сначала подгружаем первые 5 мегапикселей, потом еще, потом сколько останется. Или даже по 2 мегапикселя, что еще более сократит использование памяти. К счастью, в отличие от двухпроходного ресайза свертками, метод ресайза суперсемплингом однопроходный. Т.е. можно не только получать изображение порциями, но и отдавать на обработку одну порцию за раз. Память понадобится только под элемент Image, холст (например, 2 мегапикселя) и типизированный массив. Т.е. для картинки 8 мегапикселей (8 + 2 + 2) × 4 = 48 мегабайт, что в 2 раза меньше.

Я реализовал описанный выше подход и замерил время выполнения каждой части. Самому протестировать можно здесь. Вот что получилось у меня для картинки разрешением 10800×2332 пикселей (панорама с Айфона).
Браузер Safari 8 Chrome 40 Firefox 35 IE 11
Image load 24 ms 27 28 76
Draw to canvas 1 348 278 387
Get image data 304 299 165 320
JS Resize 233 135 138 414
Put data back 1 1 3 5
Get image blob 10 16 21 19
Total 576 833 641 1243

Это очень интересная таблица, давайте остановимся на ней подробно. Отличная новость в том, что сам ресайз на яваскрипте не является узким местом. Да, в Сафари он в 1,7 раз медленнее, чем в Хроме и Фаерфоксе, а в IE в 3 раза медленнее, но во всех браузерах время на загрузку картинки и получение данных все равно больше.

Второй примечательный момент — ни в одном браузере картинка не декодируется к событию image.onload. Декодирование откладывается на момент, когда это действительно необходимо — отображение на экране или вывод на холст. А в Сафари изображение не декодируется, даже когда нанесено на холст, ведь холст также не отображается на экране. А декодируется только когда пиксели извлекаются из холста.

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

Пока что такой подход выглядит перспективным. Давайте протестируем на мобильных устройствах. Под рукой у меня оказались iPhone 4s (i4s), iPhone 5 (i5), Meizu MX4 Pro (A) и я попросил Олега Корсунского протестировать на Windows Phone, у него оказался HTC 8x (W).
Браузер Safari i4s Safari i5 Chrome i4s Chrome A Chrome A Firefox A IE W
Image load 517 ms 137 650 267 220 81 437
Draw to canvas 2 706 959 2 725 1 108 6 954 1 007 1 019
Get image data 678 250 734 373 543 406 1 783
JS Resize 2 939 1 110 96 320 491 458 418 2 299
Put data back 9 5 315 6 4 14 24
Get image blob 98 46 187 37 41 80 33
Total 6 985 2 524 101 002 2 314 8 242 2 041 5 700

Первое, что бросается в глаза — «выдающийся» результат Хрома на iOS. Действительно, до недавнего времени в iOS все сторонние браузеры могли работать только с версией движка без jit-компиляции. В iOS 8 появилась возможность использовать jit, но Хром еще не успели адаптировать.

Другая странность — два результата у Хрома на Андроиде, радикально отличающиеся временем рисования и почти идентичные во всем остальном. Это не ошибка в таблице, Хром действительно может вести себя по-разному. Я уже говорил, что браузеры загружают картинки лениво, в момент, когда посчитают нужным. Так вот, ничего не мешает браузеру освобождать память, занятую картинкой, когда он считает, что картинка больше не нужна. Естественно, когда картинка снова понадобится при следующем рисовании на холсте, придется снова её декодировать. В данном случае картинка декодировалась 7 раз. Это хорошо видно по времени рисования отдельных чанков (напомню, в таблице только суммарное время). В таких условиях время декодирования становится непредсказуемым.

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

На этом месте я решил плюнуть на это дело. Был совсем сумасшедший вариант не только ресайзить, но и декодировать jpeg на клиенте. Минусы: только jpeg, плохое время Хрома под iOS еще сильнее ухудшится. Плюсы: предсказуемость в Хроме под Андроидом, нет лимитов на размер, нужно меньше памяти (нет бесконечного копирования на холст и обратно). На этот вариант я не решился, хотя и существует декодер jpeg на чистом javascript.

Часть 2. Вернемся к началу


Помните, как в самом начале мы получили хороший результат при последовательном уменьшении в 2 раза в лучшем случае, и мыльный — в худшем? А что, если попытаться избавиться от худшего варианта, не слишком изменив подход? Напомню, что мыло получается, если на последнем шаге нужно уменьшить картинку на совсем чуть-чуть. Что если последний шаг сделать первым, уменьшая сначала в какое-то неопределенное число раз, а потом только строго в 2 раза? Попутно надо учесть, чтобы первый шаг был не больше 5 мегапикселей по площади и 4096 пикселей по любой ширине. В таком варианте и код получается явно проще, чем ручной ресайз.

img

Слева изображение, уменьшенное за 4 шага, справа за 5, а разницы почти нет. Почти победа. К сожалению, разница между двумя и тремя шагами (не говоря о разнице между одним и двумя шагами) все равно видна достаточно сильно:

img

Хотя мыла и значительно меньше, чем было в самом начале. Я бы даже сказал, что изображение справа (полученное за 3 шага) выглядит немного приятнее левого, которое слишком резкое.

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

Часть 3. Много фоточек подряд


Ресайз — операция сравнительно долгая. Если действовать в лоб и ресайзить все картинки друг за другом, браузер надолго зависнет и будет недоступен пользователю. Лучше всего делать setTimeout после каждого шага ресайза. Но тут появляется другая проблема: если все картинки начнут ресайзиться одновременно, то и память под них понадобится одновременно. Этого можно избежать, организовав очередь. Например, можно запускать ресайз следующего изображения по окончании ресайза предыдущего. Но я предпочел более общее решение, когда очередь образуется внутри функции ресайза, а не снаружи. Это гарантирует, что две картинки не будут ресайзиться одновременно, даже если ресайз будет вызываться одновременно из разных мест.

Вот полный пример: все то, что было во второй части, плюс реализация очереди и таймауты перед долгими операциями. Я добавил крутилку на страницу, и теперь видно, что браузер если и залипает, то ненадолго. Самое время тестировать на мобильных устройствах!

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

Засучив рукава я открыл страничку на айфоне и выбрал 20 фоток. Немного подумав, Сафари радостно отрапортовал: A problem occurred with this webpage so it was reloaded. Вторая попытка — тот же результат. В этом месте я вам завидую, дорогие читатели, потому что для вас следующий абзац пролетит за минуту, тогда как для меня это была ночь боли и страданий.

Итак, Сафари вылетает. Отладить его с помощью инструментов разработчика не представляется возможным — там нет ничего про расход памяти. Я с надеждой открыл страницу в iOS симуляторе — не падает. Глянул в Activity Monitor — о, а память-то растет с каждой картинкой и не освобождается. Ну хоть что-то. Стал экспериментировать. Чтобы вы понимали, что такое эксперимент в симуляторе: увидеть утечку памяти на одной картинке невозможно. На 4-5 затруднительно. Лучше всего брать штук 20. Перетащить или выбрать их с "шифтом" нельзя, нужно 20 раз кликнуть. После того как выбрал, надо смотреть в диспетчер задач и гадать: уменьшение расхода памяти на 50 мегабайт — это случайные флуктуации, или я что-то сделал правильно.

В общем, после большого количества проб и ошибок я пришел к простому, но очень важному выводу: за собой нужно все освобождать. Как можно раньше, любыми доступными способами. А выделять как можно позже. Полагаться на сборку мусора нельзя совершенно. Если создается холст, в конце его нужно занулить (сделать размером 1×1 пиксель), если картинка — в конце нужно ее выгрузить, присвоив src="about:blank". Просто удалить из DOM недостаточно. Если открывается файл через URL.createObjectURL, его нужно тут же закрывать через URL.revokeObjectURL.

После сильной переработки кода старый айфон с 512 Мб памяти стал переваривать и 50 фоток, и больше. Хром и Опера на Андроиде тоже стали вести себя значительно лучше — беспрецедентные 160 20-мегапиксельных фоточек дались хоть и медленно, но «без разрывов». Это же благотворно сказалось на потреблении памяти и десктопными браузерами — IE, Хром и Сафари стали кушать стабильно не более 200 мегабайт на вкладку во время работы. К сожалению, это не помогло Фаерфоксу — он как кушал примерно гигабайт на 25 тестовых картинок, так и продолжил. Про мобильный Фаерфокс и Дельфин под Андроидом ничего сказать нельзя — в них невозможно выбрать несколько файлов.

Часть 4. Что-то вроде заключения


Как видите, ресайзить картинки на клиенте чертовски увлекательно и больно. Получается этакий франкенштейн: отвратительный нативный ресайз многократно используется для получения хоть какого-то подобия качества. При этом нужно обходить недетектируемые лимиты различных платформ. И все равно остается много частных сочетаний исходного и конечного размера, когда картинка получается слишком мыльной или резкой.

Браузеры пожирают ресурсы как сумасшедшие, само ничего не освобождается, магия не работает. В этом смысле все хуже, чем при работе с компилируемыми языками, где нужно явно освобождать ресурсы. В js во-первых не очевидно, что нужно освобождать, во-вторых, это далеко не всегда возможно. Тем не менее, усмирить аппетиты хотя бы большинства браузеров вполне реально.

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

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

Кстати, вот еще несколько цифр: используя эту технику, 80 фотографий с Айфона 5, уменьшенных до разрешения 800×600, загружаются по сети 3G меньше чем за 2 минуты. Те же самые оригинальные фотографии могли бы загружаться 26 минут. Так что оно того стоило.
Tags:
Hubs:
+148
Comments 90
Comments Comments 90

Articles