LoginSignup
1
3

More than 1 year has passed since last update.

Pythonでオリジナルのドット絵エディタを作る(2) - ペイントツールの追加

Last updated at Posted at 2021-08-08

はじめに

前回の記事で作成したドット絵エディタにツールや機能を追加して使いやすいドット絵エディタにしていきます。
追加するツール・機能は以下の通りです。

  • パレット
  • 線ツール
  • 四角ツール(塗りつぶし有り/無し)
  • スポイトツール
  • 塗りつぶしツール
  • 格子ツール
  • 取り消し/やり直し

環境

前回から使用しているPySide6に加えて、以下のライブラリを追加で使用します。
- NumPy (1.19.5)
- OpenCV (4.5.2)
- copy
- collections

NumPyとOpenCVは事前にインストールしておく必要があります。
(copyとcollectionsは標準ライブラリのためインストール不要です)

ソースコード

まずはソースコードを示します。

advanced-paint.py
import sys
import copy
import cv2
import numpy as np
from collections import deque
from PySide6.QtCore import *
from PySide6.QtWidgets import *
from PySide6.QtGui import *

class MainWindow(QMainWindow):
    def __init__(self, parent=None):
        super(MainWindow, self).__init__(parent)

        self.canvas = Canvas()
        self.setCentralWidget(self.canvas)
        self.setFocus()
        self.setFocusPolicy(Qt.ClickFocus)
        #ウィンドウ設定
        self.initUI()

    def initUI(self):
        self.setGeometry(100, 200, 700, 650)
        self.setWindowTitle("PAINTapl")
        #メニューバーの設定
        menubar = self.menuBar()
        openAct = QAction("&画像開く", self)
        saveAct = QAction("&保存", self)
        exitAct = QAction("&終了", self)
        openAct.setShortcut("Ctrl+O")
        openAct.triggered.connect(self.openFile)
        saveAct.setShortcut("Ctrl+S")
        saveAct.triggered.connect(self.saveFile)
        exitAct.triggered.connect(QCoreApplication.instance().quit)
        fileMenu = menubar.addMenu("&ファイル")
        fileMenu.addAction(openAct)
        fileMenu.addAction(saveAct)
        fileMenu.addAction(exitAct)
        #グリッド表示切り替え
        self.cb_image1 = QCheckBox(self)
        self.cb_image1.move(300, 20)
        self.cb_image1.resize(200, 30)
        self.cb_image1.setText("グリッドを表示")
        self.cb_image1.setCheckState(Qt.Checked)
        self.cb_image1.stateChanged.connect(self.gridControl)

        #幅入力のスピンボックス
        self.sb_width = QSpinBox(self)
        self.sb_width.setFocusPolicy(Qt.NoFocus)
        self.sb_width.move(70, 350)
        self.sb_width.resize(40,18)
        self.sb_width.setMinimum(1)
        self.sb_width.valueChanged.connect(self.valueToCanvas)
        self.sb_width_label = QLabel(self)
        self.sb_width_label.move(10, 350)
        self.sb_width_label.resize(50,18)
        self.sb_width_label.setText("横:")
        self.sb_width_label.setAlignment(Qt.AlignRight)
        #スペース入力のスピンボックス
        self.sb_height = QSpinBox(self)
        self.sb_height.setFocusPolicy(Qt.NoFocus)
        self.sb_height.move(70, 370)
        self.sb_height.resize(40,18)
        self.sb_height.setMinimum(1)
        self.sb_height.valueChanged.connect(self.valueToCanvas)
        self.sb_height_label = QLabel(self)
        self.sb_height_label.move(10, 370)
        self.sb_height_label.resize(50,18)
        self.sb_height_label.setText("縦:")
        self.sb_height_label.setAlignment(Qt.AlignRight)
        #スペース入力のスピンボックス
        self.sb_space = QSpinBox(self)
        self.sb_space.setFocusPolicy(Qt.NoFocus)
        self.sb_space.move(70, 390)
        self.sb_space.resize(40,18)
        self.sb_space.setMinimum(1)
        self.sb_space.valueChanged.connect(self.valueToCanvas)
        self.sb_space_label = QLabel(self)
        self.sb_space_label.move(10, 390)
        self.sb_space_label.resize(50,18)
        self.sb_space_label.setText("スペース:")
        self.sb_space_label.setAlignment(Qt.AlignRight)

        #ボタングループの設定
        self.bg_paint = QButtonGroup()
        self.bg_image1 = QButtonGroup()
        #ペイントツール用のトグルボタン
        self.dt_paint = [
            (0, 0, 0, "Pen", "Icon_Pen.png"),
            (0, 1, 1, "Line", "Icon_Line.png"),
            (0, 2, 2, "RectLine", "Icon_RectLine.png"),
            (0, 3, 3, "RectFill", "Icon_RectFill.png"),
            (0, 4, 4, "Dropper", "Icon_Dropper.png"),
            (0, 5, 5, "PaintFill", "Icon_PaintFill.png"),
            (0, 6, 6, "Lattice", "Icon_Lattice.png"),
        ]
        for x, y, id, _, iconImg in self.dt_paint:
            qpix = QPixmap.fromImage(iconImg)
            icon = QIcon(qpix)
            btn = QPushButton(icon, "", self)
            btn.move(40*x + 60, 40*y + 60)
            btn.resize(40, 40)
            btn.setIconSize(qpix.size())
            btn.setCheckable(True)
            if id == 0:
                btn.setChecked(True)
            self.bg_paint.addButton(btn, id)
        self.bg_paint.setExclusive(True)  # 排他的なボタン処理にする
        self.bg_paint.buttonClicked.connect(self.changedButton)
        #編集ボタン
        self.dt_image1 = [
            (0, 0, 0, "ClearAll", "Icon_Clear.png"),
            (1, 0, 1, "Previous", "Icon_Previous.png"),
            (2, 0, 2, "Next", "Icon_Next.png"),
        ]
        for x, y, id, _, iconImg in self.dt_image1:
            qpix = QPixmap.fromImage(iconImg)
            icon = QIcon(qpix)
            btn = QPushButton(icon, "", self)
            btn.move(40*x + self.canvas.offsetx_image1, 40*y + 10)
            btn.resize(40, 40)
            btn.setIconSize(qpix.size())
            self.bg_image1.addButton(btn, id)
        self.bg_image1.buttonClicked.connect(self.pushedButton_image1)

    def changedButton(self):
        id = self.bg_paint.checkedId()
        self.canvas.drawMode = self.dt_paint[id][3]

    def pushedButton_image1(self, obj):
        id = self.bg_image1.id(obj)
        processMode = self.dt_image1[id][3]
        self.pushedButton(processMode, self.canvas.image1, self.canvas.back1, self.canvas.next1)

    def pushedButton(self, processMode, image, queBack, queNext):
        if processMode == "ClearAll":
            queBack.append(self.canvas.resizeImage(image, image.size()))
            if id(image) == id(self.canvas.image1):
                image.fill(255)
        elif processMode == "Previous":
            if queBack:
                back_ = queBack.pop()
                queNext.append(self.canvas.resizeImage(image, image.size()))
                image.swap(back_)
                self.update()
        elif processMode == "Next":
            if queNext:
                next_ = queNext.pop()
                queBack.append(self.canvas.resizeImage(image, image.size()))
                image.swap(next_)
                self.update()

    def gridControl(self):
        if self.cb_image1.checkState() == Qt.Checked:
            self.canvas.gridOn(self.canvas.image1_grid)
        elif self.cb_image1.checkState() == Qt.Unchecked:
            self.canvas.gridOff(self.canvas.image1_grid)
        self.canvas.update()

    def openFile(self):
        path = QDir.currentPath()
        fileName, _ = QFileDialog.getOpenFileName(self, "Open File", path)
        if fileName:
            self.canvas.openImage(fileName, self.canvas.image1)

    def saveFile(self):
        path = QDir.currentPath()
        fileName, _ = QFileDialog.getSaveFileName(self, "Save as", path)
        if fileName:
            if not fileName.endswith(".png"):
                fileName = fileName + ".png"
            return self.canvas.saveImage(fileName, self.canvas.image1)
        return False

    def DrawLineMode(self):
        self.canvas.drawMode = "Line"

    def valueToCanvas(self):
        self.canvas.LatticeWidth = self.sb_width.value()
        self.canvas.LatticeHeight = self.sb_height.value()
        self.canvas.LatticeSpace = self.sb_space.value()

    def keyPressEvent(self, event):
        if event.isAutoRepeat():
            return
        pressed = event.key()
        if pressed == Qt.Key_Shift:
            self.canvas.shiftKey = True

    def keyReleaseEvent(self, event):
        pressed = event.key()
        if pressed == Qt.Key_Shift:
            self.canvas.shiftKey = False

