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

#はじめに
[前回の記事](https://qiita.com/Hisan_twi/items/8534e7e104d60f6ac86a)で作成したドット絵エディタにツールや機能を追加して使いやすいドット絵エディタにしていきます。
追加するツール・機能は以下の通りです。

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

#環境

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

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

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

```python: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484445/36e0ec7b-bc45-eaa5-5b06-f2ee1080f889.png)   |
|Icon_Line.png   |![Icon_Line.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484445/03453c9f-16ba-4a28-d336-22f82c01706e.png)   |
|Icon_RectLine.png  |![Icon_RectLine.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484445/d147fedb-e0cb-6817-ea24-e37fb3ef3bdc.png)   |
|Icon_RectFill.png   |![Icon_RectFill.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484445/5883662f-dd11-8e32-95ab-cf22ebb9277e.png)   |
|Icon_Dropper.png   |![Icon_Dropper.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484445/2dd086d6-bdb8-4a7e-6244-b5ab1d6c6d34.png)   |
|Icon_PaintFill.png   |![Icon_PaintFill.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484445/7d6fb65a-8870-db66-4c10-a939a2f8b238.png)   |
|Icon_Lattice.png   |![Icon_Lattice.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484445/3bb018c5-b1fe-a43f-20f1-a7728cd2ec32.png)   |
|Icon_Next.png   |![Icon_Next.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484445/1a4a9c43-2529-6365-bb09-7f3d6067f731.png)   |
|Icon_Previous.png   |![Icon_Previous.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484445/cde70e09-9280-6346-1374-9582bf705799.png)   |

###実行結果
このコードを実行すると、下の画像のウィンドウが開きます。
![スクリーンショット 2021-08-07 22.49.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484445/4bddd664-102d-4327-cb60-43c311dd3ef1.png)
前回作成したドット絵エディタに比べて、見た目が派手になりました。

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

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

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

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

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

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

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

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

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

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

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

#次回予告
シンプルなドット絵エディタに様々なツールを追加することができました。
[次回](https://qiita.com/Hisan_twi/items/219eb88c535f9a6171bd)はこのエディタに高速フーリエ変換/逆変換する機能を追加します。

#参考
PyQt6公式ドキュメント

- QPainter: https://doc.qt.io/qtforpython/PySide6/QtGui/QPainter.html
- QImage: https://doc.qt.io/qtforpython/PySide6/QtGui/QImage.html
- QPushButton: https://doc.qt.io/qtforpython/PySide6/QtWidgets/QPushButton.html