Совсем простой просмотрщик PPM-файлов

Как наверное заметили многие темы в нашем блоге очень часто повторяются, встречаясь в наших заметках в самых различных вариантах, зачастую усовершенствованных. Иногда, мы не делаем никаких улучшений, а лишь слегка облагораживаем внешний вид, описывая таким образом результаты наших собственных экспериментов с различными GUI или инструментами. Также, очень и очень редко, в нашем блоге, авторы возвращаются к уже написанному, пытаясь реализовать то, что когда-то не получалось в силу различных причин: нашей неопытности, недостачи времени и некоторых других причин…

Сегодня, я вновь хочу обратиться к одной из уже затронутых в блоге тем, но при этом я собираюсь открыть вам некоторые новые горизонты…

Про простой и не требующий особых изысков формат хранения картинок Portable Anymap я как-то уже рассказывал, предполагая, что обязательно покажу насколько это замечательный и удобный формат (особенно, если вы самолично хотите поучаствовать в экспериментах по обработке изображений). Полагаю, что все преимущества и недостатки этого формата вам понятны, но один из недостатков не может не раздражать (особенно пользователей Windows)…

Я говорю про то, что для того, чтобы насладиться результатами работы с PPM, нужен сторонний «просмотрщик» (например, XnView). Но я, к примеру, не хотел бы ставить стороннюю программу, особенно, если мои эксперименты будут краткосрочными или не очень успешными, да и вообще не слишком удобно ставить дополнительный вьювер !

Вот так и родилась идея собрать свой собственный, минимально функциональный (т.е. кроме функции просмотра ничего нет) просмотрщик PPM-файлов и сделать его с применением Qt5!

На самом деле, написать просмотрщик PPM своими руками — это пустяковое дело (несмотря на то, что я промучился с ним неделю, а сделал все буквально за 25 минут!) и тут я буду руководствоваться совершенно дурацкой идеей: я считаю PPM-файл в память, используя уже готовую функцию loadP6 из статьи про PPM, а затем используя любой подвернувшийся виджет Qt5, нарисую на нем по точкам графический образ из файла (проще простого, так как файл содержит в себе описания всех точек изображения в виде триплетов RGB, считывание/запись которых не представляют никакого труда).

Для этого, я опишу простейшее окно, которое содержит уже знакомый нам пустой виджет QWidget, текстовое поле QLineEdit для вывода имени выбранного файла и две QPushButton — одна для вызова диалога выбора файла, а вторая для того, чтобы отдать команду на прорисовку интересующего нас изображения.

Однако, помимо этого, нам потребуется один невизуальный элемент — QFileDialog (для того, чтобы любой пользователь мог легко найти нужный для отображения PPM файл на своем компьютере) и две переменные: строковая ppmFile, которая будет хранить промежуточные результаты (а именно — считанное имя файла) и булевая (логическая) переменная startDrawing, которая будет флагом запуска отрисовки файла:

class MainForm : QWidget
{
	private
	{

		QHBoxLayout horizontalBox;
		QVBoxLayout verticalBox;
		QLineEdit fn;	
		QPushButton drawButton, clearButton;
		QAction action1, action2;
		QWidget drawArea;
		QFileDialog dialog;
		string ppmFile;
		
		bool startDrawing;
	}
	
	this(QWidget parent, WindowType windowType) 
	{
		super(parent, windowType); 
		resize(1030, 530); 
		setWindowTitle("Simple PPM Viewer");
		setStyleSheet("background : white");
		
		horizontalBox = new QHBoxLayout;
		verticalBox = new QVBoxLayout;
		
		with (drawArea = new QWidget(null))
		{
			setToolTip("Область изображения");
			setStyleSheet("background : white");
		}

		fn = new QLineEdit(this);
		fn.setReadOnly(true);

		drawButton = new QPushButton("Select file", this);
		clearButton = new QPushButton("Show PPM", this);
		
		action1 = new QAction(null, &onDrawButton, aThis);
		action2 = new QAction(null, &onClearButton, aThis);
		
		connects(drawButton, "clicked()", action1, "Slot()");
		connects(clearButton, "clicked()", action2, "Slot()");
		
		horizontalBox
			.addWidget(fn)
				.addWidget(drawButton)
				.addWidget(clearButton);

		verticalBox
			.addWidget(drawArea)
				.addLayout(horizontalBox);
		
		setLayout(verticalBox);
		
		drawArea.setPaintEvent(&onPaint1, aThis);
	}
}