class Canvas(QWidget):
    def __init__(self, parent=None):
        super(Canvas, self).__init__(parent)

        self.imgMag = 16                                    #画像の拡大倍率
        self.canvasSize = 512                               #画像領域の大きさ
        self.imgSize = int(self.canvasSize/self.imgMag)     #画像の実際の画像サイズ
        self.divColor = 17                                  #色の分割数
        self.paletteMag = 16                                #パレットの拡大倍率

        self.myPenWidth = 1
        self.myPenColor = QColor(0,0,0)
        self.image1 = QImage()
        self.guide_image1 = QImage()
        self.image1_grid = QImage(self.canvasSize, self.canvasSize, QImage.Format_ARGB32)
        self.palette = QImage(1, self.divColor, QImage.Format_Grayscale8)
        self.Frame = QImage()
        self.shiftKey = False
        self.pix = QPoint(0,0)

        self.check1 = False
        self.check_palette = False

        self.back1 = deque(maxlen = 10)
        self.next1 = deque(maxlen = 10)

        #ウィンドウ左上からのキャンバスの位置
        self.offsetx_image1 = 150
        self.offsety_image1 = 60
        #ウィンドウ左上からのパレットの位置
        self.offsetx_palette = 120
        self.offsety_palette = 300

        #画像1の領域
        self.rect1_place = QRect(self.offsetx_image1, self.offsety_image1, self.canvasSize, self.canvasSize)
        #パレットの領域
        self.rect_palette_place = QRect(self.offsetx_palette, self.offsety_palette, self.paletteMag, self.paletteMag*(self.divColor))

        self.setGrid(self.image1_grid)
        self.setPalette(self.palette)

        self.drawMode = "Pen"
        self.LatticeWidth = 1
        self.LatticeHeight = 1
        self.LatticeSpace = 1

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.lastPos = event.position().toPoint()
            if self.rect1_place.contains(self.lastPos.x(), self.lastPos.y()):
                self.back1.append(self.resizeImage(self.image1, self.image1.size()))
                self.check1 = True
            elif self.rect_palette_place.contains(self.lastPos.x(), self.lastPos.y()):
                self.check_palette = True

    def mouseMoveEvent(self, event):
        if event.buttons() and Qt.LeftButton:
            if self.check1:
                if self.check1:
                    image = self.image1
                    guide_image = self.guide_image1
                    rect = self.rect1_place
                guide_image.fill(qRgba(255, 255, 255,0))
                if self.drawMode == "Pen":
                    self.drawLine(event.position().toPoint(), image, rect)
                elif self.drawMode == "Line":
                    self.drawLine(event.position().toPoint(), guide_image, rect)
                elif self.drawMode == "RectLine":
                    self.drawRect(event.position().toPoint(), guide_image, rect, False)
                elif self.drawMode == "RectFill":
                    self.drawRect(event.position().toPoint(), guide_image, rect, True)
                elif self.drawMode == "Lattice":
                    self.drawLattice(event.position().toPoint(), guide_image, rect)

    def mouseReleaseEvent(self, event):
        if event.button() == Qt.LeftButton:
            if self.check_palette:
                if self.rect_palette_place.contains(event.position().toPoint().x(), event.position().toPoint().y()):
                    self.pix = (event.position().toPoint() - QPoint(self.rect_palette_place.topLeft()) - QPoint(self.paletteMag, self.paletteMag)/2)/self.paletteMag
                    self.myPenColor = self.palette.pixelColor(self.pix)
                    self.drawFrame(self.pix, self.Frame)
            elif self.check1:   
                if self.check1:
                    image = self.image1
                    rect = self.rect1_place
                    self.guide_image1.fill(qRgba(255, 255, 255,0))
                if self.drawMode == "Pen" or self.drawMode == "Line":
                    self.drawLine(event.position().toPoint(), image, rect)
                elif self.drawMode == "RectLine":
                    self.drawRect(event.position().toPoint(), image, rect, False)
                elif self.drawMode == "RectFill":
                    self.drawRect(event.position().toPoint(), image, rect, True)
                elif self.drawMode == "Dropper":
                    self.pickColor(event.position().toPoint(), image, rect)
                elif self.drawMode == "PaintFill":
                    self.paintFill(event.position().toPoint(), image, rect)
                elif self.drawMode == "Lattice":
                    self.drawLattice(event.position().toPoint(), image, rect)
            self.check1 = False
            self.check_palette = False

    #ペンツール、線ツール
    def drawLine(self, endPos, image, rect):
        painter = QPainter(image)
        if id(image) == id(self.guide_image1):
            painter.setPen(
                QPen(QColor(0,0,255,191), self.myPenWidth, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
            )
        else:
            painter.setPen(
                QPen(self.myPenColor, self.myPenWidth, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
            )
        v1 = (self.lastPos-rect.topLeft()-QPoint(self.imgMag, self.imgMag)/2)/self.imgMag
        v2 = (endPos-rect.topLeft()-QPoint(self.imgMag, self.imgMag)/2)/self.imgMag
        #Shiftキーを押している時は線を水平・垂直・45°方向に限定する
        if self.shiftKey:
            if abs((v2 - v1).x()) > abs((v2 - v1).y()) * 1.5:
                v2.setY(v1.y())
            elif abs((v2 - v1).y()) > abs((v2 - v1).x()) * 1.5:
                v2.setX(v1.x())
            else:
                if v2.x() > v1.x():
                    if v2.y() > v1.y():                     #右下方向
                        v2.setY(v1.y()+(v2.x()-v1.x()))
                    else:                                   #右上方向
                        v2.setY(v1.y()-(v2.x()-v1.x()))
                else:
                    if v2.y() > v1.y():                     #左下方向
                        v2.setY(v1.y()-(v2.x()-v1.x()))
                    else:                                   #右下方向
                        v2.setY(v1.y()+(v2.x()-v1.x()))
        painter.drawLine(v1, v2 )
        self.update()
        if self.drawMode == "Pen":
            self.lastPos = QPoint(endPos)

    #四角形ツール(fillがTrueで塗りつぶし有り、Falseで塗りつぶし無し)
    def drawRect(self, endPos, image, rect, fill):
        painter = QPainter(image)
        if id(image) == id(self.guide_image1):
            penColor = QColor(0,0,255,191)
        else:
            penColor = self.myPenColor
        painter.setPen(
            QPen(penColor, self.myPenWidth, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
        )
        v1 = (self.lastPos-rect.topLeft()-QPoint(self.imgMag, self.imgMag)/2)/self.imgMag
        v2 = (endPos-rect.topLeft()-QPoint(self.imgMag, self.imgMag)/2)/self.imgMag
        #Shiftキーを押している時は正方形にする
        if self.shiftKey:
            if v2.x() > v1.x():
                if v2.y() > v1.y():                     #右下方向
                    v2.setY(v1.y()+(v2.x()-v1.x()))
                else:                                   #右上方向
                    v2.setY(v1.y()-(v2.x()-v1.x()))
            else:
                if v2.y() > v1.y():                     #左下方向
                    v2.setY(v1.y()-(v2.x()-v1.x()))
                else:                                   #右舌方向
                    v2.setY(v1.y()+(v2.x()-v1.x()))
        rect = QRect(v1, v2)
        if fill:
            painter.fillRect(rect, penColor)
        else:
            painter.drawRect(v1.x(), v1.y(),rect.width()-1, rect.height()-1)
        self.update()
        if self.drawMode == "Pen":
            self.lastPos = QPoint(endPos)

    #スポイトツール
    def pickColor(self, endPos, image, rect):
        v = (endPos-rect.topLeft()-QPoint(self.imgMag, self.imgMag)/2)/self.imgMag
        self.myPenColor = image.pixelColor(v)
        n = image.pixelColor(v).red() / (self.divColor - 1)
        self.pix = QPoint(0, n)
        self.drawFrame(self.pix, self.Frame)

    #塗りつぶしツール
    def paintFill(self, endPos, image, rect):
        v = (endPos-rect.topLeft()-QPoint(self.imgMag, self.imgMag)/2)/self.imgMag
        fillColor = self.myPenColor.getRgb()
        cvImage = qimage_to_cv(image)
        _, fillImage, _, _ = cv2.floodFill(cvImage, None, (v.x(), v.y()), fillColor[0:3], flags = 4)
        image.swap(cv_to_pixmap(fillImage))

    #格子ツール
    def drawLattice(self, endPos, image, rect):
        painter = QPainter(image)
        if image == self.guide_image1:
            penColor = QColor(0,0,255,191)
        else:
            penColor = self.myPenColor
        painter.setPen(
            QPen(penColor, self.myPenWidth, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
        )
        v1 = (self.lastPos-rect.topLeft()-QPoint(self.imgMag, self.imgMag)/2)/self.imgMag
        v2 = (endPos-rect.topLeft()-QPoint(self.imgMag, self.imgMag)/2)/self.imgMag
        width = self.LatticeWidth
        height = self.LatticeHeight
        space = self.LatticeSpace
        y = abs((v2-v1).y()) + 1
        x = abs((v2-v1).x()) + 1
        for j in range(y):
            if (v2 - v1).y() >= 0:
                dj = j
            else:
                dj = -j
            if j % (height + space) < height:
                for i in range(x):
                    if (v2 - v1).x() >= 0:
                        di = i
                    else:
                        di = -i
                    if i % (width + space) < width:
                        painter.drawLine(v1.x() + di, v1.y() + dj, v1.x() + di, v1.y() + dj)
        self.update()

    #グリッドの描画
    def setGrid(self, image):
        image.fill(qRgba(255, 255, 255,0))
        for i in range(0, self.canvasSize + 1, self.imgMag):
            for j in range(0, self.canvasSize):
                if not i == self.canvasSize:
                    image.setPixelColor(i, j, QColor(193,193,225,255))
                    image.setPixelColor(j, i, QColor(193,193,225,255))
                if not i == 0:
                    image.setPixelColor(i-1, j, QColor(193,193,225,255))
                    image.setPixelColor(j, i-1, QColor(193,193,225,255))
        #中心の線を赤色にする
        for j in range(0, self.canvasSize):
            image.setPixelColor(self.canvasSize/2, j, QColor(255,127,127,255))
            image.setPixelColor(j, self.canvasSize/2, QColor(255,127,127,255))
            image.setPixelColor(self.canvasSize/2-1, j, QColor(255,127,127,255))
            image.setPixelColor(j, self.canvasSize/2-1, QColor(255,127,127,255))

    #パレットの描画
    def setPalette(self, image):
        for i in range(self.divColor-1):
            gray = i*256/(self.divColor - 1)
            image.setPixelColor(QPoint(0,i), QColor(gray,gray,gray))
        else:
            i += 1
            image.setPixelColor(QPoint(0,i), QColor(255,255,255))

    #色選択枠の描画
    def drawFrame(self, pix, image):
        painter = QPainter(image)
        image.fill(qRgba(255, 255, 255,0))
        painter.setPen(
            QPen(QColor(255,0,0,255), 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
        )
        topleft = pix * self.paletteMag
        rect = QRect(topleft, topleft + QPoint(self.paletteMag-1, self.paletteMag-1))
        painter.drawRect(rect)
        self.update()

    def paintEvent(self, event):
        #キャンバスの描画
        painter1 = QPainter(self)
        rect1_size = QRect(0, 0, self.imgSize, self.imgSize)
        painter1.drawImage(self.rect1_place, self.image1, rect1_size)
        painter1.drawImage(self.rect1_place, self.guide_image1, rect1_size)
        #グリッドの描画
        rect_grid = QRect(0, 0, self.canvasSize, self.canvasSize)
        painter1_grid = QPainter(self)
        painter1_grid.drawImage(self.rect1_place, self.image1_grid, rect_grid)
        #パレットの描画
        painter_palette = QPainter(self)
        rect_palette_size = QRect(0, 0, 1, self.divColor)
        painter_palette.drawImage(self.rect_palette_place, self.palette, rect_palette_size)
        #パレットの選択枠の描画
        painter_Frame = QPainter(self)
        rect_Frame = QRect(0, 0, self.paletteMag, self.paletteMag*(self.divColor))
        self.drawFrame(self.pix, self.Frame)
        painter_Frame.drawImage(self.rect_palette_place, self.Frame, rect_Frame)

    #ウィンドウサイズを固定している場合、ウィンドを開いたときだけ動作する処理
    def resizeEvent(self, event):
        if self.image1.width() < self.width() or self.image1.height() < self.height():
            changeWidth = max(self.width(), self.image1.width())
            changeHeight = max(self.height(), self.image1.height())
            self.image1 = self.resizeImage(self.image1, QSize(changeWidth, changeHeight))
            self.guide_image1 = self.resizeimage_grid(self.guide_image1, QSize(changeWidth, changeHeight))
            self.image1_grid = self.resizeimage_grid(self.image1_grid, QSize(changeWidth, changeHeight))
            self.update()
        if self.palette.width() < self.width() or self.palette.height() < self.height():
            changeWidth = max(self.width(), self.palette.width())
            changeHeight = max(self.height(), self.palette.height())
            self.palette = self.resizeImage(self.palette, QSize(changeWidth, changeHeight))
            self.update()
        if self.Frame.width() < self.width() or self.Frame.height() < self.height():
            changeWidth = max(self.width(), self.Frame.width())
            changeHeight = max(self.height(), self.Frame.height())
            self.Frame = self.resizeimage_grid(self.Frame, QSize(changeWidth, changeHeight))
            self.update()

    def resizeImage(self, image, newSize):
        changeImage = QImage(QSize(self.imgSize, self.imgSize), QImage.Format_Grayscale8)
        changeImage.fill(255)
        painter = QPainter(changeImage)
        painter.drawImage(QPoint(0, 0), image)
        return changeImage

    def resizeimage_grid(self, image, newSize):
        changeImage = QImage(newSize, QImage.Format_ARGB32)
        changeImage.fill(qRgba(255, 255, 255,0))
        painter = QPainter(changeImage)
        painter.drawImage(QPoint(0, 0), image)
        return changeImage

    def gridOn(self, image):
        self.setGrid(self.image1_grid)

    def gridOff(self, image):
        image.fill(qRgba(255, 255, 255,0))

    def openImage(self, filename, image):
        if not image.load(filename):
            return False
        self.update()
        return True

    def saveImage(self, filename, image):
        if image.save(filename, "PNG"):
            return True
        else:
            return False

#QImageをndarrayに変換
def qimage_to_cv(qimage):
    w, h, d = qimage.size().width(), qimage.size().height(), qimage.depth()
    arr = np.array(qimage.bits()).reshape((h, w, d // 8))
    h, w = arr.shape[:2]
    arrImg = cv2.resize(arr, (w, h), interpolation=cv2.INTER_NEAREST)   #正常な画像の配列にするために必要な変換
    return arrImg

#ndarrayをQImageに変換
def cv_to_pixmap(cv_image):
    height, width = cv_image.shape[:2]
    bytesPerLine = cv_image.strides[0]
    image = QImage(cv_image.data, width, height, bytesPerLine, QImage.Format_Grayscale8).copy()
    return image

def main():
    app = QApplication(sys.argv)
    main_window = MainWindow()
    main_window.show()
    sys.exit(app.exec())

if __name__ == "__main__":
    main()

前回の記事で@shiracamusさんよりソースコードに対してアドバイスをいただきましたので、今回のコードで反映させました。
このコードを実行するためには、前回使用したIcon_Clear.pngの他に、以下のアイコン用画像ファイルをソースコードと同じフォルダに置く必要があります。

ファイル名 画像
Icon_Pen.png Icon_Pen.png
Icon_Line.png Icon_Line.png
Icon_RectLine.png Icon_RectLine.png
Icon_RectFill.png Icon_RectFill.png
Icon_Dropper.png Icon_Dropper.png
Icon_PaintFill.png Icon_PaintFill.png
Icon_Lattice.png Icon_Lattice.png
Icon_Next.png Icon_Next.png
Icon_Previous.png Icon_Previous.png

実行結果

このコードを実行すると、下の画像のウィンドウが開きます。
スクリーンショット 2021-08-07 22.49.51.png
前回作成したドット絵エディタに比べて、見た目が派手になりました。

解説

追加するツール/機能の実装方法を解説します。

パレット

パレットはキャンバスやグリッドと同じ様にQImageを用意し、ピクセルに色を並べ、QPainter.drawImage関数を使用して描画することで表示できます。セルから色を取得するにはQImage.pixelColor関数を使用します。
このドット絵エディタではグレースケールのみを使用していますが、カラーに対応させることもできます。その場合は、

ツールボタン

ボタンを複数並べて常に1つだけを有効にするときはQButtonGroupを利用します。QButtonGroupの使い方はこちらのサイトの記事を参考にしました。まずQPushButtonを用意します。QPushButtonはデフォルトではクリックした時だけ動作しますが、setCheckableTrueにすることでON/OFF切り替え動作するトグルスイッチとして利用することができます。QPushButtonオブジェクトをQButtonGroupクラスのオブジェクトにaddButtonで追加することでグループ化することができます。QButtonGroupオブジェクトをsetExclusive(True)に設定することで常に1つだけがONになる排他的な処理を設定することができます。

線ツール

線ツールはペンツールでも使用したQPainter.drawLine関数を使用します。QPainter.drawLine関数は始点と終点をしてして線を描く関数です。ペンツールではmouseMoveEventで描画→始点の更新を繰り返していましたが、線ツールではmouseMoveEventを無視してmousePressEventmouseReleaseEventのみを使用することで線が描けます。

四角形ツール

四角形の線はQPainter.drawRect関数で、塗りつぶした四角形はQPainter.fillRect関数でそれぞれ描画することができます。注意点として、この2つはどちらもQRect型の引数で四角形を指定しますが、同じQRectでも大きさが同じにならず、drawRectの方が1ピクセルずつ大きくなります。そのため、drawRectの方の縦横幅を1ピクセルずつ引いて指定しています。

Shiftキーを押した時の機能

線ツールと四角形ツールにはShiftキーを押している間は通常と異なる動作をする機能を実装しました。線ツールでは線の方向を水平・垂直または45°方向に限定し、四角形ツールでは正方形に限定します(Microsoft Officeや一般的なペイントツールによくある機能です)。キー押下はKeyPressEvent、キーを離した時はKeyReleaseEventによってそれぞれ判定することができます。注意点として、これらのイベントは対象のオブジェクトにフォーカスしていないと正しく動作しません。ウィンドウにスピンボックスやテキストボックス等のキー入力を行うオブジェクトを配置すると、デフォルトではそちらにフォーカスが移ってウィンドウでのキー判定ができなくなってしまいます(ウィンドウをクリックしただけではフォーカスされません)。そのため、このコードのようにこれらのオブジェクトでsetFocusPolicy(Qt.NoFocus)としてフォーカスできないようにするか、クリック時にフォーカスするようにメインウィンドウのマウスクリックイベントにsetFocus()を追加するなどする必要があります。

スポイトツール

スポイトツールはパレットから色を取得するのと同じ方法で実装できます。

塗りつぶしツール

囲まれたピクセル領域の塗りつぶしにはOpenCVの機能を使用しました。PySide(PyQt)でOpenCVの機能を使用するには、QImageからNumPyの画像配列へ相互に変換する機能が必須になります。この相互変換はこちらのサイトの記事を参考にして実装しました。ただし、グレースケール画像のためかそのままのコードではうまく動かなかったため手を加えています。
QImage → NumPyの変換(qimage_to_cv)では配列の形がおかしくなり、OpenCVでの操作が正常にできませんでした。理由はよくわかりませんが、OpenCVのresize (cv2.resize)で等倍変換を行うことで正常な形式に変換することができました。NumPy → QImageの変換(cv_to_pixmap)ではQImageに余計な情報が入ってしまいました。これに対しては変換QImageをcopyすることで回避できるようです。
OpenCVでの塗り潰しにはfloodFill関数を使用しました。floodFIll関数の使い方はこちらのサイトの記事が参考になります。

格子ツール

通常のペイント系ソフトには無いような独自のツールですが、実装は単純でQPainter.drawLineと繰り返し処理を組み合わせた作りになっています。格子の形を決める横・縦・スペースの数値入力にはスピンボックス(QSpinBox)を利用しています。

ガイド

線ツール、四角形ツール、格子ツールにはマウスをドラッグしている間に描画前のガイドを表示する機能をつけています。この機能のは、キャンバスと同じサイズのQImageをキャンバスとグリッドの間に配置し、普段は透明にしておいてドラッグ中だけ描画するセルに色を付けて表示させることで実装できます。実際の描画はマウスボタンを離した時(mouseReleaseEvent)に行いましたが、ガイドの描画は同じ処理をmouseMoveEventで行います。

取り消し/やり直し

描画を取り消したりやり直したりする機能の実装はcollectionsライブラリのdeque型を利用します。この機能は前回から参照している@pto8913さんの記事のペイントプログラムでも実装されており、それをほぼそのまま利用しています。deque型はリストと似ていますが、先頭と末尾のデータを操作しやすいように作られています。リストと同様に、append()で末尾へデータの追加ができますが、maxlenを指定しているとそれ以上のデータがappendされたときに先頭のデータが削除されます。pop()で末尾のデータを取り出すことができます。

次回予告

シンプルなドット絵エディタに様々なツールを追加することができました。
次回はこのエディタに高速フーリエ変換/逆変換する機能を追加します。

参考

PyQt6公式ドキュメント

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3