Скриншот приложения
Скриншот приложения


Здравствуйте, уважаемые Хабравчане и гости!

Это моя первая статья на Хабре. Она не претендует на какой-либо уровень, а предназначена в первую очередь для тех, кто так же, как и я до написания этой статьи, находится в поиске решения проблемы рисования в 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()

  1. Рисование на холсте

Рисование на холсте происходит в модуле 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, что завершает непрерывное рисование, которое вновь можно начать при зажатии ЛКМ.

  1. Изменение размера кисти.

    Изменение размера кисти происходит при помощи объекта 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
Функция Undo/Redo

В тулбаре имеются две кнопки, одна для 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(). Указываем путь и название файла, расширение и качество.

Итог.

Таким образом, можно реализовать простую рисовалку. Правда пока что у меня не получилось реализовать элемент «выделить и вырезать», но надеюсь, мой пример и моя статья может кому-нибудь помочь.

Ссылка на проект

Спасибо за внимание.

Комментарии (6)


  1. TrueRomanus
    14.09.2024 11:10
    +2

    А почему для описания не использовать .ui файлы? или вообще QML? Портянки кода для создания виджетов выглядят жирными и не читаемыми.


    1. Eixini Автор
      14.09.2024 11:10

      Для тех, кто подробней хочет изучить код, ссылку на репо кинул.


  1. razoryoutub
    14.09.2024 11:10

    Честно говоря не хватило видео или хотя бы скриншотов финального приложения(

    А так - отличная статья


    1. Eixini Автор
      14.09.2024 11:10

      Спасибо за отзыв. Вроде к превьюшке делал скрин, не знаю, не отобразился что ли.


      1. razoryoutub
        14.09.2024 11:10

        Спасибо, читаю с мобильной версии, превью не отображается


    1. Eixini Автор
      14.09.2024 11:10

      Отредактировал, скриншот вначале добавил.
      Правда не увидел как фрагмент кода добавить, а то уже недовольные нашлись(