Как я ускорил обработку изображений на Android в 15 раз

Как оптимизировать обработку изображений в рантайме, когда необходимо создать 6 изображений, каждое из которых состоит из последовательно наложенных 15-16 PNG, не получив OutOfMemoryException по дороге?


image


При разработке своего pet-приложения столкнулся с проблемой обработки изображений. Гугление хороших юзкейсов предоставить не смогло, поэтому пришлось ходить по своим граблям и изобретать велосипед самостоятельно.
Также во время разработки произошла миграция с Java на Kotlin, поэтому код в определенный момент будет переведен.


Задача


Приложение для занятий в тренажерном зале. Необходимо построение карты работы мышц по результатам тренировок в рантайме приложения.
Два пола: М и Ж. Рассмотрим вариант М, т. к. для Ж все аналогично.
Должно строится одновременно 6 изображений: 3 периода (одна тренировка, за неделю, за месяц) х 2 вида (спереди, сзади)


image


Каждое такое изображение состоит из 15 изображений групп мышц для вида спереди и 14 для вида сзади. Плюс по 1 изображению основы (голова, кисти рук и ступни ног). Итого, чтобы собрать вид спереди необходимо наложить 16 изображений, сзади – 15.


Всего 23 группы мышц для обеих сторон (для тех, у кого 15+14 != 23, небольшое пояснение – некоторые мышцы "видны" с обеих сторон).


Алгоритм наложения в первом приближении:


  1. На основе данных завершенных тренировок строится HashMap<String, Float>, String – название группы мышц, Float – степень нагрузки от 0 до 10.
  2. Каждая из 23 мышц перекрашивается в цвет от 0 (не участвовала) до 10 (макс. нагрузка).
  3. Накладываем перекрашенные изображения мыщц в два изображения (спереди, сзади).
  4. Сохраняем все 6 изображений.

image


Для хранения 31 (16+15) изображения размером 1500х1500 px при 24-битном режиме требуется 31х1500х1500х24бит = 199 MB оперативной памяти. Примерно при превышении ~30-40 МБ вы получаете OutOfMemoryException. Соотвественно, одновременно загрузить все изображения из ресурсов вы не можете, т. к. необходимо освобождать ресурсы для неполучения эксепшена. Это означает, что необходимо последовательно выполнять наложение изображений. Алгоритм трансформируется в следующий:


На основе данных завершенных тренировок строится HashMap<String, Float>, String – мышца, Float – степень нагрузки от 0 до 10.


Цикл для каждого из 6 изображений:


  1. Получили ресурс BitmapFactory.decodeResource().
  2. Каждая из 23 мышц перекрашивается в цвет от 0 (не участвовала) до 10 (макс. нагрузка).
  3. Накладываем перекрашенные изображения мыщц на один Canvas.
  4. Bitmap.recycle() освободили ресурс.

Задачу выполняем в отдельном потоке с помощью AsyncTask. В каждом Таске создается последовательно два изображения: вид спереди и сзади.


private class BitmapMusclesTask extends AsyncTask<Void, Void, DoubleMusclesBitmaps> {

        private final WeakReference<HashMap<String, Float>> musclesMap;

        BitmapMusclesTask(HashMap<String, Float> musclesMap) {
            this.musclesMap = new WeakReference<>(musclesMap);
        }

        @Override
        protected DoubleMusclesBitmaps doInBackground(Void... voids) {
            DoubleMusclesBitmaps bitmaps = new DoubleMusclesBitmaps();
            bitmaps.bitmapBack = createBitmapMuscles(musclesMap.get(), false);
            bitmaps.bitmapFront = createBitmapMuscles(musclesMap.get(), true);
            return bitmaps;
        }

        @Override
        protected void onPostExecute(DoubleMusclesBitmaps bitmaps) {
            super.onPostExecute(bitmaps);
            Uri uriBack = saveBitmap(bitmaps.bitmapBack);
            Uri uriFront = saveBitmap(bitmaps.bitmapFront);
            bitmaps.bitmapBack.recycle();
            bitmaps.bitmapFront.recycle();
            if (listener != null)
                listener.onUpdate(uriFront, uriBack);
        }
}

