Разработка приложения для рисования на Python: от холста до полноценного инструмента

Раздел: Разработка на Python -> GUI программирование

Создание графического редактора на Python: основные подходы

Эффективное решение на PyQt5 с QGraphicsView

PyQt5 предоставляет мощный фреймворк для создания графических приложений. QGraphicsView и QGraphicsScene позволяют реализовать редактор с поддержкой масштабирования, поворота и сложной логики взаимодействия.

Шаги реализации:

  1. Установка PyQt5: pip install PyQt5
  2. Создание класса DrawingView, наследующего от QGraphicsView, с переопределением событий мыши.
  3. Создание класса MainWindow с размещением QGraphicsScene и DrawingView.
  4. Добавление инструмента "кисть" через рисование линий.

import sys
from PyQt5.QtWidgets import QApplication, QMainWindow, QGraphicsView, QGraphicsScene
from PyQt5.QtCore import Qt, QPointF
from PyQt5.QtGui import QPainter, QPen

class DrawingView(QGraphicsView):
    def __init__(self, scene):
        super().__init__(scene)
        self.setRenderHint(QPainter.Antialiasing)
        self.last_point = QPointF()
        self.drawing = False
        self.pen = QPen(Qt.black, 2)

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.drawing = True
            self.last_point = self.mapToScene(event.pos())
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event):
        if self.drawing:
            current = self.mapToScene(event.pos())
            self.scene().addLine(self.last_point.x(), self.last_point.y(), current.x(), current.y(), self.pen)
            self.last_point = current
        super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.drawing = False
        super().mouseReleaseEvent(event)

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('Графический редактор')
        self.scene = QGraphicsScene()
        self.view = DrawingView(self.scene)
        self.setCentralWidget(self.view)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = MainWindow()
    w.show()
    sys.exit(app.exec_())

Python window manager (оконный менеджер в python)

Пояснения: класс DrawingView обрабатывает нажатия и движения мыши, добавляя линии на сцену. Используется setRenderHint(QPainter.Antialiasing) для сглаживания. Проблемы: при быстром движении мыши линии могут прерываться - решается настройкой setMouseTracking(True) или использованием QPainterPath. Также производительность падает при множестве линий - рекомендуется группировать их в один QGraphicsPathItem.

Типичные ошибки и их решения:

  • Ошибка: линии рисуются не на том месте из-за неправильного пересчёта координат. Решение: использовать mapToScene для преобразования координат виджета в координаты сцены.
  • Ошибка: приложение зависает при большом количестве линий. Решение: заменить отдельные линии на один QPainterPath, обновляющийся при каждом штрихе.
  • Ошибка: не работает сглаживание. Решение: установить QPainter.Antialiasing в render hints.

Цели и случаи использования: PyQt5 подходит для создания профессиональных десктопных приложений с богатым пользовательским интерфейсом, поддержкой векторной графики и масштабирования. Рекомендуется для разработки полноценных редакторов с множеством инструментов.

Как сделать простой редактор на Tkinter?

Tkinter входит в стандартную библиотеку Python и не требует установки дополнительных пакетов. Используется виджет Canvas.


import tkinter as tk
from tkinter import colorchooser

def start_draw(event):
    global last_x, last_y
    last_x, last_y = event.x, event.y

def draw(event):
    global last_x, last_y
    canvas.create_line(last_x, last_y, event.x, event.y, fill=color.get(), width=2)
    last_x, last_y = event.x, event.y

def choose_color():
    c = colorchooser.askcolor()
    if c[1]:
        color.set(c[1])

root = tk.Tk()
root.title('Простой редактор')
canvas = tk.Canvas(root, bg='white', width=800, height=600)
canvas.pack()
canvas.bind("<Button-1>", start_draw)
canvas.bind("<B1-Motion>", draw)
color = tk.StringVar(value='black')
btn = tk.Button(root, text='Выбрать цвет', command=choose_color)
btn.pack()
root.mainloop()

Python графический редактор (графический редактор на python)

Пояснение: при нажатии левой кнопки мыши запоминается начальная точка, при движении рисуется линия. Цвет выбирается через диалоговое окно. Проблема: отсутствие сохранения, невозможность масштабирования. Решается добавлением кнопок сохранения в файл через canvas.postscript или преобразование в PNG с помощью PIL.

  • Ошибка: линия рисуется от предыдущей точки, если не обновлять last_x. Решение: правильно обновлять last_x в функции draw.
  • Ошибка: цвет не меняется. Решение: убедиться, что переменная color связана с fill через trace или StringVar.