Как видно, в приведенном коде нет ничего особенного, используется все та же схема построения программы: сначала происходит наследование от базового класса QWidget (хотя можно использовать и QMainForm), после чего внутри дочернего класса происходит описание нужных свойств окна программы (размер, оформление, надписи и т.д.) и в private-блоке описываются члены класса, которые будут представлять собой отдельные визуальные и/или невизуальные компоненты, используемые основным окном (формой) программы. Все визуальные компоненты, как обычно, размещаются на форме с помощью сайзеров (sizers), в которые помещаются необходимые элементы графического интерфейса, и в нашем случае, сайзеров два: горизонтальный (внутрь него помещаются текстовое поле и две кнопки) и вертикальный (внутрь него помещаются пустой виджет и горизонтальный сайзер). Но перед размещением компонентов, задается ряд свойств, большая часть из которых была раскрыта в предыдущих статьях про QtE

Стоит заметить, что QtE5 постоянно обновляется, и порой даже я не успеваю за ней угнаться, как появляется что-то новое или исправляется старое (не волнуйтесь, все API основано на Qt5 и оно остается стабильным, что позволяет использовать документацию по Qt5 при разработке программ с применением QtE5). Так вот, с последним обновлением библиотеки, для QLineEdit был добавлен метод setReadOnly, который позволяет защитить текстовое поле от посягательств пользователя, исключая любую возможность ввода текста в поле со стороны пользователя (но копировать текст оттуда вполне себе можно). Это свойство нам потребуется, чтобы защитить текстовое поле и сделать его элементом, который будет использоваться как элемент отображающий информацию.

Далее, я использую следующую простую стратегию: применяю повторное использование кода и те же самые приемы, что и раньше. Я использую исходники проекта по построению последовательности Коллатца (применяется  тот же подход с глушением графики, а также абсолютно те же самые обработчики событий и соответствующие им объекты QAction) и копирую «один-в-один» extern(C) обработчики (хм, забыл даже изменить их имена):

extern (C)
{
	void onPaint1(MainForm* mainFormPointer, void* eventPointer, void* painterPointer) 
	{ 
		(*mainFormPointer).runPaint(eventPointer, painterPointer);
	}
	
	void onDrawButton(MainForm* mainFormPointer) {
		(*mainFormPointer).runDrawButton;
	}
	
	void onClearButton(MainForm* mainFormPointer) {
		(*mainFormPointer).runClearButton;
	}
}

Как следствие, приходиться взять те же самые имена методов, которые реагируют на нажатия кнопок (конечно, это дурной подход, и я очень рекомендую никогда так не делать — всегда в функциях старайтесь отразить их назначение или функционал, никогда не копируйте код бездумно!), сохраняя при этом их сигнатуры. Несмотря на то, что методы двух проектов почти идентичны, с точки зрения механизма работы и входных аргументов, тем не менее внутреннее содержимое методов нашего простого просмотрщика сильно отличается:

 void runDrawButton() 
	{
		dialog = new QFileDialog(this);
		dialog.setDefaultSuffix("ppm");
		dialog.setNameFilter("*.ppm");

		string selectedFilename = dialog.getOpenFileNameSt("Open file ...", "", "*.ppm");

		if (exists(selectedFilename))
		{
			ppmFile = selectedFilename;
			fn.setText(ppmFile);
		}
	}
	
	void runClearButton()
	{
		startDrawing = true;
		drawArea.update();
	}

Что в них происходит, собственно говоря?

Да ничего особенного: в начале создается объект файлового диалога QFileDialog, после чего для него выставляются свойство setDefaultSuffix, которое выполнит подстановку расширения *.PPM, если файл будет указан без расширения; и свойство setNameFilter, которое позволит выбирать только конкретные файлы, а именно — PPM-файлы. После установки параметров диалога, мы вызываем настроенный диалог с помощью особой статической функции getOpenFileNameSt (принимает несколько аргументов, большая часть из которых не обязательна: заголовок окна, исходная папка, фильтр по расширению и т.д.), которая помимо всего прочего (а именно, открытия диалога выбора файла) любезно возвратит имя файла, которое выбрал пользователь. Далее, если файл указанный пользователем существует (это же нас подстрахует от пустой строки, т.е. от случая когда пользователь отказался от выбора файла нажатием кнопок «Отмена» или «Закрыть»), то имя файла переносится в служебную переменную ppmFile и ее значение с помощью свойства setText текстового поля переносится в объект fn, что тут же замечается пользователем: в текстовом поле отображается полный путь до файла.