public class DoubleMusclesBitmaps {
        public Bitmap bitmapFront;
        public Bitmap bitmapBack;
}

Вспомогательный класс DoubleMusclesBitmaps нужен только для того, чтобы вернуть две переменные Bitmap-а: вид спереди и сзади. Забегая вперед Java-класс DoubleMusclesBitmaps заменяется на Pair<Bitmap, Bitmap> в Kotlin-е.


Рисование


Цвета colors.xml в ресурсах values.


<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="muscles_color0">#BBBBBB</color>
    <color name="muscles_color1">#ffb5cf</color>
    <color name="muscles_color2">#fda9c6</color>
    <color name="muscles_color3">#fa9cbe</color>
    <color name="muscles_color4">#f890b5</color>
    <color name="muscles_color5">#f583ac</color>
    <color name="muscles_color6">#f377a4</color>
    <color name="muscles_color7">#f06a9b</color>
    <color name="muscles_color8">#ee5e92</color>
    <color name="muscles_color9">#eb518a</color>
    <color name="muscles_color10">#e94581</color>
</resources>

Создание одного вида


public Bitmap createBitmapMuscles(HashMap<String, Float> musclesMap, Boolean isFront) {
        Bitmap musclesBitmap = Bitmap.createBitmap(1500, 1500, Bitmap.Config.ARGB_8888);
        Canvas resultCanvas = new Canvas(musclesBitmap);
        for (HashMap.Entry entry : musclesMap.entrySet()) {
            int color = Math.round((float) entry.getValue());
            //получение цвета программным способом из ресурсов цвета по названию
            color = context.getResources().getColor(context.getResources()
                    .getIdentifier("muscles_color" + color,
                            "color", context.getPackageName()));
            drawMuscleElement(resultCanvas, entry.getKey(), color);
        }
        return musclesBitmap;
}

Наложение одной мышцы


private void drawMuscleElement(Canvas resultCanvas, String drawableName, @ColorInt int color) {
        PorterDuff.Mode mode = PorterDuff.Mode.SRC_IN;
        Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        Bitmap bitmapDst = BitmapFactory.decodeResource(context.getResources(),
                context.getResources().getIdentifier(drawableName, "drawable", context.getPackageName()));
        bitmapDst = Bitmap.createScaledBitmap(bitmapDst, 1500, 1500, true);
        paint.setColorFilter(new PorterDuffColorFilter(color, mode));
        resultCanvas.drawBitmap(bitmapDst, 0, 0, paint);
        bitmapDst.recycle();//освобождение ресурса
}

Запускаем генерацию 3 пар изображений.


private BitmapMusclesTask taskLast;
private BitmapMusclesTask taskWeek;
private BitmapMusclesTask taskMonth;
private void startImageGenerating(){
        taskLast = new BitmapMusclesTask(mapLast);
        taskLast.execute();
        taskWeek = new BitmapMusclesTask(mapWeek);
        taskWeek.execute();
        taskMonth = new BitmapMusclesTask(mapMonth);
        taskMonth.execute();
}

Запускаем startImageGenerating():


> start   1549350950177
> finish  1549350959490  diff=9313 ms

Необходимо отметить, что очень много времени занимает чтение ресурсов. Для каждой пары изображений декодируется 29 PNG-файлов из ресурсов. В моем случае из общих затрат на создание изображений функция BitmapFactory.decodeResource() тратит ~75% времени: ~6960 ms.


Минусы:


  1. Периодически получаю OutOfMemoryException.
  2. Обработка занимает более 9 секунд, и это на эмуляторе(!) В "среднем" (старом моем) телефоне доходило до 20 секунд.
  3. AsyncTask со всеми вытекающими утечками [памяти].

Плюсы:
С вероятностью (1-OutOfMemoryException) изображения рисуются.


AsyncTask в IntentService


Для ухода от AsyncTask решено было перейти на IntentServiсe, в котором выполнялось задание по созданию изображений. После завершения работы сервиса, при наличия запущенного BroadcastReceiver-а получаем Uri всех шести сгенерированных изображений, иначе просто изображения сохранялись, для того, чтобы при следующем открытии пользователем приложения не было необходимости ожидать процесс создания. Время работы при этом никак не изменилось, но с одним минусом – утечками памяти разобрались, осталось еще два минуса.