Цели: Tkinter подходит для быстрых прототипов и обучения, не требует установки дополнительных библиотек. Не рекомендуется для сложных многослойных редакторов.

Как создать кроссплатформенный редактор с Kivy?

Kivy ориентирован на мультитач и мобильные устройства. Рисование реализуется через canvas инструкции.


from kivy.app import App
from kivy.uix.widget import Widget
from kivy.graphics import Line, Color
from kivy.uix.button import Button
from kivy.uix.boxlayout import BoxLayout

class DrawingWidget(Widget):
    def on_touch_down(self, touch):
        with self.canvas:
            Color(0,0,0,1)
            touch.ud['line'] = Line(points=(touch.x, touch.y), width=2)
    def on_touch_move(self, touch):
        if 'line' in touch.ud:
            touch.ud['line'].points += [touch.x, touch.y]

class DrawingApp(App):
    def build(self):
        parent = BoxLayout(orientation='vertical')
        drawing = DrawingWidget()
        parent.add_widget(drawing)
        clear_btn = Button(text='Очистить', size_hint=(1,0.1))
        clear_btn.bind(on_press=lambda x: drawing.canvas.clear())
        parent.add_widget(clear_btn)
        return parent

if __name__ == '__main__':
    DrawingApp().run()

Проблемы: нет встроенного сохранения в изображение, сложность с интеграцией стандартных диалогов. Решение: экспортировать canvas в текстуру с помощью export_to_png.

  • Ошибка: при очистке холста удаляются все инструкции, в том числе фон. Решение: сохранять фон отдельно или перерисовывать после очистки.
  • Ошибка: приложение не запускается на Android без соответствующей упаковки. Решение: использовать Buildozer для сборки APK.

Цели: Kivy предназначен для создания приложений, работающих на Windows, macOS, Linux, iOS и Android. Подходит для мобильных графических редакторов.

Как использовать wxPython для графического редактора?

wxPython предоставляет нативные элементы управления и хорошо интегрируется с системой.


import wx

class DrawingPanel(wx.Panel):
    def __init__(self, parent):
        super().__init__(parent)
        self.Bind(wx.EVT_LEFT_DOWN, self.on_press)
        self.Bind(wx.EVT_MOTION, self.on_motion)
        self.Bind(wx.EVT_LEFT_UP, self.on_release)
        self.Bind(wx.EVT_PAINT, self.paint_event)
        self.drawing = False
        self.last_point = None
        self.lines = []

    def on_press(self, event):
        self.drawing = True
        self.last_point = event.GetPosition()
        self.CaptureMouse()

    def on_motion(self, event):
        if self.drawing and event.Dragging() and event.LeftIsDown():
            current = event.GetPosition()
            self.lines.append((self.last_point, current))
            self.last_point = current
            self.Refresh()

    def on_release(self, event):
        if self.drawing:
            self.drawing = False
            self.ReleaseMouse()

    def paint_event(self, event):
        dc = wx.PaintDC(self)
        dc.SetPen(wx.Pen(wx.BLACK, 2))
        for start, end in self.lines:
            dc.DrawLine(start.x, start.y, end.x, end.y)

app = wx.App()
frame = wx.Frame(None, title='wxEditor', size=(800,600))
panel = DrawingPanel(frame)
frame.Show()
app.MainLoop()

Проблемы: перерисовка всех линий при каждом движении может быть медленной. Решение: использование буфера (wx.Bitmap) для хранения нарисованного.

  • Ошибка: линии не отображаются после сворачивания окна. Решение: правильно обрабатывать событие EVT_PAINT и использовать двойную буферизацию.
  • Ошибка: мышь не захватывается на всех платформах. Решение: проверять поддержку CaptureMouse.

Цели: wxPython подходит для создания приложений с нативным внешним видом на Windows и macOS, где важна интеграция с системными диалогами.

Как реализовать редактор с помощью Dear PyGui?

Dear PyGui использует Immediate Mode GUI и отличается высокой производительностью рендеринга.


import dearpygui.dearpygui as dpg

