Разработка приложения для рисования на Python: от холста до полноценного инструмента
Создание графического редактора на Python: основные подходы
Эффективное решение на PyQt5 с QGraphicsView
PyQt5 предоставляет мощный фреймворк для создания графических приложений. QGraphicsView и QGraphicsScene позволяют реализовать редактор с поддержкой масштабирования, поворота и сложной логики взаимодействия.
Шаги реализации:
- Установка PyQt5:
pip install PyQt5 - Создание класса
DrawingView, наследующего от QGraphicsView, с переопределением событий мыши. - Создание класса
MainWindowс размещением QGraphicsScene и DrawingView. - Добавление инструмента "кисть" через рисование линий.
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 для выбора цвета. Цвет применяется к последующим линиям и заливкам.