Заставлять пользователей ожидать создание изображений такое количество времени, конечно же, нельзя. Нужно оптимизировать.


Намечаю пути оптимизации:


  1. Обработка изображений.
  2. Добавление LruCache.

Обработка изображений


Все исходные PNG-ресурсы имеют размер 1500х1500 пх. Уменьшаем их до 1080х1080.
Как видно на второй фотографии все исходники квадратные, мышцы находятся на своем месте, а реальные полезные пиксели занимают небольшую площадь. То, что все группы мышц уже находятся на своем месте — это удобно для программиста, но не рационально для производительности. Кропаем (отрезаем) лишнее во всех исходниках, записывая положение (x, y) каждой группы мышц, чтобы наложить в последствии в нужное место.


В первом подходе перекрашивались и накладывались все 29 изображений групп мышц на основу. Основа же включала в себя только голову, кисти рук и части ног. Изменяем основу: теперь она включает в себя помимо головы, рук и ног, все остальные группы мышц. Всё красим в серый цвет color_muscle0. Это позволит не перекрашивать и не накладывать те группы мышцы, которые не были задействованы.


Теперь все исходники выглядят так:


image


LruCache


После дополнительной обработке исходных изображений, некоторые стали занимать немного памяти, что привело к мысли о переиспользовании (не освобождать их после каждого наложения методом .recycle() ) с помощью LruCache. Создаем класс для хранения исходных изображений, который одновременно берет на себя функцию чтения из ресурсов:


class LruCacheBitmap(val context: Context) {

    private val lruCache: LruCache<String, Bitmap>

    init {
        val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
        val cacheSize = maxMemory / 4
        lruCache = object : LruCache<String, Bitmap>(cacheSize) {
            override fun sizeOf(key: String, bitmap: Bitmap): Int {
                return bitmap.byteCount
            }
        }
    }

    fun getBitmap(drawableName: String): Bitmap? {
        return if (lruCache.get(drawableName) != null) lruCache.get(drawableName) else decodeMuscleFile(drawableName)
    }

    fun clearAll() {
        lruCache.evictAll()
    }

    private fun decodeMuscleFile(drawableName: String): Bitmap? {
        val bitmap = BitmapFactory.decodeResource(context.resources,
                context.resources.getIdentifier(drawableName, "drawable", context.packageName))
        if (bitmap != null) {
            lruCache.put(drawableName, bitmap)
        }
        return bitmap
    }
}

Изображения подготовлены, декодирование ресурсов оптимизировано.
Плавный переход с Java на Kotlin обсуждать не будем, но он произошел.


Корутины


Код с использованием IntentService работает, но читаемость кода с колбэками не назовешь приятной.


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


Также ускорение обработки изображений натолкнуло на мысль использовать фичу в нескольких новых местах приложения, в частности в описании упражнений, а не только после тренировки.


private val errorHandler = CoroutineExceptionHandler { _, e ->  e.printStackTrace()}
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.Main + job + errorHandler)
private var uries: HashMap<String, Uri?> = HashMap()
fun startImageGenerating() = scope.launch {
            ...
            val imgMuscle = ImgMuscle()
            uries = withContext(Dispatchers.IO) { imgMuscle.createMuscleImages() }
            ...
}

Стандартная связка errorHandler, job и scope – скоуп корутин с хендлером ошибок, если корутина сломается.


uries – HashMap, который хранит в себе 6 изображений для последующего вывода в UI:
uries["last_back"]=Uri?
uries["last_front"]=Uri?
uries["week_back"]=Uri?
uries["week_front"]=Uri?
uries["month_back"]=Uri?
uries["month_front"]=Uri?


class ImgMuscle {
    val lruBitmap: LruCacheBitmap
    suspend fun createMuscleImages(): HashMap<String, Uri?> {
        return suspendCoroutine { continuation ->
                val resultUries = HashMap<String, Uri?>()
                ... //создаем и сохраняем изображения 
                continuation.resume(resultUries)
        }
    }
}

Замеряем время обработки.


