PPM: простой формат файла изображения

Итак, очередной простой нашего блога — вся наша немногочисленная коллаборация мучалась с отчетами по научно-исследовательским работам в одной из организаций города 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 этот формат применяется как промежуточный при конверсии одного типа файлов в другой (некоторые форматы настолько экзотичны, что конвертеры для них существуют только в этом пакете!).

Весь код как обычно под спойлером.

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;
}

Напоследок, вот для примера стандартное изображение «Лена» в формате P6, запакованное в архив для экономии места.

Добавить комментарий