naritoブログ

このブログはDjangoとBootstrap4で作成されました
ソースコード

tkinterで、パズルを作る

プログラミング関連 Python Tkinter 約53日前
2017年8月25日19:58
Github:https://github.com/naritotakizawa/imgpuzzle

tkinterで、シンプルなパズルを作ります。
実行すると、まず以下のようなファイルダイアログが開きます。


画像ファイルを選ぶと、パズル開始です。


完成すると、褒められます。


ソースコード
"""パズルアプリ."""
import random
import tkinter as tk
import tkinter.ttk as ttk
from tkinter import filedialog
from tkinter import messagebox

from PIL import ImageTk, Image


class Puzzle(ttk.Frame):
    """3*3パズル."""

    def __init__(self, master, image):
        """初期化."""
        super().__init__(master)
        self.origin_image = image
        self.setup_images(image)
        self.update_gui()

    def setup_images(self, image):
        """パズル画像の準備処理."""
        # 画像を分割したリストをself.imagesとする
        self.images = self.split_image(image, split=3)

        # そのリストを細部までシャッフル
        self.shuffle(self.images)

        # パズルなので、一つ抜く
        self.images[0][0] = 0, 0, None

    def split_image(self, image, split=3):
        """画像を3*3等に分割し、リストに格納しかえす."""
        origin_width, origin_height = image.size
        split_width = origin_width / split
        split_height = origin_height / split
        images = [[None for x in range(split)] for y in range(split)]
        for row_index in range(split):
            for col_index in range(split):
                row = row_index * split_height
                col = col_index * split_width
                box = (col, row, col + split_width, row + split_height)
                img = image.crop(box)
                img_tk = ImageTk.PhotoImage(img, master=self)
                images[row_index][col_index] = row_index, col_index, img_tk
        return images

    def shuffle(self, lst):
        """リストをシャッフルする。2次元のリストもシャッフルできる."""
        if isinstance(lst, list):
            random.shuffle(lst)
            for l in lst:
                self.shuffle(l)

    def update_gui(self):
        """パズル画面の作成."""
        # gridで貼り付けたレイアウトを解除
        for widget in self.grid_slaves():
            widget.grid_forget()

        for row_index, row in enumerate(self.images):
            for col_index, data in enumerate(row):
                img_tk = data[2]
                if img_tk:
                    label = ttk.Label(
                        self, image=img_tk,
                        text='{0}{1}'.format(row_index, col_index)
                    )
                    label.grid(row=row_index, column=col_index)
        self.bind_all('<Button-1>', self.push)

    def check_space(self, row_index, col_index):
        """上下左右に空白エリアがあれば、空白エリアの座標を返す."""
        # 上と下に空白エリアはあるか?
        for r in [row_index - 1, row_index + 1]:
            try:
                data = self.images[r][col_index]
            except IndexError:
                continue
            else:
                if data[2] is None:
                    return r, col_index

        # 左と右に空白エリアはあるか?
        for c in [col_index - 1, col_index + 1]:
            try:
                data = self.images[row_index][c]
            except IndexError:
                continue
            else:
                if data[2] is None:
                    return row_index, c

        # 見つからなければNoneを返す
        return None

    def is_complete(self):
        """パズルが完成していればTrue."""
        for row_index, row in enumerate(self.images):
            for col_index, data in enumerate(row):
                origin_row_index, origin_col_index, img_tk = data
                if img_tk:
                    if origin_row_index != row_index or origin_col_index != col_index:
                        return False
        return True

    def finish(self):
        """終了画面の作成."""
        # gridで貼り付けたレイアウトを解除
        for widget in self.grid_slaves():
            widget.grid_forget()

        # イベントの解除
        self.unbind_all('<Button-1>')

        # 画面に元々の画像を表示する
        self.origin_image_tk = ImageTk.PhotoImage(
            self.origin_image, master=self)
        label = ttk.Label(self, image=self.origin_image_tk)
        label.grid(row=0, column=0)

        # 有り難いお言葉
        messagebox.showinfo(message='おめでとうございます、完成です')

    def push(self, event):
        """パズルを押した際に呼ばれる."""
        try:
            text = event.widget['text']
        # textがないウィジェットを押したら、TclError
        except tk.TclError:
            pass
        else:
            row_index, col_index = [int(x) for x in text]
            space = self.check_space(row_index, col_index)
            if space:
                # スペースの座標を取得
                space_row, space_col = space

                # スペース部分とクリックされたパズルを入れ替える
                clicked_data = self.images[row_index][col_index]
                space_data = self.images[space_row][space_col]
                self.images[row_index][col_index] = space_data
                self.images[space_row][space_col] = clicked_data

                # パズルが完成していれば
                if self.is_complete():
                    self.finish()
                else:
                    self.update_gui()

            # 上下左右にスペースのない部分をクリックしても、何もしない
            else:
                pass


