PyQt5メモ - 動いた🤩

簡易ペイントアプリを作る②

今日はずいぶんGoogle Geminiさんのお世話になりました。欲しい機能を4つ残して、基本的なアプリが完成しました。

トレース用簡易ペイントのコード

# forTrace.py 2025.09.14
# 手頃なアプリがないので自分で作ることにした。

import sys
from PyQt5.QtCore import Qt, QPointF, QSize
from PyQt5.QtGui import QPainter, QImage, QPen, QColor, QIcon, QBrush, QKeySequence, QPixmap
from PyQt5.QtWidgets import (
        QMainWindow, QApplication, QMenu, QMenuBar, QAction, QFileDialog,
        QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QSlider, QLabel, QShortcut )

# 色リスト
COLORS = [
# 17 undertones https://lospec.com/palette-list/17undertones
'#000000', '#ffffff', '#35e3e3', '#141923', '#414168', '#3a7fa7', '#8fd970',
'#5ebb49', '#458352', '#dcd37b', '#fffee5', '#ffd035', '#cc9245', '#a15c3e',
'#a42f3b', '#f45b7a', '#c24998', '#81588d', '#bcb0c2'
]

# Canvasクラス
class Canvas(QLabel):
    def __init__(self):
        super().__init__()
        width = 256 * 3
        height = 256 * 3
        self.setGeometry(0, 0, width, height)

        # 描画用のQPixmapを初期化
        self.image = QPixmap(width, height)
        self.image.fill(QColor("white"))
        self.setPixmap(self.image)

        # 初期値
        self.last_x, self.last_y = None, None
        self.pen_color = QColor('#000000')
        self.pen_size = 5

    def set_pen_color(self, c):
        self.pen_color = QColor(c)
    
    def set_pen_size(self, pix):
        self.pen_size = pix

    def mouseMoveEvent(self, e):
        if self.last_x is None: # First event.
            self.last_x = e.x()
            self.last_y = e.y()
            return # Ignore the first time.

        painter = QPainter(self.pixmap())
        p = painter.pen()
        p.setWidth(self.pen_size)   # 整数
        p.setColor(self.pen_color)
        p.setCapStyle(Qt.RoundCap) 
        p.setJoinStyle(Qt.RoundJoin)
        painter.setPen(p)
        painter.drawLine(self.last_x, self.last_y, e.x(), e.y())
        painter.end()
        self.update()

        # Update the origin for next time.
        self.last_x = e.x()
        self.last_y = e.y()

    def mouseReleaseEvent(self, e):
        self.last_x = None
        self.last_y = None

    # 外部画像を読み込み、キャンバスに設定するメソッド
    def load_image_to_canvas(self, path):
        loaded_pixmap = QPixmap(path)
        if not loaded_pixmap.isNull():
            # 新しいQPixmapを作成し、キャンバスのサイズに合わせる
            new_image = QPixmap(self.size())
            new_image.fill(QColor("white")) # 背景を白で塗りつぶす

            # QPainterを使用して、読み込んだ画像を新しいQPixmapに描画
            painter = QPainter(new_image)
            
            # 画像をキャンバスのサイズに合わせてスケール
            scaled_pixmap = loaded_pixmap.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
            
            # 描画開始位置を計算して中央に配置
            x_offset = (new_image.width() - scaled_pixmap.width()) // 2
            y_offset = (new_image.height() - scaled_pixmap.height()) // 2
            
            painter.drawPixmap(x_offset, y_offset, scaled_pixmap)
            painter.end()

            self.image = new_image
            self.setPixmap(self.image)
            self.update()
            return True
        return False

    # キャンバスをクリアするメソッド
    def clear_canvas(self):
        # QPixmapオブジェクトを新しく作成して、白で塗りつぶす
        self.image = QPixmap(self.size())
        self.image.fill(QColor("white"))
        self.setPixmap(self.image)
        self.update()


# Paletteクラス
class QPaletteButton(QPushButton):
    def __init__(self, color):
        super().__init__()
        self.setFixedSize(QSize(24,24))
        self.color = color
        self.setStyleSheet("background-color: %s;" % color)

