Пишем эффективный blur на Android

Original author: Pavel Dudka
  • Translation
  • Tutorial
image
Сегодня мы попытаемся разобраться с методами размытия (blur) доступными для Android разработчиков. Прочитав определенное число статей и постов на StackOverflow, можно сказать, что мнений и способов выполнить эту задачу достаточно много. Я попытаюсь собрать все это в кучу.

И так, зачем?


Все чаще и чаще можно заметить эффект размытия в приложениях появляющихся на просторах Google Play Store. Взять хотя бы замечательное приложение Muzei от +RomanNurik или тот же Yahoo Weather. Глядя на эти приложения можно заметить, что при умелом обращении размытием можно добиться очень впечатляющих результатов.



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

Вот примерно то, чего мы будем пытаться добиться:

image

Приступим



Для начала хочу показать с чем мы работаем. Я создал 1 activity, внутри которой расположен ViewPager. ViewPager перелистывает фрагменты. Каждый фрагмент — отдельная реализация размытия.
Вот как выглядит мой main_layout.xml:

<android.support.v4.view.ViewPager
xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/pager"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.paveldudka.MainActivity" />


И вот как выглядит layout фрагмента (fragment_layout.xml):
<?xml version="1.0" encoding="utf-8"?>

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:id="@+id/picture"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:src="@drawable/picture"
        android:scaleType="centerCrop" />

    <TextView
        android:id="@+id/text"
        android:gravity="center_horizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="My super text"
        android:textColor="@android:color/white"
        android:layout_gravity="center_vertical"
        android:textStyle="bold"
        android:textSize="48sp" />
    <LinearLayout
        android:id="@+id/controls"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#7f000000"
        android:orientation="vertical"
        android:layout_gravity="bottom"/>
</FrameLayout>


Как видим, ничего военного — обычная картинка на весь экран с текстом посередине. Также можно заметить дополнительный LinearLayout — я буду его использовать для отображения всякой служебной информации.

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


Renderscript


Наверное, самым популярным ответом сегодня на вопрос «как быстро размыть картинку в Android» является Renderscript. Это очень мощный инструмент для работы с изображениями. Несмотря на его кажущуюся сложность, многие его части очень даже просты в использовании. К счастью, blur — это одна из подобных частей.

public class RSBlurFragment extends Fragment {
    private ImageView image;
    private TextView text;
    private TextView statusText;

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_layout, container, false);
        image = (ImageView) view.findViewById(R.id.picture);
        text = (TextView) view.findViewById(R.id.text);
        statusText = addStatusText((ViewGroup) view.findViewById(R.id.controls));
        applyBlur();
        return view;
    }

    private void applyBlur() {
        image.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
            @Override
            public boolean onPreDraw() {
                image.getViewTreeObserver().removeOnPreDrawListener(this);
                image.buildDrawingCache();

                Bitmap bmp = image.getDrawingCache();
                blur(bmp, text);
                return true;
            }
        });
    }

    @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
    private void blur(Bitmap bkg, View view) {
        long startMs = System.currentTimeMillis();

        float radius = 20;

        Bitmap overlay = Bitmap.createBitmap((int) (view.getMeasuredWidth()),
                (int) (view.getMeasuredHeight()), Bitmap.Config.ARGB_8888);

        Canvas canvas = new Canvas(overlay);

        canvas.translate(-view.getLeft(), -view.getTop());
        canvas.drawBitmap(bkg, 0, 0, null);

        RenderScript rs = RenderScript.create(getActivity());

        Allocation overlayAlloc = Allocation.createFromBitmap(
                rs, overlay);

        ScriptIntrinsicBlur blur = ScriptIntrinsicBlur.create(
                rs, overlayAlloc.getElement());

        blur.setInput(overlayAlloc);

        blur.setRadius(radius);

        blur.forEach(overlayAlloc);

        overlayAlloc.copyTo(overlay);

        view.setBackground(new BitmapDrawable(
                getResources(), overlay));

        rs.destroy();
        statusText.setText(System.currentTimeMillis() - startMs + "ms");
    }

    @Override
    public String toString() {
        return "RenderScript";
    }

    private TextView addStatusText(ViewGroup container) {
        TextView result = new TextView(getActivity());
        result.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
        result.setTextColor(0xFFFFFFFF);
        container.addView(result);
        return result;
    }
}


