PySimpleGUI (Python GUI) とOpenCVでジグソーパズル作成

Python

はじめに

PySimpleGUIとOpenCVを使用してジグソーパズルを作成しました。

ダウンロードと実行方法

ダウンロード

ソースは、https://github.com/tostos5963/PySimpleGUI-JigsawPuzzle で公開していますのでダウンロードしてください。

Windowsの場合

pip install pysimplegui
python JigsawPuzzle.py

Linux(ubuntu)の場合

pip3 install pysimplegui
python3 JigswaPuzzle.py

以下のエラーが出た場合

ModuleNotFoundError: No module named 'tkinter'

python3-tk をインストールしてください

sudo apt-get install python3-tk

各ピース用のマスク画像作成

処理を簡単にするためにピース境界は以下のパターンのみとしました。

上下左右の各辺のパターンは、フラット、凹、凸の3パターンがあるので3の4乗=81パターンですが、向かい合う2辺がフラットになることはないので64パターンの画像ファイルをimages\jigsaw に置いています。

ファイル名は、上右下左の順にパターンに従って、F(Flat)、C(conCave 凹)、V(conVex 凸)のいずれかをつけています。以下の場合 FVVC.pngとなります。(チェック模様は透過)

主なクラスの処理

class MaskImage():マスク画像作成

マスク画像作成する。

