Итак, очередной простой нашего блога – вся наша немногочисленная коллаборация мучалась с отчетами по научно-исследовательским работам в одной из организаций города 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, запакованное в архив для экономии места.