Pull to refresh

Работа с растром на низком уровне для начинающих

Reading time 6 min
Views 76K
Поводом для данной статьи стал следующий пост: «Конвертация bmp изображения в матрицу и обратно для дальнейшей обработки». В свое время, мне немало пришлось написать исследовательского кода на C#, который реализовывал различные алгоритмы сжатия, обработки. То, что код исследовательский, я упомянул не случайно. У этого кода своеобразные требования. С одной стороны, оптимизация не очень важна – ведь важно проверить идею. Хотя и хочется, чтобы эта проверка не растягивалась на часы и дни (когда идет запуск с различными параметрами, либо обрабатывается большой корпус тестовых изображений). Примененный в вышеупомянутом посте способ обращения к яркостям пикселов bmp.GetPixel(x, y) – это то, с чего начинался мой первый проект. Это самый медленный, хотя и простой способ. Стоит ли тут заморачиваться? Давайте, замерим.

Использовать будем классический Bitmap (System.Drawing.Bitmap). Данный класс удобен тем, что скрывает от нас детали кодирования растровых форматов – как правило, они нас и не интересуют. При этом поддерживаются все распространенные форматы, типа BMP, GIF, JPEG, PNG.

Кстати, предложу для начинающих первую пользу. У класса Bitmap есть конструктор, который позволяет открыть файл с картинкой. Но у него есть неприятная особенность – он оставляет открытым доступ к этому файлу, поэтому повторные обращения к нему приводят к эксепшену. Чтобы исправить это поведение, можно использовать такой метод, заставляющий битмап сразу «отпустить» файл:

public static Bitmap LoadBitmap(string fileName)
{
    using (FileStream fs = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read))
        return new Bitmap(fs);
}


Методика замеров


Замерять будем, перегоняя в массив и обратно в Bitmap классику обработки изображений – Лену (http://en.wikipedia.org/wiki/Lenna). Это свободное изображение, его можно встретить в большом количестве работ по обработке изображений (и в заголовке данного поста тоже). Размер – 512*512 пикселов.

Немного о методике – в таких случаях я предпочитаю не гоняться за сверхточными таймерами, а просто много раз выполнять одно и то же действие. Конечно, с одной стороны, в этом случае данные и код уже будут в кэше процессора. Но, зато мы вычленяем затраты на первый запуск кода, связанный с переводом MSIL-кода в код процессора и другие накладные расходы. Чтобы гарантировать это, предварительно запускаем каждый кусок кода один раз – выполняем так называемый «прогрев».

Компилируем код в Release. Запускаем его обязательно не из-под студии. Более того, студию также желательно закрыть – сталкивался со случаями, когда сам факт её «запущенности» иногда сказывается на полученных результатах. Также, желательно закрыть и другие приложения.

Запускаем код несколько раз, добиваясь типичных результатов – необходимо убедиться, что на нем не сказывается какой-то неожиданный процесс. Скажем, проснулся антивирус или еще что-то. Все эти меры позволяют нам получить стабильные, повторяемые результаты.

«Наивный» метод


Именно этот метод был применен в оригинальной статье. Он состоит в том, что используется метод Bitmap.GetPixel(x, y). Приведем полностью код подобного метода, который конвертирует содержимое битмапа в трехмерный байтовый массив. При этом первая размерность – это цветовая компонента (от 0 до 2), вторая – позиция y, третья – позиция x. Так сложилось в моих проектах, если вам захочется расположить данные иначе – думаю, проблем не возникнет.

public static byte[, ,] BitmapToByteRgbNaive(Bitmap bmp)
{
    int width = bmp.Width,
        height = bmp.Height;
    byte[, ,] res = new byte[3, height, width];
    for (int y = 0; y < height; ++y)
    {
        for (int x = 0; x < width; ++x)
        {
            Color color = bmp.GetPixel(x, y);
            res[0, y, x] = color.R;
            res[1, y, x] = color.G;
            res[2, y, x] = color.B;
        }
    }
    return res;
}


Обратное преобразование аналогично, только перенос данных идет в другом направлении. Я не буду приводить его код здесь – желающие могут посмотреть в исходных кодах проекта по ссылке в конце статьи.

100 преобразований в изображение и обратно на моем ноутбуке с процессором I5-2520M 2.5GHz, требуют 43.90 сек. Получается, что при изображении 512*512, только на перенос данных, тратится порядка полусекунды!

Прямая работа с данными Bitmap


К счастью, класс Bitmap предоставляет более быстрый способ обратиться к своим данным. Для этого нам необходимо воспользоваться ссылками, предоставляемыми классом BitmapData и адресной арифметикой:

public unsafe static byte[, ,] BitmapToByteRgb(Bitmap bmp)
{
    int width = bmp.Width,
        height = bmp.Height;
    byte[, ,] res = new byte[3, height, width];
    BitmapData bd = bmp.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.ReadOnly,
        PixelFormat.Format24bppRgb);
    try
    {
        byte* curpos;
        for (int h = 0; h < height; h++)
        {
            curpos = ((byte*)bd.Scan0) + h * bd.Stride;
            for (int w = 0; w < width; w++)
            {
                res[2, h, w] = *(curpos++);
                res[1, h, w] = *(curpos++);
                res[0, h, w] = *(curpos++);
            }
        }
    }
    finally
    {
        bmp.UnlockBits(bd);
    }
    return res;
}