def __init__(self, resize_x, resize_y, scale_x, scale_y) -> None:
    self.keylist = ['CCCC', 'CCCF', 'CCCV', 'CCFC', 'CCFF', 'CCFV', 'CCVC', 'CCVF',
                    'CCVV', 'CFCC', 'CFCV', 'CFFC', 'CFFV', 'CFVC', 'CFVV', 'CVCC',
                    'CVCF', 'CVCV', 'CVFC', 'CVFF', 'CVFV', 'CVVC', 'CVVF', 'CVVV',
                    'FCCC', 'FCCF', 'FCCV', 'FCVC', 'FCVF', 'FCVV', 'FFCC', 'FFCV',
                    'FFVC', 'FFVV', 'FVCC', 'FVCF', 'FVCV', 'FVVC', 'FVVF', 'FVVV',
                    'VCCC', 'VCCF', 'VCCV', 'VCFC', 'VCFF', 'VCFV', 'VCVC', 'VCVF',
                    'VCVV', 'VFCC', 'VFCV', 'VFFC', 'VFFV', 'VFVC', 'VFVV', 'VVCC',
                    'VVCF', 'VVCV', 'VVFC', 'VVFF', 'VVFV', 'VVVC', 'VVVF', 'VVVV']
    self.mask_info_tbl = {}
    for key in self.keylist:
        p_fname = f'images/jigsaw/{key}.png'
        img_mask = cv2.imread(p_fname, cv2.IMREAD_COLOR)
        sx = int(img_mask.shape[1] * resize_x * scale_x)
        sy = int(img_mask.shape[0] * resize_y * scale_y)
        img_mask_small = cv2.resize(img_mask, dsize=(sx, sy))
        mask_h, mask_w, ch = img_mask_small.shape

        mask_gray = cv2.cvtColor(img_mask_small, cv2.COLOR_BGR2GRAY)
        mask_mono = cv2.threshold(mask_gray, 128, 255, cv2.THRESH_BINARY_INV)[1]
        mask_bin = cv2.threshold(mask_gray, 128, 255, cv2.THRESH_BINARY)[1]

        contours, hierarchy = cv2.findContours(mask_bin, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

        img_mask_mono = cv2.merge((mask_mono, mask_mono, mask_mono, mask_mono))
        img_mask_mono_not = cv2.bitwise_not(img_mask_mono)
        self.mask_info_tbl[key] = MaskImageInfo(img_mask_mono_not, contours, mask_w, mask_h)

ジグソーパズルの画像に合わせてリサイズ

リサイズした画像をグレースケールに変換

グレースケール画像を2値化、反転画像作成

2値化画像から輪郭検出。この輪郭はジグソーパズルのピースの輪郭を表示するときに使用する。(赤枠の画像は輪郭がわかるように作成したデバッグ用画像)

2値化画像をRGB+アルファチャンネル画像に変換。アルファチャンネルは透過度用のチャネル。

反転してマスク画像作成

class PieceNode(PieceObject):1ピース

ピース1つ(他のピースと接続されていないもの)の処理

def is_hit(self, pos)

ピース(透過部分を除く)がクリックされたか。

    def is_hit(self, pos):
        x, y = pos
        if self.x <= x < (self.x + self.img_piece.shape[1]):
            if self.y <= y < (self.y + self.img_piece.shape[0]):
                color = self.img_piece[(y - self.y, x - self.x)]
                if color[3] == 255:
                    return True
        return False

def has_imgid(self, imgid):

imgid (PySimpleGUI の DrawImage の戻り値)が一致するか。

    def has_imgid(self, imgid):
        if self.img_id == imgid:
            return True
        else:
            return False

def move(self, draw, dx, dy):

ピースをドラッグしたときに移動する。

    def move(self, draw, dx, dy):
        self.x += dx
        self.y += dy
        draw.MoveFigure(self.img_id, dx, dy)

def to_front(self, draw):

ピースを前面に移動する。

    def to_front(self, draw):
        draw.BringFigureToFront(self.img_id)

def adjacent_piece(self, piece):

ピースが接近したときに位置を自動調整する。

    def adjacent_piece(self, piece):
        if self.row == piece.row:
            if  (piece.y < (self.y - WITHIN_TOLERANCE)) or ((self.y + WITHIN_TOLERANCE) < piece.y):
                return (False, 0, 0)

            adj_y = self.y - (piece.last_y - self.last_y)

            if (self.col - 1) == piece.col:
                # [piece][self]
                adj_x = self.x - (self.last_x - piece.last_x)

                if (piece.x < (adj_x - WITHIN_TOLERANCE)) or ((adj_x + WITHIN_TOLERANCE) < piece.x):
                    return (False, 0, 0)
                else:
                    return (True, adj_x - piece.x, adj_y - piece.y)

            if (self.col + 1) == piece.col:
                # [self][piece]
                adj_x = self.x + (piece.last_x - self.last_x)

                if (piece.x < (adj_x - WITHIN_TOLERANCE)) or ((adj_x + WITHIN_TOLERANCE) < piece.x):
                    return (False, 0, 0)
                else:
                    return (True, adj_x - piece.x, adj_y - piece.y)

        if self.col == piece.col:
            if  (piece.x < (self.x - WITHIN_TOLERANCE)) or ((self.x + WITHIN_TOLERANCE) < piece.x):
                return (False, 0, 0)

            adj_x = self.x - (piece.last_x - self.last_x)

            if (self.row - 1) == piece.row:
                # [piece]
                # [self]
                adj_y = self.y - (self.last_y - piece.last_y)

                if (piece.y < (adj_y - WITHIN_TOLERANCE)) or ((adj_y + WITHIN_TOLERANCE) < piece.y):
                    return (False, 0, 0)
                else:
                    return (True, adj_x - piece.x, adj_y - piece.y)

            if (self.row + 1) == piece.row:
                # [self]
                # [piece]
                adj_y = self.y + (piece.last_y - self.last_y)

                if (piece.y < (adj_y - WITHIN_TOLERANCE)) or ((adj_y + WITHIN_TOLERANCE) < piece.y):
                    return (False, 0, 0)
                else:
                    return (True, adj_x - piece.x, adj_y - piece.y)

        return (False, 0, 0)

class PieceComposite(PieceObject)):複数ピース

接続されているピースを処理するクラス

def is_hit(self, pos):

ピース(透過部分を除く)がクリックされたか。

    def is_hit(self, pos):
        hit = False
        for piece in self.pieces:
            if piece.is_hit(pos):
                hit = True
                break
        return hit

def has_imgid(self, imgid):

imgid (PySimpleGUI の DrawImage の戻り値)が一致するか。

    def has_imgid(self, imgid):
        for piece in self.pieces:
            if piece.has_imgid(imgid):
                return True
        return False

def add(self, piece):

ピースを追加する。

    def add(self, piece):
        self.pieces.append(piece)

def move(self, draw, dx, dy):

ドラッグしたときに接続されているピースを全て移動する。

    def move(self, draw, dx, dy):
        for piece in self.pieces:
            piece.move(draw, dx, dy)

