Изображения. Минификация на максималках

Привет! Меня зовут Алексей Дёмин, я Android-разработчик в Prequel - мобильном редакторе для фото и видео. Даунскейл изображений встречается в приложениях повсеместно, но из-за высокого уровня абстракции работы с изображениями может возникнуть иллюзия о тривиальности этого процесса. Сегодня я бы хотел детальнее взглянуть на даунскейл и разобраться что именно происходит с изображением с точностью до пикселя.

Описание проблемы.

Пусть у нас есть картинка 1024x1024 пикселя. И мы хотим отобразить её на экране в окне 512x512 пикселей. Как нам это сделать? Каждому пикселю на экране соответствует 4 пикселя исходного изображения. Как именно агрегировать информацию из этих 4х пикселей, чтобы потерять минимум деталей? Или выйти за рамки 4х пикселей и использовать более сложные алгоритмы?

Примечание: В данной статье рассматривается даунскейл с сохранением ратио.

Дефолтный способ.

Для изменения размера изображения Android предлагает нам метод Bitmap.createScaledBitmap , у которого есть 4 параметра:

  1. Bitmap src - исходное изображение

  2. int dstWidth - желаемая ширина

  3. int dstHeight - желаемая высота

  4. boolean filter - Использовать ли билинейную фильтрацию

4ый параметр описывает как именно агрегировать информацию из набора пикселей исходного изображения, соответствующего пикселю уменьшенного. Есть 2 способа это сделать:

  1. Nearest-Neighbor

  2. Bilinear Filtering

Разберём каждый способ на примере.

Nearest-Neighbor.

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

Пусть у нас есть такая картинка 10x10 пикселей:

И мы хотим уменьшить её до размера 3x3 пикселя.

Если мы будем использовать метод Nearest-Neighbor, то получим вот такой результат:

Почему так произошло:

Чтобы определить какие пиксели исходного изображения соответствуют уменьшенному изображению 3x3 мы делаем следующее: делим исходное изображение на 3 части и в середине каждой области размещаем пиксель результирующего изображения.

Для 1го пикселя уменьшенного изображения ближайший пиксель исходного изображения имеет координаты (1,1) .

Для 2го - пиксели (1,4) и (1,5) - поэтому метод просто выбирает какой-то из них - в нашем случае (1,4)

Для 3го ближайший - (1,8)

Аналогично для остальных строк. Таким образом 1 столбец красный, 2ой красный и 3ий синий.

Bilinear Filtering.

При использовании билинейной фильтрации получаем следующий результат:

Алгоритм разбиения на области тот же, но теперь мы смешиваем 4 пикселя исходного изображения в соответсвии с их площадью, пересекающейся с пикселем результирующего изображения.
Для 1го пикселя - все 4 красные
Для 2го площадь красных равна площади синих, поэтому результирующий цвет - смесь красного и синего в равных пропорциях
Для 3го пикселя - все 4 синие

Проверим результат с помощью color-picker-а Загружаем результирующую картину и получаем цвета по столбцам в формате RGB

  1. (255,0,0) - красный

  2. (128,0,128) - равное соотношение красного и синего

  3. (0,0,255) - синий

Playground тут

Текстуры.

Взглянем чуть глубже на процесс отрисовки изображения.

В большинстве своём для отрисовки используется OpenGL ES(Далее OpenGL). Чтобы отрисовать картинку в OpenGL нам потребуется создать текстуру.

При создании текстуры нам необходимо указать 2 свойства:

  1. GL_TEXTURE_MIN_FILTER - фильтр применяемый при уменьшении изображения

  2. GL_TEXTURE_MAG_FILTER - фильтр применяемый при увеличении изображения

GL_TEXTURE_MAG_FILTER имеет 2, уже знакомых нам, возможных значения:

  1. GL_NEAREST - Nearest-Neighbor

  2. GL_LINEAR - Bilinear Filtering

