Неприемлемая производительность при чтении прозрачного .png Pixel by Pixel

Я создаю инструмент, который обнаруживает спрайты на листе спрайтов и преобразует каждый найденный спрайт в новый BufferedImage. Этот процесс работает, но непозволительно медленно с определенными форматами изображений, в основном прозрачными изображениями, такими как этот:

Kenney's Game Assets — Animal Pack — Round

(Игровые активы Kenney — Animal Pack)

Я профилировал свой код и определил, что подавляющее большинство, более 99% времени моего приложения, тратится только на этот метод из-за множества вызовов getRGB().

private fun findContiguousSprite(image: BufferedImage, startingPoint: Point, backgroundColor: Color): List<Point> {
    val unvisited = LinkedList<Point>()
    val visited   = arrayListOf(startingPoint)

    unvisited.addAll(neighbors(startingPoint, image).filter {
        Color(image.getRGB(it.x, it.y)) != backgroundColor
    })

    while (unvisited.isNotEmpty()) {
        val currentPoint = unvisited.pop()
        val currentColor = Color(image.getRGB(currentPoint.x, currentPoint.y))

        if (currentColor != backgroundColor) {
            unvisited.addAll(neighbors(currentPoint, image).filter {
                !visited.contains(it) &&
                !unvisited.contains(it) &&
                (Color(image.getRGB(it.x, it.y)) != backgroundColor)
            })

            visited.add(currentPoint)
        }
    }

    return visited.distinct()
}

Я попытался оптимизировать процесс извлечения цветов RGB, как показано в вопросе Java - получить пиксель массив из изображения путем доступа к буферу растровых данных изображения, но это не работает в новейших версиях Java с расширением java.lang.ClassCastException: java.awt.image.DataBufferInt cannot be cast to java.awt.image.DataBufferByte.

Другие камни преткновения включают обманчиво ненужное сочетание цветов, например, в строке Color(image.getRGB(it.x, it.y)) != backgroundColor. Однако если image.getRGB() по умолчанию возвращает цветовое пространство RGBA, то background.rgb возвращает только цветовое пространство sRGB.

Вопрос: как повысить скорость чтения BufferedImage, особенно в случае прозрачных изображений? Почему это так быстро почти с любым другим изображением .png, которое я добавляю, кроме этих?

Примечание. Хотя код написан на Kotlin, в качестве ответа я принимаю Java или любой другой язык JVM.

Дамп кода. Если вам нужен полный раздел кода:

private fun findSpriteDimensions(image: BufferedImage,
                                 backgroundColor: Color): List<Rectangle> {
    val workingImage = image.copy()

    val spriteDimensions = ArrayList<Rectangle>()
    for (pixel in workingImage) {
        val (point, color) = pixel

        if (color != backgroundColor) {
            logger.debug("Found a sprite starting at (${point.x}, ${point.y})")
            val spritePlot = findContiguousSprite(workingImage, point, backgroundColor)
            val spriteRectangle = spanRectangleFrom(spritePlot)

            logger.debug("The identified sprite has an area of ${spriteRectangle.width}x${spriteRectangle.height}")

            spriteDimensions.add(spriteRectangle)
            workingImage.erasePoints(spritePlot, backgroundColor)
        }
    }

    return spriteDimensions
}

private fun findContiguousSprite(image: BufferedImage, startingPoint: Point, backgroundColor: Color): List<Point> {
    val unvisited = LinkedList<Point>()
    val visited   = arrayListOf(startingPoint)

    unvisited.addAll(neighbors(startingPoint, image).filter {
        Color(image.getRGB(it.x, it.y)) != backgroundColor
    })

    while (unvisited.isNotEmpty()) {
        val currentPoint = unvisited.pop()
        val currentColor = Color(image.getRGB(currentPoint.x, currentPoint.y))

        if (currentColor != backgroundColor) {
            unvisited.addAll(neighbors(currentPoint, image).filter {
                !visited.contains(it) &&
                !unvisited.contains(it) &&
                (Color(image.getRGB(it.x, it.y)) != backgroundColor)
            })

            visited.add(currentPoint)
        }
    }

    return visited.distinct()
}

private fun neighbors(point: Point, image: Image): List<Point> {
    val points = ArrayList<Point>()
    val imageWidth = image.getWidth(null) - 1
    val imageHeight = image.getHeight(null) - 1

    // Left neighbor
    if (point.x > 0)
        points.add(Point(point.x - 1, point.y))

    // Right neighbor
    if (point.x < imageWidth)
        points.add(Point(point.x + 1, point.y))

    // Top neighbor
    if (point.y > 0)
        points.add(Point(point.x, point.y - 1))

    // Bottom neighbor
    if (point.y < imageHeight)
        points.add(Point(point.x, point.y + 1))

    // Top-left neighbor
    if (point.x > 0 && point.y > 0)
        points.add(Point(point.x - 1, point.y - 1))

    // Top-right neighbor
    if (point.x < imageWidth && point.y > 0)
        points.add(Point(point.x + 1, point.y - 1))

    // Bottom-left neighbor
    if (point.x > 0 && point.y < imageHeight - 1)
        points.add(Point(point.x - 1, point.y + 1))

    // Bottom-right neighbor
    if (point.x < imageWidth && point.y < imageHeight)
        points.add(Point(point.x + 1, point.y + 1))

    return points
}