def main():
    """実行用関数."""
    root = tk.Tk()
    root.title('パズル')
    filename = filedialog.askopenfilename()
    image = None

    # 画像を開こうと試みる
    try:
        image = Image.open(filename)

    # open()できなかった(画像以外のファイルを開いた)
    except OSError:
        messagebox.showerror(message='画像を開いてください')

    # キャンセルボタンを押した
    except AttributeError:
        pass

    # 画像ファイルをきちんと開いた
    else:
        app = Puzzle(root, image)
        app.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
        root.mainloop()

    # 画像を開いていたら閉じる
    finally:
        if image:
            image.close()


if __name__ == '__main__':
    main()




tkinterで様々な種類の画像を扱う場合は、pillowをインストールし、ImageTkを使うと捗ります。
ImageTk.PhotoImage()とすることで、tkinter標準のPhotoImageの代わりに使えます。
from PIL import ImageTk, Image


実行すると、filedialog.askopenfilename()でファイルを選択させるダイアログを開きます。
def main():
    """実行用関数."""
    root = tk.Tk()
    root.title('パズル')
    filename = filedialog.askopenfilename()


やり方は色々あるのですが、今回はtry〜except構文を使います。まず画像を開こうとして...
    # 画像を開こうと試みる
    try:
        image = Image.open(filename)


画像以外のファイルや、キャンセル押下時の処理を捕まえます。
    # open()できなかった(画像以外のファイルを開いた)
    except OSError:
        messagebox.showerror(message='画像を開いてください')

    # キャンセルボタンを押した
    except AttributeError:
        pass


このelseは、exceptに行かなかった場合に入ります。つまり、例外が特にない場合はelseに行くということです。
特に問題がなければ、実際にパズルアプリを実行します。
    # 画像ファイルをきちんと開いた
    else:
        app = Puzzle(root, image)
        app.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
        root.mainloop()


finallyでは、画像を開いていればcloseで閉じます。後始末処理ですね。
    # 画像を開いていたら閉じる
    finally:
        if image:
            image.close()



メインのPuzzleクラスです。
Image.open()で開いた画像を保存しておき、パズルのセットアップ処理を呼び、画面の更新をします。
class Puzzle(ttk.Frame):
    """3*3パズル."""

    def __init__(self, master, image):
        """初期化."""
        super().__init__(master)
        self.origin_image = image
        self.setup_images(image)
        self.update_gui()



これがパズルの準備処理です。
self.split_image(image, split=3)で、画像を3*3に分割しリストに格納します。
そのリストをシャッフルすることでパズル内の画像をランダムにし、self.images[0][0] = 0, 0, None とすることでシャッフルされた3*3の画像の、左上を空白にします。
    def setup_images(self, image):
        """パズル画像の準備処理."""
        # 画像を分割したリストをself.imagesとする
        self.images = self.split_image(image, split=3)

        # そのリストを細部までシャッフル
        self.shuffle(self.images)

        # パズルなので、一つ抜く
        self.images[0][0] = 0, 0, None


self.imagesには、まず以下のように格納されています。
0, 0 等の部分は、その画像の元々の座標で、画像が本来あるべき座標になります。
PIL.ImageTk.PhotoImageは、tkinterで使える形式になった画像オブジェクトです。
[
# 1行目
[(0, 0, <PIL.ImageTk.PhotoImage object at 0x7f8a14f6c278>),
  (0, 1, <PIL.ImageTk.PhotoImage object at 0x7f8a1a570cf8>),
  (0, 2, <PIL.ImageTk.PhotoImage object at 0x7f8a14f6c6d8>)],

# 2行目
 [(1, 0, <PIL.ImageTk.PhotoImage object at 0x7f8a14f6c7f0>),
  (1, 1, <PIL.ImageTk.PhotoImage object at 0x7f8a14f6ca58>),
  (1, 2, <PIL.ImageTk.PhotoImage object at 0x7f8a14f6cb38>)],

# 3行目
 [(2, 0, <PIL.ImageTk.PhotoImage object at 0x7f8a14f6cbe0>),
  (2, 1, <PIL.ImageTk.PhotoImage object at 0x7f8a14f6cc88>),
  (2, 2, <PIL.ImageTk.PhotoImage object at 0x7f8a14f6cd30>)]

]