Тогда как GL_TEXTURE_MIN_FILTER помимо 2х вышеперечисленных вариантов, имеет 4 дополнительных опции:

  1. GL_NEAREST_MIPMAP_NEAREST

  2. GL_LINEAR_MIPMAP_NEAREST

  3. GL_NEAREST_MIPMAP_LINEAR

  4. GL_LINEAR_MIPMAP_LINEAR

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

Проект на OpenGL ES.

Статья не является туториалом по OpenGL, поэтому описание шагов создания проекта будет верхнеуровневым, исходники доступны по ссылке.

Отрисовка происходит на GLSurfaceView с помощью класса имплементирующего GLSurfaceView.Renderer - SimpleTextureRenderer

Для отрисовки текстуры необходимо создать vertexShader

attribute vec4 a_Position;
attribute vec2 a_TextureCoordinates;
varying vec2 v_TextureCoordinates;
void main() {
    v_TextureCoordinates = a_TextureCoordinates;
    gl_Position = a_Position;
}

С помощью которого мы зададим соответствие координаты текстуры и координат экрана.

И fragmentShader

precision mediump float;
uniform sampler2D u_TextureUnit;
varying vec2 v_TextureCoordinates;
void main() {
    gl_FragColor = texture2D(u_TextureUnit, v_TextureCoordinates);
}

Который рассчитает цвета пикселей на основе переданной текстуры

Определим массивы координат экрана и текстур

private val positionVertexes=floatArrayOf(
// Triangle 1
-1f, -1f,
1f, 1f,
-1f, 1f,
// Triangle 2
-1f, -1f,
1f, 1f,
1f, -1f,
)
private val textureVertexes=floatArrayOf(
// Triangle 1
0f, 0f,
1f, 1f,
0f, 1f,
// Triangle 2
0f, 0f,
1f, 1f,
1f, 0f,
)

Стоит обратить внимание, что координаты экрана от -1 до 1, а координаты текстуры от 0 до 1

метод TextureHelper.loadTexture создаёт текстуру и с помощью метода glTexImage2D загружает в неё битмапу из ресурсов

метод TextureHelper.readPixels позволяет считать в битмапу изображение отрисованное в данный момент на экране(для простоты опустим нюансы с FrameBuffer-ами)

Далее отрисовав картинку 10x10

загруженную в текстуру с фильтрами GL_NEAREST и GL_LINEAR на GLSurfaceView размером 3x3, получаем результаты, аналогичные полученным с помощью метода Bitmap.createScaledBitmap , рассматриваемого выше.

Фильтрация с помощью Mipmap.

Для того, чтобы сделать нашу минификацию более плавной и качественной, OpenGL предоставляет нам возможность использования автоматически сгенерированных текстур, называемых mipmap. Они представляют из себя уменьшенные копии исходного изображения определённого размера. Размер рассчитывается по следующей формуле:

\lfloor(size/2^n)\rfloor

где size - исходный размер, а n - mipmap-уровень.

Например пусть размер исходного изображения 10x10. Тогда мы получим следующие размеры изображения для соответствующий mipmap-уровней

Уровень

Размер

0

10

1

5

2

2

3

1

Сами mipmap-ы генерируются на основе более совершенных алгоритмов минификации, чем NEAREST или LINEAR. Применение этих алгоритмов занимает больше времени, но так как mipmap-ы генерируются 1 раз при создании текстуры, а при отрисовке просто переиспользуются, то это не сказывается на производительности.

Без использования mipmap мы могли интерполировать цвет пикселя только по 2м направлениям: по оси X и оси Y, поэтому алгоритм, соответствующий GL_LINEAR, называется билинейным. С добавлением mipmap-ов у нас появляется дополнительный уровень интерполяции, который генерит ещё 4 варианта фильтрации:

  1. GL_NEAREST_MIPMAP_NEAREST - Выбираем ближайюшую по размеру mipmap-у и в ней ближайший пиксель

  2. GL_LINEAR_MIPMAP_NEAREST - Выбираем ближайюшую по размеру mipmap-у и в ней линейно интерполируем между пикселями

  3. GL_NEAREST_MIPMAP_LINEAR - Выбираем ближайший пиксель в каждой из 2х ближайших mipmap и линейно интерполируем между ними

  4. GL_LINEAR_MIPMAP_LINEAR - Линейно интерполируем внутри каждой из 2х ближайших mipmap и линейно интерполируем между ними