def mouse_drag(sender, app_data, user_data):
    dpg.draw_line(user_data['last_x'], user_data['last_y'], app_data[1], app_data[2], parent='drawlist', color=(0,0,0), thickness=2)
    user_data['last_x'], user_data['last_y'] = app_data[1], app_data[2]

def mouse_down(sender, app_data, user_data):
    user_data['last_x'], user_data['last_y'] = app_data[1], app_data[2]

dpg.create_context()
with dpg.window(label='DearPyGui Editor', width=800, height=600):
    with dpg.drawlist(width=800, height=500, id='drawlist'):
        pass
    dpg.add_button(label='Очистить', callback=lambda: dpg.delete_item('drawlist', children_only=True))
dpg.create_viewport(title='Editor', width=800, height=600)
dpg.setup_dearpygui()
with dpg.handler_registry():
    dpg.add_mouse_drag_handler(callback=mouse_drag, user_data={'last_x':0,'last_y':0})
    dpg.add_mouse_down_handler(callback=mouse_down, user_data={'last_x':0,'last_y':0})
dpg.show_viewport()
dpg.start_dearpygui()
dpg.destroy_context()

Проблемы: отсутствие встроенных диалогов для выбора цвета и сохранения файла, необходимость ручного управления контекстом. Решение: использовать дополнительные модули или реализовать свои диалоги.

  • Ошибка: линии не отображаются, если не вызван родительский drawlist. Решение: убедиться, что параметр parent правильный.
  • Ошибка: приложение вылетает при закрытии. Решение: правильно уничтожать контекст через dpg.destroy_context().

Цели: Dear PyGui подходит для создания быстрых прототипов инструментов с акцентом на производительность, например, для научной визуализации.

Расширенные примеры кода для графического редактора

Здесь представлены более сложные реализации, выходящие за рамки базового рисования. Каждый пример сопровождается кодом и описанием результата.

Пример 1: Редактор на PyQt5 с инструментами карандаш, прямоугольник, эллипс и выбор цвета

В этом примере добавляется панель инструментов и возможность выбора фигур.

Пример

import sys
from PyQt5.QtWidgets import (QApplication, QMainWindow, QGraphicsView, QGraphicsScene,
                             QToolBar, QAction, QColorDialog)
from PyQt5.QtCore import Qt, QPointF, QRectF
from PyQt5.QtGui import QPainter, QPen, QColor, QBrush

class DrawingView(QGraphicsView):
    def __init__(self, scene):
        super().__init__(scene)
        self.setRenderHint(QPainter.Antialiasing)
        self.last_point = QPointF()
        self.drawing = False
        self.pen = QPen(Qt.black, 2)
        self.fill = QBrush(Qt.NoBrush)
        self.tool = 'pencil'

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.drawing = True
            self.last_point = self.mapToScene(event.pos())
            if self.tool in ('rect', 'ellipse'):
                self.current_item = None
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event):
        if self.drawing and event.buttons() & Qt.LeftButton:
            current = self.mapToScene(event.pos())
            if self.tool == 'pencil':
                self.scene().addLine(self.last_point.x(), self.last_point.y(), current.x(), current.y(), self.pen)
                self.last_point = current
            elif self.tool == 'rect':
                if self.current_item:
                    self.scene().removeItem(self.current_item)
                rect = QRectF(self.last_point, current)
                self.current_item = self.scene().addRect(rect, self.pen, self.fill)
            elif self.tool == 'ellipse':
                if self.current_item:
                    self.scene().removeItem(self.current_item)
                rect = QRectF(self.last_point, current)
                self.current_item = self.scene().addEllipse(rect, self.pen, self.fill)
        super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.drawing = False
            self.current_item = None
        super().mouseReleaseEvent(event)

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('Расширенный редактор')
        self.scene = QGraphicsScene()
        self.view = DrawingView(self.scene)
        self.setCentralWidget(self.view)
        self.create_toolbar()

    def create_toolbar(self):
        toolbar = self.addToolBar('Инструменты')
        pencil_action = QAction('Карандаш', self)
        pencil_action.triggered.connect(lambda: setattr(self.view, 'tool', 'pencil'))
        toolbar.addAction(pencil_action)
        rect_action = QAction('Прямоугольник', self)
        rect_action.triggered.connect(lambda: setattr(self.view, 'tool', 'rect'))
        toolbar.addAction(rect_action)
        ellipse_action = QAction('Эллипс', self)
        ellipse_action.triggered.connect(lambda: setattr(self.view, 'tool', 'ellipse'))
        toolbar.addAction(ellipse_action)
        color_action = QAction('Цвет...', self)
        color_action.triggered.connect(self.choose_color)
        toolbar.addAction(color_action)

    def choose_color(self):
        color = QColorDialog.getColor()
        if color.isValid():
            self.view.pen.setColor(color)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = MainWindow()
    w.show()
    sys.exit(app.exec_())