Давайте разберемся что же здесь происходит:
  • При создании фрагмента — создается layout, добавляется TextView в мою «сервисную панель»(я ее буду использовать чтобы отображать скорость работы алгоритма) и запускается размытие
  • Внутри applyBlur() я регистрирую onPreDrawListener. Делаю я это потому, что на момент вызова этой функции мои UI элементы еще не готовы, поэтому и размывать-то особо нечего. Поэтому мне надо дождаться момента когда мой layout будет измерян и готов к отрисовке. Этот колбэк будет вызван непосредственно перед отрисовкой первого фрейма
  • Внутри onPreDraw() первым делом что я обычно делаю — это меняю возвращаемое значение на true. Дело в том, что IDE генерирует false по умолчанию, а это значит, что отрисовка первого фрейма будет пропущена. В данном случае меня интересует первый фрейм, поэтому ставим true.
  • Далее убираем наш колбек — нас больше не интересуют onPreDraw события
  • Теперь мне надо вытащить Bitmap из моей ImageView. Заставляю ее создать drawing cache и забираю его
  • Ну и, собственно, размытие. Рассмотрим этот процесс подробнее


Сразу хочу отметить, что данный код имеет ряд недостатков, о которых следует обязательно помнить:
  • Данный код не «переразмывает» при изменениях layout'a. По-хорошему необходимо зарегистрировать onGlobalLayoutListener и перезапускать алгоритм при получении этого события
  • Размытие производится в главном потоке. Не пытайтесь делать это в своих приложениях — подобного рода операции надо «выгружать» в отдельный поток, чтобы не блокировать UI. AsyncTask или что-то подобное справятся с этой задачей


Вернемся к blur():
  • Создается пустой Bitmap, по размеру соответствующий нашему TextView — сюда мы скопируем кусок нашего фона
  • Создаем Canvas, чтобы можно было в этот Bitmap рисовать
  • Смещаем систему координат на позицию, на которой находится TextView
  • Рисуем кусок фона
  • На этом этапе у нас есть Bitmap, который содержит кусок фона, находящийся непосредственно за TextView
  • Создаем Renderscript объект
  • Копируем наш Bitmap в структуру, с которой работает Renderscript
  • Создаем скрипт для размытия (ScriptIntrinsicBlur)
  • Выставляем параметры размытия (в моем случае радиус 20) и запускаем скрипт
  • Копируем результат обратно в наш Bitmap
  • Отлично, у нас есть размытый Bitmap — устанавливаем его как фон для нашего TextView


Вот что получилось:
image

Как видим, результат довольно неплох на вид и занял у нас 57ms. Учитывая, что на отрисовку одного фрейма не должно уходить больше 16мс (~60fps), можно посчитать, что frame rate в нашем случае упадет до 17fps на период пока выполняется размытие. Я бы сказал, неприемлимо. Имеено поэтому необходимо сгрузить размытие в отдельный поток.

Хочу также заметить, что ScriptIntrinsicBlur доступен в API > 16. Безусловно, можно использовать renderscript support library, что позволит снизить необходимый уровень API.
Но, как нетрудно догадаться, на renderscript'e свет клином не сошелся, поэтому давайте рассмотрим одну из альтернатив.

FastBlur



На самом деле размытие — ничто иное как манипуляция с пикселями, поэтому что нам мешает самим этими самыми пикселями и поманипулировать? Благо, на просторах интернета доступно довольно большое кол-во реализаций всевозможных алгоритмов размытия, поэтому наша задача сводится к тому, чтобы выбрать наиболее оптимальный.
На всезнающем StackOverflow (а точнее тут), я наткнулся на неплохую реализацию алгоритма размытия.

Давайте посмотрим что из этого получилось. Весь код приводить не буду, потому что отличаться будет только функция blur():

private void blur(Bitmap bkg, View view) {
    long startMs = System.currentTimeMillis();
    float radius = 20;

    Bitmap overlay = Bitmap.createBitmap((int) (view.getMeasuredWidth()),
            (int) (view.getMeasuredHeight()), Bitmap.Config.ARGB_8888);
    Canvas canvas = new Canvas(overlay);
    canvas.translate(-view.getLeft(), -view.getTop());
    canvas.drawBitmap(bkg, 0, 0, null);
    overlay = FastBlur.doBlur(overlay, (int)radius, true);
    view.setBackground(new BitmapDrawable(getResources(), overlay));
    statusText.setText(System.currentTimeMillis() - startMs + "ms");
}