Первая оптимизация

Руководствуясь комментарием @Durandal, я решил изменить свой ArrayList на HashSet. Я также нашел способ сохранить альфа-значения, используя альтернативный конструктор для цвета, Color(rgb, preserveAlpha). Теперь мне больше не нужно упаковывать getRGB() перед сравнением двух значений.

private fun findContiguousSprite(image: BufferedImage, point: Point, backgroundColor: Color): List<Point> {
    val unvisited = LinkedList<Point>()
    val visited   = hashSetOf(point)

    unvisited.addAll(neighbors(point, image).filter { image.getRGB(it.x, it.y) != backgroundColor.rgb })

    while (unvisited.isNotEmpty()) {
        val currentPoint = unvisited.pop()
        val currentColor = image.getRGB(currentPoint.x, currentPoint.y)

        if (currentColor != backgroundColor.rgb) {
            unvisited.addAll(neighbors(currentPoint, image).filter {
                !visited.contains(it) &&
                !unvisited.contains(it) &&
                image.getRGB(it.x, it.y) != backgroundColor.rgb
            })

            visited.add(currentPoint)
        }
    }

    return visited.toList()
}

Это обработало изображение выше в 319ms. Потрясающий!


person IAE    schedule 25.05.2016    source источник
comment
Ничего не тестируя, я предполагаю, что основным узким местом производительности является использование неподходящих типов коллекций, LinkedList и ArrayList в сочетании с использованием contains() всегда является предупреждающим флагом. HashSet, вероятно, является более подходящим типом для такого типа работы.   -  person Durandal    schedule 25.05.2016
comment
Эй, Дюрандаль, спасибо за предложение! Я реализовал его в коде, и он значительно ускорился — см. редактирование оптимизации. Вы должны получить признание за свой комментарий, почему бы вам не опубликовать его как ответ и не получить несколько голосов?   -  person IAE    schedule 25.05.2016
comment
Я не очень беспокоюсь о репутации, но некоторые пояснительные рассуждения могут быть полезны любому, кто здесь споткнется, поэтому я напишу это как ответ.   -  person Durandal    schedule 26.05.2016
comment
Вы написали: я попытался оптимизировать процесс извлечения цветов rgb, как показано в вопросе Java - получить массив пикселей из изображения, обратившись к буферу растровых данных изображения, но это не удается в новейших версиях Java с java.lang.ClassCastException : java.awt.image.DataBufferInt нельзя преобразовать в java.awt.image.DataBufferByte. -- Просто создайте новый BufferedImage с правильным форматом, -- и нарисуйте предыдущий в новый. Затем вы можете использовать предпочтительный формат DataBuffer. Даже если это займет несколько мс, я уверен, что общее время будет быстрее.   -  person Terje    schedule 27.05.2016
comment
Эй, Терье, спасибо за вклад! К сожалению, я не уверен, что вы имеете в виду под правильным форматом. После прочтения я автоматически преобразовываю каждый BufferedImage в тип ARGB. Мне это нужно, потому что извлеченные спрайты должны иметь прозрачный фон.   -  person IAE    schedule 27.05.2016


Ответы (1)


Использование contains в ArrayList или LinkedList имеет сложность O(n). Это может быстро превратиться в большие накладные расходы, учитывая, что вы выполняете много вызовов contains() в своем фильтре. Сложность поиска visited.contains() растет с увеличением размера обрабатываемого изображения (больше пикселей для посещения и больше посещенных пикселей, превращая сложность в O(n^2)).

Самый простой способ уменьшить эту стоимость — использовать тип коллекции с быстрым содержанием; как O(1) в случае HashSet. Семантика Set также немного лучше соответствует вашим требованиям, чем списки, поскольку я понимаю, что посещенные/непосещенные коллекции не должны допускать дубликатов. Поскольку наборы не допускают дублирования по контракту, некоторые явные проверки содержимого могут быть устранены; в местах, где вы хотите отреагировать на событие первого добавления точки, вы также можете использовать логический результат, который дает метод add () (истинно, когда элемент не присутствовал и был добавлен).

Хотя упаковка/распаковка требует некоторого времени, это линейная стоимость. Сокращение накладных расходов на бокс потребует немало изменений в коде и «только» даст вам постоянное ускорение. Стандартные коллекции не работают с примитивами (без автоупаковки, которой вы хотите избежать в первую очередь). В то время как есть специальные коллекции сторонних производителей (на ум приходит Trove), которые обрабатывают примитивы без бокса. Я бы пошел по этому пути только в случае крайней необходимости, переходя к примитивным типам, где это возможно, скорее всего, ваш код станет длиннее и несколько более запутанным.

person Durandal    schedule 26.05.2016