Результат: открывается окно с панелью инструментов. Выбор инструмента позволяет рисовать линии, прямоугольники или эллипсы. Цвет линии меняется через диалог выбора цвета. Фигуры отображаются сразу при перетаскивании.

Пример 2: Реализация отмены (undo) и повтора (redo) с использованием стека команд

Отмена действий важна для любого редактора. В данном примере используется паттерн Команда: каждое рисование запоминается как объект, который можно откатить.

Пример

import sys
from PyQt5.QtWidgets import QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QToolBar, QAction, QUndoStack, QUndoCommand
from PyQt5.QtCore import Qt, QPointF
from PyQt5.QtGui import QPainter, QPen

class DrawCommand(QUndoCommand):
    def __init__(self, scene, line):
        super().__init__()
        self.scene = scene
        self.line = line

    def undo(self):
        self.scene.removeItem(self.line)

    def redo(self):
        self.scene.addItem(self.line)

class DrawingView(QGraphicsView):
    def __init__(self, scene, undo_stack):
        super().__init__(scene)
        self.undo_stack = undo_stack
        self.setRenderHint(QPainter.Antialiasing)
        self.last_point = QPointF()
        self.drawing = False
        self.pen = QPen(Qt.black, 2)
        self.current_line = None

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.drawing = True
            self.last_point = self.mapToScene(event.pos())
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event):
        if self.drawing:
            current = self.mapToScene(event.pos())
            line = self.scene().addLine(self.last_point.x(), self.last_point.y(), current.x(), current.y(), self.pen)
            cmd = DrawCommand(self.scene(), line)
            self.undo_stack.push(cmd)
            self.last_point = current
        super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.drawing = False
        super().mouseReleaseEvent(event)

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('Undo/Redo редактор')
        self.undo_stack = QUndoStack(self)
        self.scene = QGraphicsScene()
        self.view = DrawingView(self.scene, self.undo_stack)
        self.setCentralWidget(self.view)
        self.create_toolbar()

    def create_toolbar(self):
        toolbar = self.addToolBar('Правка')
        undo_action = self.undo_stack.createUndoAction(self, '&Отменить')
        undo_action.setShortcut('Ctrl+Z')
        toolbar.addAction(undo_action)
        redo_action = self.undo_stack.createRedoAction(self, '&Повторить')
        redo_action.setShortcut('Ctrl+Shift+Z')
        toolbar.addAction(redo_action)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = MainWindow()
    w.show()
    sys.exit(app.exec_())
Результат: каждое движение мыши создаёт команду в стеке. Нажатие Ctrl+Z отменяет последнюю нарисованную линию, Ctrl+Shift+Z возвращает её обратно. Стек не ограничен по размеру, но в реальном проекте нужно ограничить количество команд.

Пример 3: Сохранение холста в PNG файл с использованием PyQt5

Сохранение результата работы – обязательная функция. Здесь используется QPixmap для рендеринга сцены.

Пример

import sys
from PyQt5.QtWidgets import QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QFileDialog, QAction
from PyQt5.QtCore import Qt, QPointF
from PyQt5.QtGui import QPainter, QPen, QPixmap

class DrawingView(QGraphicsView):
    # ... (такой же как в первом примере) ...
    pass

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('Сохранение редактора')
        self.scene = QGraphicsScene()
        self.view = DrawingView(self.scene)
        self.setCentralWidget(self.view)
        self.create_menu()

    def create_menu(self):
        menubar = self.menuBar()
        file_menu = menubar.addMenu('&Файл')
        save_action = QAction('&Сохранить как PNG...', self)
        save_action.setShortcut('Ctrl+S')
        save_action.triggered.connect(self.save_png)
        file_menu.addAction(save_action)

    def save_png(self):
        file_path, _ = QFileDialog.getSaveFileName(self, 'Сохранить изображение', '', 'PNG (*.png)')
        if file_path:
            pixmap = QPixmap(self.scene.sceneRect().size().toSize())
            pixmap.fill(Qt.white)
            painter = QPainter(pixmap)
            self.scene.render(painter)
            painter.end()
            pixmap.save(file_path, 'PNG')