И вот результат:
image

Как видим, по качеству сложно заметить разницу с renderscript. Но вот производительность оставляет желать лучшего — 147ms! И это далеко не самый медленный алгоритм! Боюсь даже пробовать размывать по Гауссу.

Оптимизируем



Давайте на секунду задумаемся что из себя представляет размытие. По своей сути размытие очень тесно связано с «потерей» пикселей (здесь хотел бы попросить сильно не ругаться знатоков математики и графики, потому что описываю больше основываясь на свое понимание проблемы, чем на конкретные факты :) ). Что же еще может легко нам помочь «потерять» пиксели? Уменьшение картинки!

Что если мы уменьшим картинку сначала, размоем ее, а потом увеличим обратно?

Давайте пробовать!

image

И так, имеем 13ms renderscript и 2ms FastBlur. Довольно неплохо, учитывая, что качество размытия осталось сравнимо по качеству с предыдущими результатами.

Давайте взглянем на код. Я опишу только вариант с FastBlur, т.к. код для Renderscript будет аналогичен (ссылка на полную версию кода доступна в конце статьи):

private void blur(Bitmap bkg, View view) {
    long startMs = System.currentTimeMillis();
    float scaleFactor = 1;
    float radius = 20;
    if (downScale.isChecked()) {
        scaleFactor = 8;
        radius = 2;
    }

    Bitmap overlay = Bitmap.createBitmap((int) (view.getMeasuredWidth()/scaleFactor),
            (int) (view.getMeasuredHeight()/scaleFactor), Bitmap.Config.ARGB_8888);
    Canvas canvas = new Canvas(overlay);
    canvas.translate(-view.getLeft()/scaleFactor, -view.getTop()/scaleFactor);
    canvas.scale(1 / scaleFactor, 1 / scaleFactor);
    Paint paint = new Paint();
    paint.setFlags(Paint.FILTER_BITMAP_FLAG);
    canvas.drawBitmap(bkg, 0, 0, paint);

    overlay = FastBlur.doBlur(overlay, (int)radius, true);
    view.setBackground(new BitmapDrawable(getResources(), overlay));
    statusText.setText(System.currentTimeMillis() - startMs + "ms");
}


  • scaleFactor определяет насколько сильно будем уменьшать картинку. В моем случае уменьшать будем в 8 раз. Причем учитывая что львиную долю пикселей мы потеряем при уменьшении/увеличении картинки, можно смело уменьшать радиус размытия основного алгоритма. Путем научного тыка я уменьшил до 2х
  • Создаем Bitmap. В этот раз он будет меньше — в данном случае в 8 раз.
  • Заметим, что при отрисовке, я выставил флаг FILTER_BITMAP_FLAG, что позволит применить сглаживание при изменении размера картинки
  • Как и прежде, применяем размытие, но теперь ко много меньшей картинке и с меньшим радиусом, что позволяет ускорить алгоритм
  • Ставим эту маленькую картинку как фон для TextView — она автоматически будет увеличена.


Довольно интересно, что Renderscript отработал медленнее, чем FastBlur. Произошло это из-за того, что мы сэкономили время на копировании Bitmap в Allocation и обратно.

Как результат, мы получили довольно шустрый метод размытия картинок на Android