>start   1549400719844
>finish  1549400720440 diff=596 ms

С 9313 мс обработка уменьшилась до 596 мс


Если есть идеи по дополнительной оптимизации – велком в комментарии.

Поделиться публикацией

Комментарии 23

    +3
    Удивительно, что вы не начали с варианта хранения сразу всех заготовок мышц покрашенных во все возможные цвета.
      0
      Тоже сразу об этом подумал
        +1
        Удивительно, но это не рационально :)
        Во-первых, сейчас заготовок: 31 мышца Х 2 пола = 62 файла. Если сохранять все перекрашенные варианты — это х10 цветов, или 620 файлов. Текущий объем файлов занимает 766КБ (уже кропнутых). По вашему варианту объем отдельных файлов составит более 7МБ – это роскошь для мобайла.
        Во-вторых, декодирование ресурсов требует в 3-6 раз больше времени, чем перекрашивание. Специально для вас замерил:
        decodeResource> 83 ms
        Paint> 18 ms
        decodeResource> 85 ms
        Paint> 16 ms
        decodeResource> 137 ms
        Paint> 26 ms

        В-третьих, как следствие второго, вы не сможете переиспользовать ресурсы, уже декодированные.
        +6
        А что если использовать для этих целей SVG? Можно ли в андроиде изменить цвет отдельной ноды в SVG? Чтобы не нужно было хранить кучу больших PNG и отрисовать сразу в один проход. Если нет, то можно разбить на кучу маленьких SVG, менять им tint color в рантайме и отрисовывать — даже так должно быть эффективнее.
          0
          Ох, как же я забыл об этом написать, это был первый вариант. Все мышцы хранились в VectorDrawable (тот же SVG, используется в Android). К сожалению, программно заменить цвет заливки нельзя (либо я не нашел таких методов). Но самый большой минус – векторные изображения сначала конвертируются в растр нужного размера, а затем уже их можно полноценно использовать, а это дополнительная память и время.
          Поэтому от использования вектора очень быстро отказался, но можно было бы озаглавить пост «Ускорил в 100 раз!», наверное :)
          В дополнение, существует LayerDrawable, который позволяет накладывать массив Drawable друг на друга. Удобно, но ресурсоёмко.
            +2
            Скорее всего Вы не нашли как.
            Даже на уровне кривого хака, от человека который ничего не смыслит в андроиде, там наверняка есть файловые и строковый операции. Которые позволяют сделать следующее:

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

            Если андроид не умеет показывать SVG, в чем я сильно сомневаюсь, он наверняка умеет конвертировать один формат в другой.

            Профит.

              0
              Супер, видится очень быстрым.
              Благодарю!
                0
                Я сам не андроид разработчик, но на StackOverflow есть похожая тема с большим количеством вариантов. Возможно, что-то подойдёт.
                  0

                  Еще можно попробовать сделать Custom View перенеся все фигуры в кривые через Path.
                  Раньше писал свой плагин для Sketch, но он работает теперь только со старыми версиями. Сейчас PaintCode позволяет это делать за тебя. Можно реализовать определения кликов через класс Region и сделать вполне себе интерактивную карту тела, меняя цвета в рантайме через перерисовку.


                  Можешь посмотреть примерную реализацию работы с регионами писал очень давно, но вроде как еще актуально все)

                  0
                  Если даже с SVG возникнут какие-то сложности, в данном случае будет достаточно отрисовки через Canvas.drawPath.
                  0
                  Я разработкой под Android не занимался, но неужели не существует простого 2D движка с рендером на GPU с поддержкой SVG без предварительной растеризации в текстуру? Батарейку сильно жрать не должно, так как перерисовку не нужно будет выполнять постоянно как в играх.
                +3

                С ходу напрашивается


                1. Сделать один нормальный, грейскейленый атлас со всеми группами мышц (не нужно ни каких основ)
                2. Один раз построить карту с положением группы на теле человека (x, y)
                3. Чтобы отобразить нужное изображение (тело человека спереди или сзади) просто последовательно отрисовываем нужные элементы из атласа в позиции соответствующей карте из пункта 2. Для интенсивности вешаем на отрисовку нужного элемента простой шейдер, который раскрасит его в заданный цвет.
                4. Это смело можно делать в рантайме, нужно загрузить только один (или два в зависимости от ограничений на текстуры) атлас. Таким образом отображаем любую картинку, которая будет соответствовать данным — карта с название группы мышц и степенью нагрузки. А хранить исключительно данные тренировки
                  0
                  1. Так и есть. В оптимизированном варианте изображение основы включает все кости и мышцы. Накладываются только участвовавшие мышцы.
                  2. Так и есть. В каждой группе мышц захардкодены ее координаты на основе (общей карте).
                  3. Так и есть. Отрисовываются только нужные элементы, но методом
                  resultCanvas.drawBitmap(bitmapDst, x, y, paint); 

                  О шейдерах ничего не могу сказать, не знаком, посмотрю обязательно, спасибо!
                    0
                    Причем можно хранить только левые (или правые мышцы), а недостающие зеркалить на лету.
                    +1
                    Почему нельзя просто хранить карту мышц, а пользователю генерировать новую картинку расскрашивая нужные мышцы?
                      0

                      Генерирование изображений действительно было необходимо?
                      Почему бы не использовать изображение с индексированным цветом (PNG8), на изображении фигура целиком, для каждой группы мышц — отдельный цвет и отрисовывать с нужной на данный момент палитрой?


                      На основе данных завершенных тренировок строится HashMap<String, Float>, String – мышца, Float – степень нагрузки от 0 до 10.

                      Зачем String, ведь есть же enum?

                        0

                        Да, готовые используются для наложения в других местах.
                        String также необходим для поиска, хранения в бд, статистики и тд.

                          0
                          Бизнес логика должна работать с ID мышцы (ENUM и его int значение).
                          Захотите потом сделать смену языка (русский / английский). Что тогда произойдет с уже наполненой базой данных, где ключем является имя?

                            0

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


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


                            String также необходим для поиска, хранения в бд, статистики и тд.

                            Элементам enum-a можно добавить дополнительные аттрибуты:


                            public enum SomeEnum {
                            
                                FOO(123, "Alpha"),
                                BAR(345, "Beta"),
                                BAZ(567, "Gamma");
                            
                                private int id;
                            
                                private String description;
                            
                                SomeEnum(int id, String description) {
                                    this.id = id;
                                    this.description = description;
                                }
                            
                                public int getId() {
                                    return this.id;
                                }
                            
                                public String getDescription() {
                                    return this.description;
                                }
                            
                            }



                            public class Test {
                            
                                public static void main(String... args) {
                                    for(SomeEnum value : SomeEnum.class.getEnumConstants()) {
                                        System.out.println();
                                        System.out.println("Enum Name:        " +  value.name());
                                        System.out.println("Enum Id:          " +  value.getId());
                                        System.out.println("Enum Description: " +  value.getDescription());
                                    }
                                }
                            
                            }
                          0
                          — Ты чего такой грустный?
                          — Да вот начальство заставляет поднять цену на товары для постоянных клиентов на 20%. Неудобно как-то…
                          — Ой, фигня вопрос! Объявляешь поднятие цены на 100%, а для постоянных клиентов — скидка 80%

                          Как-то так.
                          (как я накосячил и тормознул свое приложение в 50 раз — а потом обнаружил косяки и частично исправил)
                            0

                            Мне вот тоже непонятно использование растра для изображений с большими зонами заливки одним цветом и даже без градиента. А вот вектор позволил бы даже легко растянуть фигурку вширь, чтобы по мере тренировок можно было делать её всё стройнее и стройнее :)

                              0
                              Обсуждаемый вверху вариант с использованием VectorDrawable имеет место быть. Есть несколько опен соурс решений по смене цвета «path» или «group» в конечном файле xml (vector drawable). Думаю стоит присмотреться, если минимальная версия sdk позволяет (>21).
                              Пример нескольких библиотек:
                              github.com/harjot-oberai/VectorMaster
                              github.com/devendroid/VectorChildFinder
                                0
                                Есть либа AndroidSVG, которая напрямую отрисовывает вектор на канве без конвертации в битмап.

                                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                Самое читаемое