Рассмотрим конкретный пример. Пусть у нас есть изображение 10x10:

Мы хотим уменьшить его до размера 4x4.
В таком случае интерполяция будет между mipmap-ами 1 и 2 уровней.
Чтобы полностью контролировать итоговый цвет воспользуемся возможностью загрузки кастомных mipmap-ов.

glTexImage2D(
GL_TEXTURE_2D,
level,
GL_RGBA,
mipmapSize,
mipmapSize,
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
getByteBufferFromResource(context, resource)
)

Для 1го уровня загрузим изображение аналогичное исходному, но размером 5x5

Для 2го уровня, для показательности примера, загрузим полностью зелёную текстуру размера 2x2

Получаем следующие результаты:

GL_NEAREST_MIPMAP_NEAREST

Разберёмся почему результат именно такой

Ближайшая по размеру mipmap - это mipmap 1го уровня, её размер 5x5. Разделяем на 4 равные части и размещаем в центе пиксель итогового изображения.

Так как мы берём ближайший, то получаем, что

  1. 1ый пиксель синий

  2. 2ой - красный

  3. 3ий - красный

  4. 4ый - синий

Для GL_LINEAR_MIPMAP_NEAREST

Получаем такой результат

В формате RGB он выглядит следующим образом:

  1. (32,0,223)

  2. (159,0,96)

  3. (159,0,96)

  4. (32,0,223)

Как видно из расположения итоговых пикселей:

Первый пиксель итоговой картинки пересекается с синим пикселем исходной на 7/8, с красным на 1/8, итоговое соотношение 7:1

255 * (1/8) = 32 - R компонента

255 * (7/8) = 223 - B компонента

Второй пиксель итоговой картинки пересекается с синим пикселем исходной на 3/8, с красным на 5/8, итоговое соотношение 5:3

255 * (5/8) = 159 - R компонента

255 * (3/8) = 96- B компонента

Для GL_NEAREST_MIPMAP_LINEAR получаем следующий результат:

В формате RGB он выглядит следующим образом:

  1. (0,82,173)

  2. (173,82,0)

  3. (173,82,0)

  4. (0,82,173)

В этом случае интерполяция рассчитывается между mipmap-уровнями.

Коэффициент вычисляется следующим образом:

1 - \log_{2}(size / scaled)

где size - исходный размер, scaled - итоговый размер

Для нашего примера будет следующий результат:

1 - \log_{2}(10 / 4) = 0,3219

То есть 255 * 0,3219 = 82 - G компонента

Для GL_LINEAR_MIPMAP_LINEAR получаем следующий результат:

В формате RGB он выглядит следующим образом:

  1. (22,82,152)

  2. (108,82,65)

  3. (108,82,65)

  4. (22,82,152)

Зелёная компонента определяется так же, как для GL_NEAREST_MIPMAP_LINEAR , а оставшиеся 255 - 82 = 173 пункта распределяются в таком же соотношении, как для GL_LINEAR_MIPMAP_NEAREST .

Например красная компонента для 1го пикселя 173 * (1/8) = 22

Playground тут

Выводы.

Высокоуровневые методы предоставляемые нам классом Bitmap не являются самыми качественными из доступных.

Пример даунскейла картинки

Результат для GL_LINEAR

Резульата для GL_LINEAR_MIPMAP_LINEAR

Результат с использованием mipmap более детализированный и плавный. Если качество уменьшенной картинки критично, то полезно иметь представление о возможности использования mipmap-ов.

Всем добра и качественного даунскейла!

Want to improve your IT-english skills and have fun?
Follow GeekEng in telegram
Learn