Полезные ссылки:
Исходники к данной стате на GitHub
Прародитель статьи
Пост на SO о алгоритмах размытия
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 11

    +6
    Вы перевели сами себя? Алгоритмы интересные, но есть комментарии к реализации:
    Подход к fps неверен, так как вместо подсчета каждый кадр вы делаете blur один раз и кэшируете результат тут
    view.setBackground(new BitmapDrawable(
                    getResources(), overlay))

    далее только рисуется BitmapBrawable, без затрат на blur.
    Считаю, что ООП заставляет вынести всю логику в отдельный элемент, который будет назван BlurTextView или BlurLayout, так как вы программно добавляете TextView в контейнер вот тут:
    private TextView addStatusText(ViewGroup container) {
            TextView result = new TextView(getActivity());
            ... container.addView(result); ...
        }

    или как минимум в класс BlurUtil, если потребуется использовать его не только для TextView или в рамках Layout.
    Если требования к ресурсам и производительности настолько высоки и при открытии фрагмента 50ms мы ждать не можем, можно нарисовать прямо на картинку, которая в ImageView, при этом мы также избавимся от overdraw и лишних затрат по выделению памяти, таких как
    Bitmap bmp = image.getDrawingCache(); // Тут выделится память для картинки размером с image!
      0
      Насчет fps — совершенно согласен, но тут скорей я просто выразился неточно. Имелось как раз в виду, что при длительности обработки >16ms начнут пропускаться фреймы. Этот дроп в fps будет наблюдаться только во время работы blur'a. В то же время blur отработает только 1 раз, а потом закешируется.
      Насчет ООП — работы тут еще много — т.к. логика в обоих фрагментах одинакова, можно было создать 1 базовый фрагмент и «скармливать» туда что-то вроде интерфейса Blurrer, Оптимизировать и оптимизировать)) Но целью данной статьи небыло создание универсального виджета production-уровня. Я лишь хотел показать с чем едят blur.
      Плюс далеко неизвестно кто и как будет использовать blur, поэтому сложно полагаться на тот факт, что размывать мы будем только при создании фрагмента. В данный момент, к примеру, я работаю над куском UI, который размывает фон при показе floating окошка поверх активити. Да и даже, я думаю, найдутся уникалы, которые захотят размывать фон динамически при скроллинге контента (я бы настойчиво не рекомендавал это делать :) ). Вобщем применений может быть масса.

      Но а в основном, очень даже валидные комментарии — обновлю GitHub проект как будет время.
        0
        В моем текущем проекте, к сожалению, требуется показывать полупрозрачную панель поверх списка (часть списка под панелью должна выглядеть размытой).

        Пользуюсь RenderScript для размытия, получается достаточно быстро, но все равно весь UI заметно подтормаживает. Пока отложил решение этой проблемы (подтормаживание UI) на потом.

        Может быть у вас есть идея, как поступать в случае вроде моего? Я понимаю, что лучше всего отговорить заказчика :-), но может есть какие-то другие идеи (судя по всему, вы сталкивались с подобной ситуацией)?
          0
          На самом деле даже не пробовал сделать  real-time blur. По понятным причинам размывать кусок фона при каждом изменении скрола не вариант :) Но можно попробовать сделать что-то вроде размытия всего экрана (верней той части  listView, что в данный момент видна на экране). Как только listView начинает скроллиться, скроллим нашу размытую подложку. Главное расположить layout так, чтобы подложка была видна только под ActionBar (ну или что используется для этих целей), т.е была иллюзия что размывается сам listView. Как только «доезжаем» до края размытой подложки — размываем новую порцию listView (что в данный момент на экране). В данном случае небольшой «лаг» будет заметен при обновлении подложки.
          Но, если честно, не уверен, что из этого выйдет что-то хорошее :( Надо пробовать.

          В Yahoo Weather сделали хитрый трюк — они не делают новую порцию размытого экрана как только доезжают до края подложки — они оставляют ее на месте. Создается иллюзия динамически размываемого фона, хотя по сути размывают они весь фон только один раз.
            0
            Спасибо, попробую вариант рисования порциями.
            0
            Кстати наткнулся на то, что Вы как раз ищите: GlassActionBar
              0
              Я смотрел на этот проект, но он тоже хитрит: он рисует все сразу в битмап.

              А в тестовом проекте есть примеры для разных вариантов использования кроме варианта со списком. К сожалению, списки могут быть очень длинными (в моем проекте и вообще) и нарисовать их целиком сразу не получится.
        +1
        Почему бы не реализовать через быстрое преобразование Фурье (ну или Хартли, так как изображение не комплексное, а вещественное)? Должно быть еще быстрее. Ведь это банальная свертка с ядром размытия. А от Гаусса даже Фурье считать не надо, опять будет Гаусс.
          –1
          Только на прошлой неделе по проекту столкнулся с подобным таском, я его отложил как сложный и не важный, а тут пост на эту тему, здорово)
            0
            Огромное спасибо за эту статью! Мне как Андроид-разработчику было очень интересно и полезно почитать. Кто знает, может в следующем приложении нужно будет реализовать размытие.
              0
              А есть up-to-date способы размывания активити под диалогом?

              Only users with full accounts can post comments. Log in, please.