これをシャッフルして、以下のようになります。
[
[(0, 2, <PIL.ImageTk.PhotoImage object at 0x7f44c434d6d8>),
  (0, 1, <PIL.ImageTk.PhotoImage object at 0x7f44c9951cf8>),
  (0, 0, <PIL.ImageTk.PhotoImage object at 0x7f44c434d278>)],

 [(2, 2, <PIL.ImageTk.PhotoImage object at 0x7f44c434dd30>),
  (2, 0, <PIL.ImageTk.PhotoImage object at 0x7f44c434dbe0>),
  (2, 1, <PIL.ImageTk.PhotoImage object at 0x7f44c434dc88>)],

 [(1, 2, <PIL.ImageTk.PhotoImage object at 0x7f44c434db38>),
  (1, 1, <PIL.ImageTk.PhotoImage object at 0x7f44c434da58>),
  (1, 0, <PIL.ImageTk.PhotoImage object at 0x7f44c434d7f0>)]
]



最後に、このリストの0,0にあたる左上をの画像オブジェクトをNoneにします。
Noneならば、その場所はスペースとして判断できます。
self.images[0][0] = 0, 0, None


シャッフル処理は以下です。2次元、3次元の配列でもシャッフルできますが、細かいシャッフルはできていません。
その代わりシンプルなつくりです。
    def shuffle(self, lst):
        """リストをシャッフルする。2次元のリストもシャッフルできる."""
        if isinstance(lst, list):
            random.shuffle(lst)
            for l in lst:
                self.shuffle(l)



これが画像の分割処理です。
    def split_image(self, image, split=3):
        """画像を3*3等に分割し、リストに格納しかえす."""
        origin_width, origin_height = image.size
        split_width = origin_width / split
        split_height = origin_height / split
        images = [[None for x in range(split)] for y in range(split)]
        for row_index in range(split):
            for col_index in range(split):
                row = row_index * split_height
                col = col_index * split_width
                box = (col, row, col + split_width, row + split_height)
                img = image.crop(box)
                img_tk = ImageTk.PhotoImage(img, master=self)
                images[row_index][col_index] = row_index, col_index, img_tk
        return images



画像を分割するだけなら、以下のようにできます。
上でやっているのは、以下のコードを少し拡張しただけです。
from PIL import Image

def split_image(image, split=3):
    """画像を3*3等に分割し、リストに格納しかえす."""
    origin_width, origin_height = image.size
    split_width = origin_width / split
    split_height = origin_height / split
    images = [[None for x in range(split)] for y in range(split)]
    for row_index in range(split):
        for col_index in range(split):
            row = row_index * split_height
            col = col_index * split_width
            box = (col, row, col + split_width, row + split_height)
            img = image.crop(box)
            images[row_index][col_index] = img
    return images


image = Image.open('sample.png')
images = split_image(image)
for row in images:
    for img in row:
        img.show()




次はupdate_guiメソッドです。これはself.imagesの内容を頼りに、パズルのレイアウトを作成します。
流れとしては、パズルが移動されるとself.imagesの要素も交換され、このメソッドが呼ばれることで再描画されます。
(全体を描画しなおすので、パフォーマンス的にはよろしくないです。今回はシンプルさをとりました)

labelのimage属性に、格納しているPhotoImageを指定すると画像が表示されるようになります。
また、textに現在その画像がある座標を貼っつけておきます。クリックすると、このテキストを取得することでクリック座標を調べるようにしています。
    def update_gui(self):
        """パズル画面の作成."""
        # gridで貼り付けたレイアウトを解除
        for widget in self.grid_slaves():
            widget.grid_forget()

        for row_index, row in enumerate(self.images):
            for col_index, data in enumerate(row):
                img_tk = data[2]
                if img_tk:  # パズルの空欄部分なら、何もしない
                    label = ttk.Label(
                        self, image=img_tk,
                        text='{0}{1}'.format(row_index, col_index)  # 01 のような座標が入る
                    )
                    label.grid(row=row_index, column=col_index)
        self.bind_all('<Button-1>', self.push)  # フレーム内の全てのウィジェットに、クリックでself.pushを呼ぶように


PhotoImageを扱う際の注意点があります。
以下のコードは、実行しても画像が表示されません。
import tkinter as tk
import tkinter.ttk as ttk

