Итак, очередной простой нашего блога — вся наша немногочисленная коллаборация мучалась с отчетами по научно-исследовательским работам в одной из организаций города N, но не стоит думать, что мы бездействовали и не писали код.
Когда-то, работая над очередной задачей, связанной с обработкой изображений, я думал, как же все-таки проще это сделать и при этом не использовать сторонние библиотеки, которые я не мог поставить на рабочий компьютер. Тогда ничего гениально простого в голову не пришло и решения найти не удалось.
Но, как это часто бывает, задача вновь возникла на горизонте…
В этот раз, я совершенно случайным образом, узнал про простой и достаточно удобный графический формат, который носит название Portable Anymap. С помощью этого можно описывать различные типы изображений: бинарные (т.е. состоящие всего лишь из двух цветов), изображения в оттенках серого и полутоновые картинки в формате RGB.
Очевидно, что каждый тип изображения образует свое узкое подмножество формата Portable Anymap, и соответственно имеет свое т.н. «магическое число».
Магическое число — это буквенно-числовое обозначение, однозначно идентифицирующее используемый формат файла. Также в зависимости от удобочитаемости, каждый из форматов может быть ASCII-форматом (в нем цвета записываются в понятной для человека форме, в форме обычных десятичных чисел) или бинарным (в нем цвета записываются в виде их бинарного представления).
Наиболее удобным вариантом для экспериментов, а также для задач скоростного прототипирования (т.е. быстрого построения прототипов) и испытания алгоритмов DIP, является подмножество формата Portable Anymap под названием PPM.
PPM является бинарным форматом, и потому не предназначен для непосредственного чтения человеком. Несмотря на это, сам формат является очень простым, а его структуру можно выразить следующим образом:
«Магическое число» | |
Число колонок | Число рядов |
Максимальное значение яркости отдельной компоненты | |
Информация о пикселях изображения |
Каждая строка этой таблицы представляет собой отдельную секцию файла Portable Anymap и размещается с новой строки.
Магическое число или сигнатура формата представляет собой два символа, которые записывают в обычной текстовой форме, и в случае PPM, этими символами являются символы P6. Именно поэтому бинарную версию PPM формата, файлы которого имеют расширение *.ppm, называют форматом P6.
Следующая секция, как и предыдущая, записывается в файл в обычном текстовом виде, и представляет собой два числа, разделенных пробелом. Первое число обозначает количество колонок, а второе число — количество рядов. Дело в том, что секция которая хранит информацию о пикселях хранит их в последовательном виде (т.е. пиксельные представления идут друг за другом) без всяких разделителей. Программа, которая считывает PPM-файл, на основе указанных количеств колонок и рядов, правильно размещает пиксели (считывает их последовательно и размещает как-будто бы в виртуальной таблице размерностью количество колонок * количество рядов). Если количество пиксельных представлений больше, чем произведение числа колонок на число рядов, то программа может либо проигнорировать остаток пиксельных представлений, либо выдать сообщение о том, что файл поврежден.
Максимальное значение яркости представляет собой число, записанное обычным текстом, которое означает максимально возможное значение яркости для отдельного канала (напоминаю, формат P6 также, как и некоторые другие форматы, является RGB-форматом) в пределах текущего файла. На основе этого значения интерпретируется информация из следующей секции. В случае бинарного формата PPM максимальное значение яркости составит 255, поскольку значения отдельных компонент RGB варьируются от 0 до 255.
Информация о пикселях храниться в бинарном виде и представляет собой последовательность чисел, описывающих цвет в RGB-виде. Три числа, описывающих отдельный RGB-цвет, я буду далее называть пиксельным представлением. В секции информации о пикселях пиксельные представления записываются путем перевода каждого числа в бинарную форму, после чего эта форма переводится в отдельный символ. Эти символы записываются последовательно, друг за другом, исключая наличие каких-либо разделителей между пиксельными представлениями.
Чтобы вы смогли получить более наглядное представление из такого сухого описания, я предлагаю вам взглянуть на простой пример P6-файла:
P6 3 2 255 !@#$%^&*()_+|{}:"<;
В увеличенном виде (в увеличенном, потому что пиксели мелкие, а вы вряд ли сможете рассмотреть полученный результат) выглядит примерно так:
Если вы хотите далее работать с этим форматом, то рекомендую установить универсальный просмотрщик изображений XnView, который позволит вам просматривать вам результаты экспериментов с PPM. Также, вы можете посмотреть пример в XnView для этого вам нужно: скопировать приведенный выше пример формата в текстовом виде в ваш любимый текстовый редактор и сохранить полученный файл с расширением *.ppm, а затем открыть PPM-файл в XnView.
А теперь попробуем наладить контакт между D и PPM.
Первым делом нам потребуется базовое описание одного пиксельного представления. Это описание удобно осуществить путем введения класса RGBColor:
class RGBColor { private { ubyte R; ubyte G; ubyte B; } this(ubyte R, ubyte G, ubyte B) { this.R = R; this.G = G; this.B = B; } ubyte red() { return R; } ubyte green() { return G; } ubyte blue() { return B; } float luminance() { return 0.3f * R + 0.59f * G + 0.11f * B; } override string toString() { return format("RGBColor(%d, %d, %d)", R, G, B); } }
Класс RGBColor представляет собой объектно-ориентированное представление обычного RGB-представления цвета и, в принципе, описание можно было осуществить и с помощью обычной структуры.
Набор операций, определенных внутри класса достаточно скудный, но тем не менее позволяет многое: создание нового пиксельного представления, считывание значения отдельных компонент цвета, вывод значения яркости цвета (с точки зрения человеческого глаза) и представление цвета в строковой форме. Класс намеренно был сделан минимальным для того, чтобы можно было легко и просто его расширять в дальнейшем.
Помимо самих пиксельных представлений нам необходим контейнер, в котором они будут храниться. Иными словами, нужно создать структуру данных, которая будет эффективно представлять само изображение.
Для этого я написал вот такой вот класс под названием P6Image:
class P6Image { private { RGBColor[] pixels; size_t width; size_t height; size_t calculateRealIndex(size_t i, size_t j) { return width * j + i; } } this(size_t width, size_t height, RGBColor color = new RGBColor(0, 0, 0)) { this.width = width; this.height = height; pixels = map!(a => color)(iota(width * height)).array; } size_t getWidth() { return width; } size_t getHeight() { return height; } size_t getArea() { return width * height; } RGBColor opIndex(size_t i) { return pixels[i]; } RGBColor opIndex(size_t i, size_t j) { return pixels[calculateRealIndex(i, j)]; } void opIndexAssign(RGBColor color, size_t i) { pixels[i] = color; } void opIndexAssign(RGBColor color, size_t i, size_t j) { pixels[calculateRealIndex(i, j)] = color; } }
Внутри этого класса находятся несколько приватных полей, которые хранят в себе пиксельные представления (далее просто — пиксели), длину изображения (т.е. количество колонок) и ширину изображения (т.е. количество рядов). Одномерный массив RGBColor[] был выбран для представления множества пикселей картинки неслучайно. Во-первых, из соображения эффективности, т.к одномерные массивы, как правило размещаются в памяти последовательно, что позволяет скоратить количество обращений к ним (конечно, такой подход имеет и минусы: если картинка большая, то в память попадет громадный одномерный массив, что не есть хорошо). Во-вторых, пиксели в PPM-файле размещаются друг за другом, что отражается наглядно как одномерный, а не двумерный массив, к которому мы привыкли.
Наличие одномерного массива представляет собой некоторые неудобства в свете того, что обычно изображение — это двумерный массив, да и гораздо привычнее обращаться к пикселу по двум индексам. Поэтому, чтобы было удобно, внутри класса организован метод calculateRealIndex, который транслирует двумерные координаты в одномерные, на основании простой и понятной формулы:
width * j + i;
Также, этот метод позволяет оформить аккуратно два метода opIndex и opIndexAssign, которые принимают два индекса. Первый метод служит для извлечения из класса некоторого пикселя по его известным индексам, а второй — изменяет значение некоторого пикселя, т.е. оба метода позволяют классу эмулировать некоторое поведение обычного двумерного массива (мы уже писали о подобных методах в одной из статей). Также, очевидно, что оба метода являются перегруженными вариантами, поскольку помимо версий методов с двумя индексами, описаны методы, котрые принимают в качестве аргумента лишь один индекс. Методы с одним аргументом оставлены не только для удобства, но и для организации некоторых интересных функций.
Конструктор класса P6Image принимает три аргумента: длину, ширину и цвет по умолчанию для всего изображения. Если первые два аргумента являются обязательными, то третий не является необходимым и служит для создания изображений, содержащих фон определенного цвета (по умолчанию, фон — черный). Заполнение фона в конструкторе осуществляется на функциональный манер: берется функция идентичности, которая для любого a возвращает одно и то же значение цвета (которое было передано в конструктор), и применяется к массиву чисел от 0 до общего количества пикселей (его легко подсчитать как произведения количества колонок на количество рядов или можно просто вызвать метод getArea), а затем переводиться из диапазона в обычный массив.
Наличие двух классов для представления формата PPM и его содержимого, это конечно замечательно, но не позволяет манипулировать файлами, а такая возможность жизненно необходима для реализации большинства алгоритмов…
Для решения проблемы с сохранением файлов можно воспользоваться функцией saveP6, которая выглядит следующим образом:
auto saveP6(P6Image p6image, string filename) { File file; auto width = p6image.getWidth; auto height = p6image.getHeight; with (file) { open(filename, "w"); writeln("P6"); writeln(width, " ", height); writeln(255); } auto S = p6image.getArea; foreach (size_t pixelPosition; 0 .. S) { auto pixel = p6image[pixelPosition / width, pixelPosition % height]; file.write( cast(char) pixel.red, cast(char) pixel.green, cast(char) pixel.blue ); } }
Эта функция принимает два аргумента: объект изображения в формате P6 и имя файла, куда этот объект будет сохранен. Сначала внутри saveP6 определяются вспомогательные переменные, в которые помещаются длина и ширина изображения, соттветственно. Функция открывает файл для записи и после этого внутри блока with происходит запись первых трех секций, которые должны быть записаны в обычной текстовой форме. Блок with в данном случае используется для создания своей собственной области видимости для объекта file, что позволяет описывать методы этого объекта без использования «точечной» записи вида «объект.метод». Далее, мы запоминаем в переменную S общее количество всех пикселей и используем это значение для вычленения из объекта p6image всех пикселей. Для каждого из пикселей производиться извлечение каждого из его компонент, превращение компонент сразу в бинарную (символьную) форму и моментальная запись результатов в файл.
После проделывания всех операций внутри saveP6, D автоматически закрывает файл, и у нас получается картинка пригодная к открытию в XnView!
Загрузить файл P6 для последующей обработки можно с помощью функции loadP6, которая выглядит следующим образом:
auto loadP6(string filename) { P6Image p6image = new P6Image(0, 0); File file; with (file) { open(filename, "r"); if (readln.strip == "P6") { auto imageSize = readln.split; auto width = parse!size_t(imageSize[0]); auto height = parse!size_t(imageSize[1]); readln(); auto buffer = new ubyte[width * 3]; p6image = new P6Image(width, height); for (size_t i = 0; i < height; i++) { file.rawRead!ubyte(buffer); for (size_t j = 0; j < width; j++) { p6image[j + i * width] = new RGBColor( buffer[j * 3], buffer[j * 3 + 1], buffer[j * 3 + 2] ); } } close(); } } return p6image; }
Сначала мы создаем вообще пустую картинку, на тот случай, если вдруг по какой-то причине файл не будет открыт или загружен. Кроме того, переменная типа P6Image нам также пригодиться в дальнейшем, для того, чтобы сохранить в нее удачно загруженное изображение. Как видно, функция принимает один аргумент, роль которого играет имя файла или путь к заведомо существующему файлу.
После того, как существующий файл удалось открыть, функция избавляется от ненужной в дальнейшем сигнатуры формата (попутно проверяя ее наличие, и как следствие, проверяя тот ли формат файла ей подали) и считывает количество колонок и рядов, которые необходимы для создания буфера нужного размера. Далее функция игнорирует значение максимальной яркости, поскольку мы твердо уверены, что в PPM-файле находятся только RGB-пиксели.
Функция автоматически выделяет буфер нужной длины, предполагая, что далее идут пиксельные представления, которые включают в себя три элемента, после чего в цикле происходит заполнение буфера бинарными значениями, которые моментально приводятся к ubyte и помещаются в конструктор класса RGBColor в качестве компонентов цвета. Я понимаю, что работает эта функция несколько более хитро чем предыдущая, однако пробегитесь глазами по коду и вы поймете, как оно работает.
Использование описанных классов и функций сохранения/загрузки настолько просто, что это кажется ребячеством. Не спешите так думать: модифицированный класс P6Image используется в одной из библиотек цифровой обработки изображений (так уж получилось, что я один из ее авторов. кроме того, она скоро появится в реестре dub). Кроме того в силу своей простоты, формат PPM очень часто используют для экспериментов, а в пакете Netpbm этот формат применяется как промежуточный при конверсии одного типа файлов в другой (некоторые форматы настолько экзотичны, что конвертеры для них существуют только в этом пакете!).
[accordion][panel intro=»Весь код как обычно под спойлером.»]
import std.algorithm; import std.conv : parse; import std.range : array, iota; import std.stdio; import std.string; class RGBColor { private { ubyte R; ubyte G; ubyte B; } this(ubyte R, ubyte G, ubyte B) { this.R = R; this.G = G; this.B = B; } ubyte red() { return R; } ubyte green() { return G; } ubyte blue() { return B; } float luminance() { return 0.3f * R + 0.59f * G + 0.11f * B; } override string toString() { return format("RGBColor(%d, %d, %d)", R, G, B); } } class P6Image { private { RGBColor[] pixels; size_t width; size_t height; size_t calculateRealIndex(size_t i, size_t j) { return width * j + i; } } this(size_t width, size_t height, RGBColor color = new RGBColor(0, 0, 0)) { this.width = width; this.height = height; pixels = map!(a => color)(iota(width * height)).array; } size_t getWidth() { return width; } size_t getHeight() { return height; } size_t getArea() { return width * height; } RGBColor opIndex(size_t i) { return pixels[i]; } RGBColor opIndex(size_t i, size_t j) { return pixels[calculateRealIndex(i, j)]; } void opIndexAssign(RGBColor color, size_t i) { pixels[i] = color; } void opIndexAssign(RGBColor color, size_t i, size_t j) { pixels[calculateRealIndex(i, j)] = color; } } auto saveP6(P6Image p6image, string filename) { File file; auto width = p6image.getWidth; auto height = p6image.getHeight; with (file) { open(filename, "w"); writeln("P6"); writeln(width, " ", height); writeln(255); } auto S = p6image.getArea; foreach (size_t pixelPosition; 0 .. S) { auto pixel = p6image[pixelPosition / width, pixelPosition % height]; file.write( cast(char) pixel.red, cast(char) pixel.green, cast(char) pixel.blue ); } } auto loadP6(string filename) { P6Image p6image = new P6Image(0, 0); File file; with (file) { open(filename, "r"); if (readln.strip == "P6") { auto imageSize = readln.split; auto width = parse!size_t(imageSize[0]); auto height = parse!size_t(imageSize[1]); readln(); auto buffer = new ubyte[width * 3]; p6image = new P6Image(width, height); for (size_t i = 0; i < height; i++) { file.rawRead!ubyte(buffer); for (size_t j = 0; j < width; j++) { p6image[j + i * width] = new RGBColor( buffer[j * 3], buffer[j * 3 + 1], buffer[j * 3 + 2] ); } } close(); } } return p6image; }
[/panel][/accordion]
Напоследок, вот для примера стандартное изображение «Лена» в формате P6, запакованное в архив для экономии места.