В методе runClearButton, вопреки названию (хотя и не совсем вопреки), происходит запуск процесса отрисовки изображения из файла, а также обновление (оно же, в общем-то, и очистка) пустого QWidget, задействованного для отрисовки — тут все в точности, как и в примере рисования последовательности Коллатца, путем установки логической переменной — мы запускаем встроенную процедуру отрисовки, которая выглядит примерно так:

void runPaint(void* eventPointer, void* painterPointer) 
	{
		
		QPainter painter = new QPainter('+', painterPointer); 
		
		QColor color = new QColor;
		color.setRgb(200, 200, 200, 250);

		if (startDrawing)
		{
			startDrawing = false;
			painter.drawPPMFile(ppmFile);
		}
		
		painter.end;
	}

Но представляет здесь интерес, не та часть этой процедуры, которая постоянно выводит белый цвет, а процедура drawPPMFile, обеспечивающая работу всей «магии» просмотрщика. Эта функция принимает два аргумента — объект типа QPainter, который, по сути, задает контекст для рисования, и строку с именем (а еще лучше — полным путем) файла формата Portable Anymap.

А выглядит эта функция вот так:

void drawPPMFile(QPainter painter, string filename)
{
	P6Image p6image = loadP6(filename);

	QColor color = new QColor;

	QPen pen = new QPen;

	pen.setWidth(4);

	
	for (int i = 0; i < p6image.getWidth; i++)
	{
		for (int j = 0; j < p6image.getHeight; j++)
		{
			auto pixel = p6image[i,j];
			auto r = pixel.red;
			auto g = pixel.green;
			auto b = pixel.blue;
			
			color.setRgb(r, g, b);
			
			pen.setColor(color);

			painter.setPen(pen);			
			painter.drawPoint(i, j);
		}
	}
}

Как видно, тут все очень просто: загружаем файл в объект P6Image  с помощью процедуры loadP6, затем создаем объект QColor (представляет собой некоторый цвет) и передаем QColor в объект типа QPen (представляет собой перо, которое рисует на графическом контексте типа QPainter). Необходимо установить толщину пера в пикселях, и я, от нечего делать, поставил в метод setWidth значение 4, после чего можно доставать из объекта P6Image попиксельно значения, представляющие собой значения компонент red, green и blue (иными словами, RGB) для каждой точки PPM-картинки.

Все остальное на редкость элементарно: по одному пикселю переносим изображение из P6Image на QPainter, извлекаемый из нашего пустого QWidget.

К несчастью, в D нет встроенных процедур для манипуляции с PPM-файлами, поэтому я рекомендую прочесть нашу статью про этот формат, а также обратить внимание на скромный, но мощный проект LightHouse Software — библиотеку rip.

На всякий случай, я привожу упрощенный код, который загружает PPM-файл и предоставляет необходимые структуры данных:

module image;

import std.algorithm;
import std.conv;
import std.range;
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 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;
}

А теперь добавляем (уже в который раз) переработанную процедуру main:

alias WindowType = QtE.WindowType;
alias normalWindow = WindowType.Window;

auto QtDebugInfo(bool flag)
{
	if (LoadQt(dll.QtE5Widgets, flag)) 
	{
		return 1;
	}

	return 0;
}

int main(string[] args) 
{
	alias normalWindow = WindowType.Window;
	
	QtDebugInfo(true);
	
	QApplication app = new QApplication(&Runtime.cArgs.argc, Runtime.cArgs.argv, 1);
	
	MainForm mainForm = new MainForm(null, normalWindow);
	
	mainForm
		.show
			.saveThis(&mainForm);
	
	return app.exec;
}

И запускаем, наслаждаясь результатом:

Полный код основного модуля:

module app;

import core.runtime;
import std.file;

import qte5;

import image;

alias WindowType = QtE.WindowType;
alias normalWindow = WindowType.Window;

auto QtDebugInfo(bool flag)
{
	if (LoadQt(dll.QtE5Widgets, flag)) 
	{
		return 1;
	}

	return 0;
}