Результат: при выборе пункта меню "Сохранить как PNG" открывается диалог выбора файла. После сохранения создаётся PNG изображение текущего содержимого сцены с белым фоном.

Пример 4: Инструмент выбора цвета и толщины линии в Tkinter

Расширение простого Tkinter-редактора: добавление слайдера для толщины и кнопки выбора цвета.

Пример

import tkinter as tk
from tkinter import colorchooser, Scale, HORIZONTAL

def start_draw(event):
    global last_x, last_y
    last_x, last_y = event.x, event.y

def draw(event):
    global last_x, last_y
    canvas.create_line(last_x, last_y, event.x, event.y, fill=color.get(), width=width_scale.get())
    last_x, last_y = event.x, event.y

def choose_color():
    c = colorchooser.askcolor()
    if c[1]:
        color.set(c[1])

root = tk.Tk()
root.title('Редактор с настройками')
canvas = tk.Canvas(root, bg='white', width=800, height=600)
canvas.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
canvas.bind("<Button-1>", start_draw)
canvas.bind("<B1-Motion>", draw)

color = tk.StringVar(value='black')
btn_color = tk.Button(root, text='Цвет', command=choose_color)
btn_color.pack(side=tk.LEFT)

width_scale = Scale(root, from_=1, to=20, orient=HORIZONTAL, label='Толщина')
width_scale.set(2)
width_scale.pack(side=tk.LEFT)

root.mainloop()
Результат: окно с холстом и панелью управления. Ползунок регулирует толщину линии от 1 до 20. Кнопка открывает диалог выбора цвета.

Пример 5: Интеграция заливки цветом в Kivy редакторе

В Kivy добавлена заливка области с использованием метода on_touch_down для распознавания заливки.

Пример

from kivy.app import App
from kivy.uix.widget import Widget
from kivy.graphics import Line, Color, Rectangle
from kivy.uix.button import Button
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.colorpicker import ColorPicker
from kivy.uix.popup import Popup

class DrawingWidget(Widget):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.color = (0,0,0,1)

    def on_touch_down(self, touch):
        if touch.button == 'left':
            with self.canvas:
                Color(*self.color)
                touch.ud['line'] = Line(points=(touch.x, touch.y), width=2)
        elif touch.button == 'right':
            with self.canvas:
                Color(*self.color)
                Rectangle(pos=(touch.x-25, touch.y-25), size=(50,50))
        return super().on_touch_down(touch)

    def on_touch_move(self, touch):
        if 'line' in touch.ud:
            touch.ud['line'].points += [touch.x, touch.y]

class DrawingApp(App):
    def build(self):
        parent = BoxLayout(orientation='vertical')
        drawing = DrawingWidget()
        parent.add_widget(drawing)
        btn_box = BoxLayout(size_hint=(1,0.1))
        clear_btn = Button(text='Очистить')
        clear_btn.bind(on_press=lambda x: drawing.canvas.clear())
        btn_box.add_widget(clear_btn)
        color_btn = Button(text='Цвет')
        def show_color_picker(btn):
            content = BoxLayout(orientation='vertical')
            picker = ColorPicker()
            content.add_widget(picker)
            close_btn = Button(text='Закрыть')
            popup = Popup(title='Выбор цвета', content=content, size_hint=(0.5,0.5))
            close_btn.bind(on_press=lambda x: setattr(drawing, 'color', picker.color) or popup.dismiss())
            content.add_widget(close_btn)
            popup.open()
        color_btn.bind(on_press=show_color_picker)
        btn_box.add_widget(color_btn)
        parent.add_widget(btn_box)
        return parent

if __name__ == '__main__':
    DrawingApp().run()
Результат: правая кнопка мыши рисует квадрат заливки, левая – линии. Кнопка "Цвет" открывает ColorPicker для выбора цвета. Цвет применяется к последующим линиям и заливкам.

Графический редактор на Python - comments

En
Python графический редактор (python)