Такой подход дает нам получить 0.533 секунды на 100 преобразований (ускорились в 82 раза)! Думаю, это уже отвечает на вопрос – а стоит ли писать более сложный код преобразования? Но можем ли мы еще ускорить процесс, оставаясь в рамках managed-кода?

Массивы vs указатели


Многомерные массивы являются не самыми быстрыми структурами данных. Здесь производятся проверки на выход за пределы индекса, сам элемент вычисляется, используя операции умножения и сложения. Поскольку адресная арифметика уже дала нам один раз существенное ускорение при работе с данными Bitmap, то может быть, попробуем её применить и для многомерных массивов? Вот код прямого преобразования:

public unsafe static byte[, ,] BitmapToByteRgbQ(Bitmap bmp)
{
    int width = bmp.Width,
        height = bmp.Height;
    byte[, ,] res = new byte[3, height, width];
    BitmapData bd = bmp.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.ReadOnly,
        PixelFormat.Format24bppRgb);
    try
    {
        byte* curpos;
        fixed (byte* _res = res)
        {
            byte* _r = _res, _g = _res + width*height, _b = _res + 2*width*height;
            for (int h = 0; h < height; h++)
            {
                curpos = ((byte*)bd.Scan0) + h * bd.Stride;
                for (int w = 0; w < width; w++)
                {
                    *_b = *(curpos++); ++_b;
                    *_g = *(curpos++); ++_g;
                    *_r = *(curpos++); ++_r;
                }
            }
        }
    }
    finally
    {
        bmp.UnlockBits(bd);
    }
    return res;
}


Результат? 0.162 сек на 100 преобразований. Так что ускорились еще в 3.3 раза (270 раз по сравнению с «наивной» версией). Именно подобный код и использовался мною при исследованиях алгоритмов.

Зачем вообще переносить?


Не совсем очевидно, а зачем вообще переносить данные из Bitmap. Может вообще, все преобразования осуществлять именно там? Соглашусь, что это один из возможных вариантов. Но, дело в том, что многие алгоритмы удобнее проверять на данных с плавающей запятой. Тогда нет проблем с переполнениями, потерей точности на промежуточных этапах. Преобразовать в double/float-массив можно аналогичным способом. Обратное преобразование требует проверки при конвертации в byte. Вот простой код такой проверки:

private static byte Limit(double x)
{
    if (x < 0)
        return 0;
    if (x > 255)
        return 255;
    return (byte)x;
}


Добавление таких проверок и преобразование типов замедляет наш код. Версия с адресной арифметикой на double-массивах исполняется уже 0.713 сек (на 100 преобразований). Но на фоне «наивного» варианта – она просто молния.

А если нужно быстрее?


Если нужно быстрее, то пишем перенос, обработку на C, Asm, используем SIMD-команды. Загружаем растровый формат напрямую, без обертки Bitmap. Конечно, в этом случае мы выходим за пределы Managed-кода, со всеми вытекающими плюсами и минусами. И делать это имеет смысл для уже отлаженного алгоритма.

Полный код к статье можно найти здесь: rasterconversion.codeplex.com/SourceControl/latest

Update 2013-10-08:
По предложению комментаторов, добавил в код вариант переноса данных в массив с помощью Marshal.Copy(). Это сделано чисто с тестовыми целями — у такого способа работы есть свои ограничения:
  • Порядок данных точно такой же, как и в оригинальном Bitmap. Т.е., компоненты перемешаны. Если мы хотим их отделить друг от друга — нужно будет все-равно проходиться циклом по массиву, копируя данные.
  • Тип у яркости остается byte, в то же время, часто бывает удобно промежуточные вычисления выполнять с плавающей запятой.
  • Marshal.Copy() работает с одномерными массивами. Да, они конечно самые быстрые и не очень сложно везде писать rgb[x+width*y], но все-таки...

Итак, копирование в две стороны происходит за 0.158 сек (на 100 преобразований). По сравнению с более гибким вариантом на указателях, ускорение очень небольшое, в пределах статистической погрешности результатов разных запусков.

Update 2016-04-25:
Пользователь Ant00 указал на ошибку в коде метода BitmapToByteRgbQ. Она не сказывалась на времени, но перекладывание осуществлялось неправильно. Была ошибка при копипасте фрагментов из работающего кода. Поправил. Спасибо за настойчивость (не с первого раза я внимательно рассмотрел код в статье, которой уже 2.5 года).
Tags:
Hubs:
+23
Comments 22
Comments Comments 22

Articles