extern (C)
{
	void onPaint1(MainForm* mainFormPointer, void* eventPointer, void* painterPointer) 
	{ 
		(*mainFormPointer).runPaint(eventPointer, painterPointer);
	}
	
	void onDrawButton(MainForm* mainFormPointer) {
		(*mainFormPointer).runDrawButton;
	}
	
	void onClearButton(MainForm* mainFormPointer) {
		(*mainFormPointer).runClearButton;
	}
}


void drawPPMFile(QPainter painter, string filename)
{
	P6Image p6image = loadP6(filename);

	QColor color = new QColor;

	QPen pen = new QPen;

	pen.setWidth(4);

	
	for (int i = 0; i < p6image.getWidth; i++)
	{
		for (int j = 0; j < p6image.getHeight; j++)
		{
			auto pixel = p6image[i,j];
			auto r = pixel.red;
			auto g = pixel.green;
			auto b = pixel.blue;
			
			color.setRgb(r, g, b);
			
			pen.setColor(color);

			painter.setPen(pen);			
			painter.drawPoint(i, j);
		}
	}
}


class MainForm : QWidget
{
	private
	{

		QHBoxLayout horizontalBox;
		QVBoxLayout verticalBox;
		QLineEdit fn;	
		QPushButton drawButton, clearButton;
		QAction action1, action2;
		QWidget drawArea;
		QFileDialog dialog;
		string ppmFile;
		
		bool startDrawing;
	}
	
	this(QWidget parent, WindowType windowType) 
	{
		super(parent, windowType); 
		resize(1030, 530); 
		setWindowTitle("Simple PPM Viewer");
		setStyleSheet("background : white");
		
		horizontalBox = new QHBoxLayout;
		verticalBox = new QVBoxLayout;
		
		with (drawArea = new QWidget(null))
		{
			setToolTip("Область изображения");
			setStyleSheet("background : white");
		}

		fn = new QLineEdit(this);
		fn.setReadOnly(true);

		drawButton = new QPushButton("Select file", this);
		clearButton = new QPushButton("Show PPM", this);
		
		action1 = new QAction(null, &onDrawButton, aThis);
		action2 = new QAction(null, &onClearButton, aThis);
		
		connects(drawButton, "clicked()", action1, "Slot()");
		connects(clearButton, "clicked()", action2, "Slot()");
		
		horizontalBox
			.addWidget(fn)
				.addWidget(drawButton)
				.addWidget(clearButton);

		verticalBox
			.addWidget(drawArea)
				.addLayout(horizontalBox);
		
		setLayout(verticalBox);
		
		drawArea.setPaintEvent(&onPaint1, aThis);
	}
	
	void runPaint(void* eventPointer, void* painterPointer) 
	{
		
		QPainter painter = new QPainter('+', painterPointer); 
		
		QColor color = new QColor;
		color.setRgb(200, 200, 200, 250);

		if (startDrawing)
		{
			startDrawing = false;
			painter.drawPPMFile(ppmFile);
		}
		
		painter.end;
	}
	
	void runDrawButton() 
	{
		dialog = new QFileDialog(this);
		dialog.setDefaultSuffix("ppm");
		dialog.setNameFilter("*.ppm");

		string selectedFilename = dialog.getOpenFileNameSt("Open file ...", "", "*.ppm");

		if (exists(selectedFilename))
		{
			ppmFile = selectedFilename;
			fn.setText(ppmFile);
		}
	}
	
	void runClearButton()
	{
		startDrawing = true;
		drawArea.update();
	}
}


int main(string[] args) 
{
	alias normalWindow = WindowType.Window;
	
	QtDebugInfo(true);
	
	QApplication app = new QApplication(&Runtime.cArgs.argc, Runtime.cArgs.argv, 1);
	
	MainForm mainForm = new MainForm(null, normalWindow);
	
	mainForm
		.show
			.saveThis(&mainForm);
	
	return app.exec;
}

Конечно, приведенная программа очень проста и наивна, к тому же явно не безошибочна, что подтверждается наличием некоторого количества упущений и возможно ошибок, но я надеюсь, что она поможет всем желающим научиться работать с QtE5.

P.S: Огромная благодарность от меня Мохову Геннадию Владимировичу за помощь с освоением QtE5 и некоторые практические идеи; Bagomot’у — за интересную картинку, использованную в тестировании, ну и общую впечатленность программкой; Роману Власову — за совместную работу над проектом rip и поиск ошибок в моем коде.

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