def to_front(self, draw):

接続されているピース全て前面に移動する。

    def to_front(self, draw):
        for piece in self.pieces:
            piece.to_front(draw)

def connect(self, draw, pieces2) -> bool:

ピースを接続する。

    def connect(self, draw, pieces2) -> bool:
        can_connect, dx, dy = (False, 0, 0)
        for p1 in self.pieces:
            for p2 in pieces2.pieces:
                can_connect, dx, dy = p1.adjacent_piece(p2)
                if can_connect:
                    break
            if can_connect:
                break

        if can_connect:
            for p2 in pieces2.pieces:
                p2.move(draw, dx, dy)
                self.add(p2)
            return True

        return False

class Jigsaw():ジグソーパズル

ジグソーパズル

def add(self, img_id, piece):

ピースを追加

    def add(self, img_id, piece):
        self.piece_tbl[img_id] = piece

def click_down(self, pos):

マウス押下時の処理。クリック位置にピースがあればドラッグ開始する。

    def click_down(self, pos):
        self.x1, self.y1 = pos
        if self.first_click:
            self.first_click = False
            self.x0, self.y0 = (self.x1, self.y1)
            ids = self.draw.GetFiguresAtLocation((self.x1, self.y1))
            is_hit = False
            if len(ids) >= 1:
                for imgid in reversed(ids):
                    is_hit = False
                    for id in self.piece_tbl:
                        if self.piece_tbl[id].has_imgid(imgid):
                            if self.piece_tbl[id].is_hit((self.x1, self.y1)):
                                self.drag_img_id = id
                                try:
                                    self.drag_img_ids.remove(id)
                                except Exception:
                                    pass
                                self.drag_img_ids.append(id)
                                self.piece_tbl[id].to_front(self.draw)
                                is_hit = True
                                break

                    if is_hit:
                        break
            if is_hit == False:
                self.drag_img_id = 0
        else:
            if self.drag_img_id in self.piece_tbl:
                dx, dy = self.x1 - self.x0, self.y1 - self.y0
                self.x0, self.y0 = (self.x1, self.y1)
                self.piece_tbl[self.drag_img_id].move(self.draw, dx, dy)

def click_up(self):

マウスボタンを離した時の処理。ドラッグ終了。接続可能なら接続する。

    def click_up(self):
        self.first_click = True
        if len(self.drag_img_ids) <= 0:
            return

        if len(self.drag_img_ids) >= 3:
            self.drag_img_ids.pop(0)

        if len(self.drag_img_ids) <= 1:
            img_id_prev = 0
            img_id_now = self.drag_img_ids[0]
        else:
            img_id_prev = self.drag_img_ids[0]
            img_id_now = self.drag_img_ids[1]

        if img_id_prev != 0 and img_id_prev != img_id_now:
            if self.piece_tbl[img_id_prev].connect(self.draw, self.piece_tbl[img_id_now]):
                del self.piece_tbl[img_id_now]
                self.drag_img_ids.remove(img_id_now)
                return

        max_pieces_img_id = 0
        num_max = 0
        for img_id in self.piece_tbl:
            if num_max < self.piece_tbl[img_id].num_pieces():
                max_pieces_img_id = img_id
                num_max = self.piece_tbl[img_id].num_pieces()

        if max_pieces_img_id != 0 and max_pieces_img_id != img_id_now:
            if self.piece_tbl[max_pieces_img_id].connect(self.draw, self.piece_tbl[img_id_now]):
                del self.piece_tbl[img_id_now]
                self.drag_img_ids.remove(img_id_now)

class Board():メイン

def click_down(self, pos):

マウス押下時の処理。

    def click_down(self, pos):
        if self.jigsaw != None:
            self.jigsaw.click_down(pos)

def click_up(self):

マウスボタンを離した時の処理。完成していたら「complete」を表示する。

    def click_up(self):
        if self.jigsaw != None:
            self.jigsaw.click_up()
            if self.is_complte != True:
                if self.jigsaw.num_blocks() <= 1:
                    self.is_complte = True
                    sg.popup('complete!', keep_on_top=True)

def onclick_start(self):

スタートボタン押下時の処理。

