Здравствуйте, уважаемые Хабравчане и гости!
Это моя первая статья на Хабре. Она не претендует на какой-либо уровень, а предназначена в первую очередь для тех, кто так же, как и я до написания этой статьи, находится в поиске решения проблемы рисования в 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(). Указываем путь и название файла, расширение и качество.
Итог.
Таким образом, можно реализовать простую рисовалку. Правда пока что у меня не получилось реализовать элемент «выделить и вырезать», но надеюсь, мой пример и моя статья может кому-нибудь помочь.
Спасибо за внимание.