# メインウィンドウ
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.canvas = Canvas()

        # パラメータ
        title = "forTrace.py"
        icon = "icons/face-button.png"
        
        # 描画領域
        self.canvas = Canvas()
        
        # タイトルとアイコン
        title = "forTrace.py"
        icon = "icons/face-button.png"
        self.setWindowTitle(title)
        self.setWindowIcon(QIcon(icon))

        # ウィジェット配置
        w = QWidget()
        l = QVBoxLayout()
        w.setLayout(l)
        palette = QHBoxLayout()
        self.add_palette_buttons(palette)
        l.addLayout(palette)
        l.addWidget(self.canvas)
        self.setCentralWidget(w)

        # メニュー
        mainMenu = self.menuBar()
        fileMenu = mainMenu.addMenu("ファイル")
        brushes  = mainMenu.addMenu("ブラシ")

        # アクション登録
        loadAction = QAction(QIcon("icons/load-button.png"), "読込",self)
        loadAction.setShortcut("Ctrl+O")
        fileMenu.addAction(loadAction)
        loadAction.triggered.connect(self.load)
        
        saveAction = QAction(QIcon("icons/save-button.png"), "保存",self)
        saveAction.setShortcut("Ctrl+S")
        fileMenu.addAction(saveAction)
        saveAction.triggered.connect(self.save)

        clearAction = QAction(QIcon("icons/delete-button.png"), "クリア", self)
        clearAction.setShortcut("Ctrl+C")
        fileMenu.addAction(clearAction)
        clearAction.triggered.connect(self.clear)
        
        pix1Action = QAction( QIcon("icons/pixel-1.svg"), "1px", self)
        brushes.addAction(pix1Action)
        pix1Action.triggered.connect(self.pix1)
        
        pix5Action = QAction( QIcon("icons/pixel-5.svg"), "5px", self)
        brushes.addAction(pix5Action)
        pix5Action.triggered.connect(self.pix5)

        pix10Action = QAction( QIcon("icons/pixel-10.svg"), "10px", self)
        brushes.addAction(pix10Action)
        pix10Action.triggered.connect(self.pix10)
        
        pix20Action = QAction( QIcon("icons/pixel-20.svg"), "20px", self)
        brushes.addAction(pix20Action)
        pix20Action.triggered.connect(self.pix20)
        
        fillAction = QAction( QIcon("icons/fill.svg"), "フィル", self)
        brushes.addAction(fillAction)
        fillAction.triggered.connect(self.fill)


    # 読込
    def load(self):
        # ファイルダイアログを開き、画像ファイルを選択
        filePath, _ = QFileDialog.getOpenFileName(self, "Load Image", "", "BMP(*.bmp);;PNG(*.png);;JPEG(*.jpg);;All Files(*.*) ")
        if not filePath:
            return

        if not self.canvas.load_image_to_canvas(filePath):
            QMessageBox.warning(self, "エラー", "画像の読み込みに失敗しました。")

    # 保存
    def save(self):
        filePath, _ = QFileDialog.getSaveFileName(self, "Save Image", "", "BMP(*.bmp);;PNG(*.png);;JPEG(*.jpg *.jpeg);;All Files(*.*) ")
        if filePath == "":
            return
        
        # キャンバスからQPixmapを取得
        pixmap = self.canvas.pixmap()
        if pixmap.isNull():
            # pixmapが存在しない場合は警告を表示して終了
            QMessageBox.warning(self, "エラー", "保存する画像がありません。")
            return
        
        # QPixmapをQImageに変換して保存
        image = pixmap.toImage()
        if not image.save(filePath):
            QMessageBox.warning(self, "エラー", "画像の保存に失敗しました。")

    # クリア
    def clear(self):
        self.canvas.clear_canvas()

    # 線幅1ピクセル(極細)
    def pix1(self):
        self.canvas.set_pen_size(1)

    # 線幅5ピクセル(細)
    def pix5(self):
        self.canvas.set_pen_size(5)

    # 線幅10ピクセル(太)
    def pix10(self):
        self.canvas.set_pen_size(10)

    # 線幅20ピクセル(極太)
    def pix20(self):
        self.canvas.set_pen_size(20)

    # 塗りつぶし
    def fill(self):
        print('fill 未実装')

    # パレット
    def add_palette_buttons(self, layout):
        for c in COLORS:
            b = QPaletteButton(c)
            b.pressed.connect(lambda c=c: self.canvas.set_pen_color(c))
            layout.addWidget(b)

#.......................................................................#
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

出来上がりはこんな感じ

ファイルメニュー

ブラシメニュー


そしてこれから

あと4つ欲しいのが、

  • マウスホィールの上下でズームイン/ズームアウト
  • マウスの中ボタンドラッグでパン
  • やり直し機能(Ctrl+Z)
  • 塗りつぶし

これらの実装ができたら、mtPaintに変えて自作アプリを使います。

Python  Qt5 

関連記事