選択された画像をウィンドウサイズに合わせてリサイズします。

        try:
            org_img = cv2.imread(img_file)
            img_pic_h, img_pic_w, _  = org_img.shape

            sx = float(self.disp_image_width / img_pic_w)
            sy = float(self.disp_image_height / img_pic_h)
            if sx < sy:
                self.img_pic = cv2.resize(org_img, dsize=None, fx=sx, fy=sx)
            else:
                self.img_pic = cv2.resize(org_img, dsize=None, fx=sy, fy=sy)

            img_pic_h, img_pic_w, _  = self.img_pic.shape

            resize_x = img_pic_w / float(IMAGE_WIDTH)
            resize_y = img_pic_h / float(IMAGE_HEIGHT)
        except Exception as err:
            print(err)
            return

選択されたピース数により縦横の数を決定します。

        if num_pieces == 24:
            num_cols = 6
            num_rows = 4

        elif num_pieces == 60:
            num_cols = 10
            num_rows = 6

        elif num_pieces == 144:
            num_cols = 16
            num_rows = 9

        elif num_pieces == 240:
            num_cols = 20
            num_rows = 12

各ピースの座標を求めます。

        x_step = int(IMAGE_WIDTH / num_cols)
        x_1st = int(x_step * 0.722)
        x_list = [0, x_1st]
        for x in range(2, num_cols):
            x_list.append(x_1st + x_step * (x - 1))

        y_step = int(IMAGE_HEIGHT / num_rows)
        y_1st = int(y_step * 0.722)
        y_list = [0, y_1st]
        for y in range(2, num_rows):
            y_list.append(y_1st + y_step * (y - 1))

各ピースの上下左右の凹凸をランダムに決定し、マスク画像を使用して各ピース画像を作成します。

        u_list = {}     # UP
        r_list = {}     # RIGHT
        d_list = {}     # DOWN
        l_list = {}     # LEFT
        for row in range(num_rows):
            for col in range(num_cols):
                # F: Flat,  C: conCave 凹  V: conVex  凸
                rval = random.randint(1, 100) % 2
                if rval == 0:
                    d_list[(row, col)] = 'C'
                else:
                    d_list[(row, col)] = 'V'

                if row == 0:
                    u_list[(row, col)] = 'F'
                else:
                    if d_list[(row - 1, col)] == 'C':
                        u_list[(row, col)] = 'V'
                    else:
                        u_list[(row, col)] = 'C'
                    if row == (num_rows - 1):
                        d_list[(row, col)] = 'F'

                rval = random.randint(1, 100) % 2
                if rval == 0:
                    r_list[(row, col)] = 'C'
                else:
                    r_list[(row, col)] = 'V'

                if col == 0:
                    l_list[(row, col)] = 'F'
                else:
                    if r_list[(row, col - 1)] == 'C':
                        l_list[(row, col)] = 'V'
                    else:
                        l_list[(row, col)] = 'C'
                    if col == (num_cols - 1):
                        r_list[(row, col)] = 'F'

                mask_key = u_list[(row, col)] + r_list[(row, col)] + d_list[(row, col)] + l_list[(row, col)]
                mask_info = self.mask.mask_info_tbl[mask_key]

                img_mask_mono_not = mask_info.img
                mask_w = mask_info.w
                mask_h = mask_info.h

                x = int(x_list[col] * resize_x)
                y = int(y_list[row] * resize_y)
                img_crop = self.img_pic[y:y+mask_h, x:x+mask_w]
                img_contours = img_crop.copy()
                cv2.drawContours(img_contours, mask_info.contours, -1, color=(255, 255, 255, 255), thickness=1)

                img_crop_mask = cv2.bitwise_and(img_contours, img_mask_mono_not)

                id_img = self.draw.DrawImage(data=cvimg2pngimg(img_crop_mask), location=(x, y))
                self.jigsaw.add(id_img, PieceComposite(PieceNode(img_crop_mask, id_img, x, y, row, col)))

                move_x = random.randint(10, self.graph_w - int(mask_w * 1.2))
                move_y = random.randint(10, self.graph_h - int(mask_h * 1.2))
                self.jigsaw.piece_tbl[id_img].move(self.draw, move_x - x, move_y - y)

矩形抜き出し

輪郭表示

ピース抜き出し

コメント

タイトルとURLをコピーしました