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

в 8:33, , рубрики: android, Разработка под android

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 о алгоритмах размытия

Автор: paveldudka

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js