from PIL import ImageTk, Image


class ImageTest(ttk.Frame):

    def __init__(self, master):
        super().__init__(master)
        self.create_widgets()
    
    def create_widgets(self):
        img = ImageTk.PhotoImage(Image.open('sample.png'), master=self)
        label = ttk.Label(self, image=img)
        label.grid(row=0, column=0)


def main():
    root = tk.Tk()
    root.title('パズル')
    app = ImageTest(root)
    app.grid(column=0, row=0, sticky=(tk.N, tk.S, tk.E, tk.W))
    root.mainloop()

if __name__ == '__main__':
    main()



しかし、以下のように変更すると画像が表示されます...
        self.img = ImageTk.PhotoImage(Image.open('sample.png'), master=self)
        label = ttk.Label(self, image=self.img)


覚えておくこととして、PhotoImageへの参照をどこかに残しておく必要があります。さもなくばガーベジコレクションされます。
パズルアプリはself.imagesの中にPhotoImageへの参照が残っているので、ちゃんと動作しました。


パズルクリックで呼ばれるpushメソッドです。
ウィジェトtのtext属性で、まずは座標を調べます。
tk.TclErrorは、text属性のないウィジェットのクリック時...今回はスペース部分のクリックで呼ばれます。
    def push(self, event):
        """パズルを押した際に呼ばれる."""
        try:
            text = event.widget['text']
        # textがないウィジェットを押したら、TclError
        except tk.TclError:
            pass



スペース部分以外のパズルをクリックされたら、まずはクリック座標を数値に変換し、row_index, col_indexとして格納しておきます。
そして、上下左右のどこかにスペースがあるかをチェックします。(周りにスペースがないと、パズルは移動できませんね?)
        else:
            row_index, col_index = [int(x) for x in text]
            space = self.check_space(row_index, col_index)



そのスペースがあるかのチェックメソッドです。
スペースがあれば、座標をタプルで返します。なければNoneです。
    def check_space(self, row_index, col_index):
        """上下左右に空白エリアがあれば、空白エリアの座標を返す."""
        # 上と下に空白エリアはあるか?
        for r in [row_index - 1, row_index + 1]:
            try:
                data = self.images[r][col_index]
            except IndexError:
                continue
            else:
                if data[2] is None:
                    return r, col_index

        # 左と右に空白エリアはあるか?
        for c in [col_index - 1, col_index + 1]:
            try:
                data = self.images[row_index][c]
            except IndexError:
                continue
            else:
                if data[2] is None:
                    return row_index, c

        # 見つからなければNoneを返す
        return None



pushメソッドに戻ります。
周りにスペースがなければ、今回は処理終了でまたクリックを待ちます。
スペースがあればパズルを移動できるので、self.imagesのクリックされた要素とスペース部分の要素を交換します。
そして、パズルが完成しているかをチェックし、完成していればfinish()メソッドで終了画面を作成します。
            if space:
                # スペースの座標を取得
                space_row, space_col = space

                # スペース部分とクリックされたパズルを入れ替える
                clicked_data = self.images[row_index][col_index]
                space_data = self.images[space_row][space_col]
                self.images[row_index][col_index] = space_data
                self.images[space_row][space_col] = clicked_data

                # パズルが完成していれば
                if self.is_complete():
                    self.finish()
                else:
                    self.update_gui()

            # 上下左右にスペースのない部分をクリックしても、何もしない
            else:
                pass



self.imagesを作る際に、画像が本来あるべき座標も一緒に格納していました。ここでそれを使っています。
    def is_complete(self):
        """パズルが完成していればTrue."""
        for row_index, row in enumerate(self.images):
            for col_index, data in enumerate(row):
                origin_row_index, origin_col_index, img_tk = data
                if img_tk:
                    if origin_row_index != row_index or origin_col_index != col_index:
                        return False
        return True


完成していたら、画面とイベントをクリアし、元々の画像を画面に貼り付け、メッセージを出して終了!
    def finish(self):
        """終了画面の作成."""
        # gridで貼り付けたレイアウトを解除
        for widget in self.grid_slaves():
            widget.grid_forget()

        # イベントの解除
        self.unbind_all('<Button-1>')

        # 画面に元々の画像を表示する
        self.origin_image_tk = ImageTk.PhotoImage(
            self.origin_image, master=self)
        label = ttk.Label(self, image=self.origin_image_tk)
        label.grid(row=0, column=0)

        # 有り難いお言葉
        messagebox.showinfo(message='おめでとうございます、完成です')