Здравствуйте, уважаемые Хабравчане и гости!
Это моя первая статья на Хабре. Она не претендует на какой-либо уровень, а предназначена в первую очередь для тех, кто так же, как и я до написания этой статьи, находится в поиске решения проблемы рисования в PySide6.
Дело в том, что для своего пет-проекта мне нужна была рисовалка на минималке, но при этом, должна иметь базовый функционал, от нее не требуется быть полноценным графическим редактором. Что нужно было:
-
Самое главное – рисование на холсте
-
Изменение размера кисти
-
Изменение цвета кисти
-
Изменения размера холста
-
Функция Undo/Redo
-
Очистку холста
-
Сохранение изображения
Ну что, начнем.
Структура проекта:
PaintNote (корень сурцов)
— res
— icons
— icons.qrc (файл ресурсов)
— rc_icons.py (файл ресурсов, сконвертированный, чтобы можно было обращаться к файлам в коде)
— app.py (точка входа)
— PaintingArea.py (холст)
— PaintingWindow.py (окно приложения)
— PaintingWindow.ui
— Ui_PaintingWindow.py
Обычно я создаю директории для ui файлов, и отдельно для сгенерировынных из них ui_***.py (например, ui_gen)
Сам файл точки входа:
app.py
import sys from PySide6.QtWidgets import QApplication from PaintNote.PaintWindow import PaintWindow def main(): app = QApplication(sys.argv) app.setApplicationName('MyPaint') window = PaintWindow() window.show() app.exec() if __name__ == '__main__': main()
В этом файле находится точка входа в приложение. Объявляется объект QApplication, выполняется его настройка. Затем объявляется объект самого окна нашего рисовальщика и вызывается. Затем приложение запускается благодаря методу exec().
Холст, который будет вставлен в качестве виджета в окне рисовальщика (лично я делал через QtDesigner, заменял стандартный QWidget на PaintingArea):
PaintingArea.py
from PySide6.QtWidgets import QWidget from PySide6.QtGui import QPainter, QPen, QBrush, QImage from PySide6.QtCore import Qt, QSize, QPoint, QRect class PaintingArea(QWidget): def __init__(self, parent): super().__init__() self._parent = parent self.setMinimumSize(self._parent.size().width(), self._parent.size().height()) self.buffer_image = QImage(0, 0, QImage.Format.Format_RGB32) # Setting up the main canvas self.image = QImage(self.width(), self.height(), QImage.Format.Format_RGB32) self.image.fill(Qt.GlobalColor.white) # Image stack size for Undo/Redo self.image_stack_limit = 50 self.image_stack = list() self.image_stack.append(self.image.copy()) self.current_stack_position = 0 # Setting Default Tools self.painting = False self.pen_size = 3 self.pen_color = Qt.GlobalColor.black self.pen_style = Qt.PenStyle.SolidLine self.pen_cap = Qt.PenCapStyle.RoundCap self.pen_join = Qt.PenJoinStyle.RoundJoin self.last_point = QPoint() def resizeEvent(self, event): # Save current image to buffer self.buffer_image = self.image # Adjust the canvas to the new window size and clear the canvas to avoid distortion self.image = self.image.scaled(self._parent.size().width(), self._parent.size().height()) self.image.fill(Qt.GlobalColor.white) # Transfer the image from the buffer to the canvas, to the starting coordinate painter = QPainter(self.image) painter.drawImage(QPoint(0, 0), self.buffer_image) def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: painter = QPainter(self.image) painter.setPen(QPen(self.pen_color, self.pen_size, self.pen_style, self.pen_cap, self.pen_join)) painter.drawPoint(event.pos()) self.painting = True self.last_point = event.pos() self.update() def mouseMoveEvent(self, event): if (event.buttons() == Qt.MouseButton.LeftButton) and self.painting: painter = QPainter(self.image) painter.setPen(QPen(self.pen_color, self.pen_size, self.pen_style, self.pen_cap, self.pen_join)) painter.drawLine(self.last_point, event.pos()) self.last_point = event.pos() self.update() def mouseReleaseEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.painting = False # Replacing an incorrectly sized zero (clean) image if len(self.image_stack) >= 1: temp_zero_img = self.image.copy() temp_zero_img.fill(Qt.GlobalColor.white) self.image_stack[0] = temp_zero_img.copy() if (len(self.image_stack) < self.image_stack_limit and not (self.current_stack_position < len(self.image_stack) - 1)): self.image_stack.append(self.image.copy()) self.current_stack_position = len(self.image_stack) - 1 self.update() elif self.current_stack_position < len(self.image_stack) - 1: for i in range(len(self.image_stack) - 1, self.current_stack_position, -1): self.image_stack.pop(i) self.image_stack.append(self.image.copy()) self.current_stack_position = len(self.image_stack) - 1 else: # Shift elements in a list self.image_stack.pop(0) # Replacing the last element (which was previously the first) with a new element self.image_stack.append(self.image.copy()) self.current_stack_position = len(self.image_stack) - 1 self.update() def paintEvent(self, event): canvas_painter = QPainter(self) canvas_painter.drawImage(QPoint(0, 0), self.image) def undo(self): # If the current position is not at the very minimum if self.current_stack_position > 0: self.current_stack_position -= 1 self.image = self.image_stack[self.current_stack_position].copy() self.update() def redo(self): # If the current position is not at the very maximum of the stack if self.current_stack_position < len(self.image_stack) - 1: self.current_stack_position += 1 self.image = self.image_stack[self.current_stack_position].copy() self.update() def keyPressEvent(self, event): print(event.key()) def clear(self): # Reset current stack position self.current_stack_position = 0 # Clear canvas self.image.fill(Qt.GlobalColor.white) # Copy clear canvas canvas = self.image.copy() # Clear Undo-Redo stack self.image_stack.clear() # Add zero image self.image_stack.append(canvas.copy()) self.update()
Само окно нашего рисовальщика:
PaintWindow.py
from PySide6.QtWidgets import QMainWindow, QWidget, QColorDialog, QSizePolicy, QLabel, QSpinBox, QPushButton from PySide6.QtGui import QIcon, QUndoStack from PySide6.QtCore import Qt, QSize from PaintNote.PaintNote.PaintNote.Ui_PaintWindow import Ui_PaintWindow from PaintNote.PaintNote.PaintNote.PaintingArea import PaintingArea from PaintNote.PaintNote.PaintNote.res import rc_icons class PaintWindow(QMainWindow): def __init__(self): super().__init__() self.ui = Ui_PaintWindow() self.ui.setupUi(self) self.setWindowTitle('Paint note') self.setWindowIcon(QIcon(':/icons/colors.png')) # Save self.save_button = QPushButton(QIcon(':/icons/save.png'), '', self.ui.toolbar) self.ui.toolbar.addWidget(self.save_button) # Spacer 1 self.spacer1 = QWidget() self.spacer1.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.ui.toolbar.addWidget(self.spacer1) # Set pen color self.pen_color_button = QPushButton(QIcon(':/icons/colors.png'), '', self.ui.toolbar) self.ui.toolbar.addWidget(self.pen_color_button) # Set pen size self.pen_size_label = QLabel() self.pen_size_label.setText('Pen size:') self.ui.toolbar.addWidget(self.pen_size_label) self.pen_size_spinbox = QSpinBox() self.pen_size_spinbox.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) self.pen_size_spinbox.setMinimumSize(QSize(75, 24)) self.pen_size_spinbox.setMinimum(1) self.ui.toolbar.addWidget(self.pen_size_spinbox) # Spacer 2 self.spacer2 = QWidget() self.spacer2.setMinimumWidth(40) self.spacer2.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) self.ui.toolbar.addWidget(self.spacer2) # Undo self.undo_button = QPushButton(QIcon(':/icons/back.png'), '', self.ui.toolbar) self.ui.toolbar.addWidget(self.undo_button) # Redo self.redo_button = QPushButton(QIcon(':/icons/forward.png'), '', self.ui.toolbar) self.ui.toolbar.addWidget(self.redo_button) # Spacer 3 self.spacer3 = QWidget() self.spacer3.setMinimumWidth(40) self.spacer3.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) self.ui.toolbar.addWidget(self.spacer3) # Clear canvas self.clear_button = QPushButton(QIcon(':/icons/garbage.png'), '', self.ui.toolbar) self.ui.toolbar.addWidget(self.clear_button) # ----------------- Undo/Redo ----------------- self.undoStack = QUndoStack(self) self.undoStack.setUndoLimit(30) # ======================== StatusBar settings ======================== self.cursor_coordinates_label = QLabel() self.ui.statusbar.addWidget(self.cursor_coordinates_label) # Signal - slot self.save_button.clicked.connect(self.save) self.pen_color_button.clicked.connect(self.set_pen_color) self.undo_button.clicked.connect(self.undo) self.redo_button.clicked.connect(self.redo) self.pen_size_spinbox.valueChanged.connect(self.set_pen_size) self.clear_button.clicked.connect(self.clear_canvas) def save(self): self.ui.canvas.image.save('.\\test.png', 'PNG', -1) def set_pen_size(self): self.ui.canvas.pen_size = self.pen_size_spinbox.value() def set_pen_color(self): color_dialog = QColorDialog() color = color_dialog.getColor() if color.isValid(): self.ui.canvas.pen_color = color def undo(self): self.ui.canvas.undo() def redo(self): self.ui.canvas.redo() def clear_canvas(self): # Clear canvas self.ui.canvas.clear()
-
Рисование на холсте
Рисование на холсте происходит в модуле PaintingArea. В качестве холста используется QImage.
Устанавливается максимальный доступный размер, т.е самого окна PaintingArea, и фон – просто белый.
Необходимо настроить кисть.
# Setting Default Tools self.painting = False self.pen_size = 3 self.pen_color = Qt.GlobalColor.black self.pen_style = Qt.PenStyle.SolidLine self.pen_cap = Qt.PenCapStyle.RoundCap self.pen_join = Qt.PenJoinStyle.RoundJoin
Так же ввести переменную, в которой будет храниться последняя координата, необходимая для рисования.
self.last_point = QPoint()
По нажатии левой кнопки мыши, происходит событие рисования.
def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: painter = QPainter(self.image) painter.setPen(QPen(self.pen_color, self.pen_size, self.pen_style, self.pen_cap, self.pen_join)) painter.drawPoint(event.pos()) self.painting = True self.last_point = event.pos() self.update()
Создается объект QPainter, ему задается кисть и начинается процесс рисования. Во время рисования переменной self.painting присваивается значение True, которое будет иметь данное значение до тех пор, пока левая кнопка мыши не будет отпущена.
В переменную self.last_point (которая упоминалась выше), записывается текущее положение курсора на холсте.
Для того, чтобы нарисованное отобразилось, вызывается метод update(), который в свою очередь вызывает метод paintEvent().
Чтобы «не стоять» на месте, т.е чтобы рисовать не только точку, а какие-нибудь линии, необходимо перемещение. Оно обрабатывается в следующем методе:
def mouseMoveEvent(self, event): if (event.buttons() == Qt.MouseButton.LeftButton) and self.painting: painter = QPainter(self.image) painter.setPen(QPen(self.pen_color, self.pen_size, self.pen_style, self.pen_cap, self.pen_join)) painter.drawLine(self.last_point, event.pos()) self.last_point = event.pos() self.update()
Здесь как раз и пригождается значение True переменной self.painting, которое совместно с зажатой левой кнопкой мыши позволяет продолжать рисовать непрерывно.
Опять создается объект класса QPainter, в качестве родителя ему передается холст, т.е объект QImage. Снова устанавливается кисть. Здесь уже рисуется не точка, а линия. В качестве начальной точки используется последняя координата из метода mousePressEvent, а в качестве конечной – новая координата, т.е куда переместился курсор. И снова запоминается последняя координата, которая при продолжении рисования будет использована.
Чтобы завершить непрерывное рисование (линий), достаточно отпустить левую кнопку мыши.
Это вызовет следующий обработчик события – mouseReleaseEvent.
В моем примере в нем большая часть кода связана с функционалом Undo/Redo, речь о котором будет ниже. Но приведу часть кода:
def mouseReleaseEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.painting = False
Так как отпустили левую кнопку мыши, то и проверка происходит именно на нее.
Внутри переменной self.painting присваивается значение False, что завершает непрерывное рисование, которое вновь можно начать при зажатии ЛКМ.
-
Изменение размера кисти.
Изменение размера кисти происходит при помощи объекта QSpinBox.
В главном окне имеется объект QSpinBox, при изменении значения срабатывает сигнал
self.pen_size_spinbox.valueChanged.connect(self.set_pen_size)
и обрабатывается в слоте
def set_pen_size(self): self.ui.canvas.pen_size = self.pen_size_spinbox.value()
который обращается к модулю с холстом.
3.Изменение цвета кисти.
Изменение цвета кисти так же, как и изменение размера кисти начинается с главного окна
В главном окне имеется объект QPushButton, при нажатии срабатывает сигнал
self.pen_color_button.clicked.connect(self.set_pen_color)
и обрабатывается в слоте
def set_pen_color(self): color_dialog = QColorDialog() color = color_dialog.getColor() if color.isValid(): self.ui.canvas.pen_color = color
В данном случае создается объект диалогового окна с выбором цвета
После выбора нужного цвета, возвращается результат, и если он корректный, то происходит обращение через холст к кисти, которой устанавливается выбранный цвет.
4.Изменение размера холста
С изменением размера холста намного интересней. Когда то у меня не получалось реализовать эту фичу, но как то я придумал два возможных решения, одно из которых я смог реализовать и оно сработало.
Я создал переменную, в которую временно будет записываться текущее состояние холста перед тем, как произойдет событие изменение холста.
self.buffer_image = QImage(0, 0, QImage.Format.Format_RGB32)
Хочется отметить, что изменение размера холста зависит от изменение самого окна.
Обрабатывается изменение в самом модуле с холстом
def resizeEvent(self, event): # Save current image to buffer self.buffer_image = self.image # Adjust the canvas to the new window size and clear the canvas to avoid distortion self.image = self.image.scaled(self._parent.size().width(), self._parent.size().height()) self.image.fill(Qt.GlobalColor.white) # Transfer the image from the buffer to the canvas, to the starting coordinate painter = QPainter(self.image) painter.drawImage(QPoint(0, 0), self.buffer_image)
Первое что происходит, это сохранение состояние холста, не важно, нарисовано что-либо или нет.
Далее устанавливается новый размер для холста, в зависимости от размера родительского окна.
Затем, холст очищается. Это необходимо для того, чтобы избежать коллизий.
И в конце создаем объект QPainter с холстом в качестве родителя и в нулевой координате рисуем наше изображение из «буфера».
Хочется отметить один важный момент. Все элементы управления я вынес в QToolBar, чтобы их размеры не сказывались на размер холста.
5.Функция Undo/Redo
Данную фичу я реализовывал не через фреймворк Qt Undo Framework, а делал свой велосипед.
В тулбаре имеются две кнопки, одна для Undo , другая для Redo.
У них имеются сигналы
self.undo_button.clicked.connect(self.undo) self.redo_button.clicked.connect(self.redo)
Которые начинают обрабатываться так же в PaintWindow
def undo(self): self.ui.canvas.undo() def redo(self): self.ui.canvas.redo()
вот уже в них происходит обращение в модуль с холстом
def undo(self): # If the current position is not at the very minimum if self.current_stack_position > 0: self.current_stack_position -= 1 self.image = self.image_stack[self.current_stack_position].copy() self.update() def redo(self): # If the current position is not at the very maximum of the stack if self.current_stack_position < len(self.image_stack) - 1: self.current_stack_position += 1 self.image = self.image_stack[self.current_stack_position].copy() self.update()
В undo идет проверка на текущую позицию верхушки стека. Если стек не пустой, то уменьшаем позицию стека на единицу, и по этой позиции получаем изображение. Важно отметить, что именно копию через метод copy() и устанавливаем его на холст. Чтобы изменения произошли, необходимо вызвать update(), который вызовет paintEvent() для перерисовки.
С redo ситуация похожая, только происходит проверка на то, является ли текущая позиция стека верхушкой или нет. Если undo уже было использовано, то соответственно можно использовать redo.
Увеличиваем показатель стека на единицу и получаем копию изображения из стека по данному индексу, обновляем холст.
Как говорилось выше, будет разбор mouseReleaseEvent.
def mouseReleaseEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.painting = False # Replacing an incorrectly sized zero (clean) image if len(self.image_stack) >= 1: temp_zero_img = self.image.copy() temp_zero_img.fill(Qt.GlobalColor.white) self.image_stack[0] = temp_zero_img.copy() if (len(self.image_stack) < self.image_stack_limit and not (self.current_stack_position < len(self.image_stack) - 1)): self.image_stack.append(self.image.copy()) self.current_stack_position = len(self.image_stack) - 1 self.update() elif self.current_stack_position < len(self.image_stack) - 1: for i in range(len(self.image_stack) - 1, self.current_stack_position, -1): self.image_stack.pop(i) self.image_stack.append(self.image.copy()) self.current_stack_position = len(self.image_stack) - 1 else: # Shift elements in a list self.image_stack.pop(0) # Replacing the last element (which was previously the first) with a new element self.image_stack.append(self.image.copy()) self.current_stack_position = len(self.image_stack) - 1 self.update()
Первым делом, в начале обработчика, нам нужно в стек получить чисто изображение. Чтобы избежать коллизии.
if len(self.image_stack) >= 1: temp_zero_img = self.image.copy() temp_zero_img.fill(Qt.GlobalColor.white) self.image_stack[0] = temp_zero_img.copy()
Получаем первое изображение, на котором хоть что то произошло (рисование точки, линии и т.п), очищаем его и помещает на первую позицию стека изображений.
Дальше идут различные ситуации. Дело в том, что имеется некий лимит стека, который задается в конструкторе модуля холста
# Image stack size for Undo/Redo self.image_stack_limit = 50 self.image_stack = list() self.image_stack.append(self.image.copy()) self.current_stack_position = 0
В данном случае он равняется 50 изображений.
if (len(self.image_stack) < self.image_stack_limit and not (self.current_stack_position < len(self.image_stack) - 1)): self.image_stack.append(self.image.copy()) self.current_stack_position = len(self.image_stack) - 1 self.update()
Если лимит еще не достигнут и не происходила операция Undo, то мы просто добавляем новое изображение в стек.
elif self.current_stack_position < len(self.image_stack) - 1: for i in range(len(self.image_stack) - 1, self.current_stack_position, -1): self.image_stack.pop(i) self.image_stack.append(self.image.copy()) self.current_stack_position = len(self.image_stack) - 1
Если же после использования Redo было новое рисование, то необходимо очистить стек изображение до того момента, до куда была отмотка Undo. Затем уже вставить изображение и обозначить новую позицию верхушки стека.
else: # Shift elements in a list self.image_stack.pop(0) self.image_stack.append(self.image.copy()) self.current_stack_position = len(self.image_stack) - 1
Если происходит рисование, и достигнут лимит стека, то удаляем первый элемент стека и добавляем в конец новое изображение, т.е делаем сдвиг по принципу очереди.
И не забываем в конце обработчика вызвать обновление холста
self.update()
6.Очистка холста.
Холст можно очистить. Необходимо нажать на кнопку на тулбаре. Процесс очистки начинается в модуле с главным окном, срабатываем сигнала
self.clear_button.clicked.connect(self.clear_canvas)
и обработчиком
def clear_canvas(self): # Clear canvas self.ui.canvas.clear()
Который уже обращается к методу в модуле с холстом
def clear(self): # Reset current stack position self.current_stack_position = 0 # Clear canvas self.image.fill(Qt.GlobalColor.white) # Copy clear canvas canvas = self.image.copy() # Clear Undo-Redo stack self.image_stack.clear() # Add zero image self.image_stack.append(canvas.copy()) self.update()
Сначала мы сбрасываем позицию указателя верхушки стека.
Далее очищаем изображение и копируем его, чтобы вставить на холст (сохраняя последний размер). В конце очищаем стек и вызываем обновление холста.
7.Сохранение изображения.
Наше изображение можно сохранить. Для простоты примера я сохраняю в туже директорию, где находится проект.
Начинается так же с нажатия кнопки на тулбаре и срабатыванием сигнала
self.save_button.clicked.connect(self.save)
и обработчиком
def save(self): self.ui.canvas.image.save('.\\test.png', 'PNG', -1)
который обращается к холсту и его методу save(). Указываем путь и название файла, расширение и качество.
Итог.
Таким образом, можно реализовать простую рисовалку. Правда пока что у меня не получилось реализовать элемент «выделить и вырезать», но надеюсь, мой пример и моя статья может кому-нибудь помочь.
Спасибо за внимание.
ссылка на оригинал статьи https://habr.com/ru/articles/843322/
Добавить комментарий