Python Pillow画像をOpenCVでカード領域に分割する

Python Pillow画像をOpenCVでカード領域に分割する


目次

目的

Pillowで読み込んだ画像をOpenCVで処理し、カード領域を自動抽出する手順を解説します! 今回は特に「抽出したboxesからカード画像を個別に切り出す方法」にフォーカスします。


前提:boxesとは?

前回までで、画像からカード領域の矩形リスト boxes が作成されています。 boxes はリストで、各要素が (x, y, w, h) という形式です:

boxes = [(x1, y1, w1, h1), (x2, y2, w2, h2), ...]

ここで、

  • x, y は矩形の左上座標
  • w, h は幅と高さ

を意味します。


1. 元画像から矩形領域を切り出す

Pillow画像に対して、矩形領域 (x, y, x+w, y+h) を指定して切り出します。

from PIL import Image

# 元画像をPillowで開く(RGBA or RGB)
original_image = Image.open("image/sample.jpg").convert("RGBA")

# 例: boxesリストから切り出し
for idx, (x, y, w, h) in enumerate(boxes):
    cropped = original_image.crop((x, y, x + w, y + h))

    # 保存ファイル名を決める
    save_path = f"clips/card_{idx+1:02d}.png"
    cropped.save(save_path)
  • Image.crop((left, upper, right, lower)) を使って切り出します!
  • 保存する際はPNG形式を推奨(透明度情報も保持できるため)

2. 保存ファイルの命名規則

カードが複数あるので、保存するファイル名は一意にする必要があります。

例えば:

保存順ファイル名
1枚目card_01.png
2枚目card_02.png
3枚目card_03.png

3. 注意点と工夫

透明背景付き保存

  • convert("RGBA")しておけば、透明背景も維持して保存できます(特にPNG形式)

領域が小さいものを除外

  • whが小さすぎるものはフィルタリング済みですが、さらに厳しくすることも可能です

自動並び替え

  • boxesをX軸またはY軸順にソートしてから切り出すと、カード番号が自然な順番になります

例:

boxes = sorted(boxes, key=lambda b: (b[1], b[0]))  # まずY軸(行)、次にX軸(列)順

まとめ

  • boxesにはカード領域の(x, y, w, h)がリストされている
  • Image.crop()で簡単に矩形領域を切り出せる
  • 切り出した画像は番号付きで保存すれば管理しやすい
  • 透明度を保持したいならPNG保存がおすすめ

これで「画像からカードを個別ファイルに切り出す」処理がバッチリできるようになります!


次回:「カード画像を自動リサイズしてサムネイル一覧を作る」編へ続きます。

はい、喜んで!
このコード部分、とても重要なところなので、できるだけ詳しく順番に丁寧に解説していきますね!


✏️ 解説:このコードの意味

       # # PIL → OpenCV画像へ Pillow 画像 を NumPy( RGB ) 形式に変換
        cv_img = np.array(self.original_image.convert("RGB"))
        # グレイスケールに変換
        gray = cv2.cvtColor(cv_img, cv2.COLOR_RGB2GRAY)
        # 二値化 明るい背景(白カード)を検出
        _, thresh = cv2.threshold(gray, 220, 255, cv2.THRESH_BINARY)
        # 輪郭検出
        contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        # カードのサイズをフィルタリング boxes = [(x,y,w,h), (x,y,w,h), ...]
        boxes = [cv2.boundingRect(cnt) for cnt in contours if cv2.boundingRect(cnt)[2] > 50 and cv2.boundingRect(cnt)[3] > 80

os.makedirs("clips", exist_ok=True)

  • "clips"という名前のフォルダを作成します。
  • exist_ok=Trueをつけているので、
    • フォルダがすでに存在していてもエラーにならない
    • なければ新しく作り、あればそのまま使う。
  • → これで、保存先のフォルダが常に安全に準備される

cv_img = np.array(self.original_image.convert("RGB"))

  • Pillow(PIL)画像 (self.original_image) を
  • NumPy配列に変換します。
  • その前に .convert("RGB") をしているのは、
    • OpenCVは**RGBA(透明度付き)**を正しく扱えないから。
  • → これでPillowからOpenCV用に変換完了! 【イメージ変化】 もともと 変換後 PIL.Image (RGBA or RGB) NumPy配列 (height × width × 3)

gray = cv2.cvtColor(cv_img, cv2.COLOR_RGB2GRAY)

  • OpenCVの関数 cvtColor() を使って、
  • カラー画像 (RGB) をグレースケール画像 (GRAY) に変換します。
  • グレースケール=1ピクセルが1つの明るさ値だけを持つ(0〜255) 【イメージ変化】 RGB グレースケール [R, G, B] [明るさ]

_, thresh = cv2.threshold(gray, 220, 255, cv2.THRESH_BINARY)

  • 2値化処理を行っています。
  • grayの各ピクセルについて:
    • 220以上なら255(白)
    • 220未満なら0(黒)
  • cv2.THRESH_BINARYモードなので完全に白黒になる! 【イメージ変化】 条件 出力 gray値 ≥ 220 255(白) gray値 < 220 0(黒)

contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

  • **白い領域(255)**を検出して、**輪郭(contours)**を取得しています。
  • cv2.RETR_EXTERNALなので、
    • 一番外側の輪郭だけを取ります(内部の細かい輪郭は無視)
  • cv2.CHAIN_APPROX_SIMPLEは、
    • 輪郭点を必要最低限だけ記録してデータ量を圧縮します。

boxes = [cv2.boundingRect(cnt) for cnt in contours if cv2.boundingRect(cnt)[2] > 50 and cv2.boundingRect(cnt)[3] > 80]

  • boundingRect(cnt)
    • 各輪郭cntを囲む最小の長方形(x,y,w,h)を取得します。
  • さらにフィルター:
    • w > 50
    • 高さ h > 80 だけを対象に残す。
  • 小さすぎるゴミやノイズを自動で除外しています!

🔥 まとめ図

流れを図で表すとこうなります!

Pillow画像
  ↓ (RGB変換)
NumPy配列
  ↓ (グレースケール変換)
灰色画像
  ↓ (2値化 220以上で白)
白黒画像
  ↓ (輪郭検出)
輪郭リスト
  ↓ (矩形化+フィルター)
boxes = [(x,y,w,h), (x,y,w,h), ...]

✅ これで何をやったか一言で言うと?

「カードの矩形領域だけを自動で抽出して、リストにまとめた」

ということです!


🌟 超重要ポイント

  • os.makedirs保存先を安全に確保
  • convert("RGB")OpenCV対応
  • threshold(220)背景除去
  • findContours()物体検出
  • boundingRect + サイズフィルタカードだけ選別

ここまで完璧に理解できたら、
次はこれを

  • さらに精度を上げる方法
  • 背景色によって動的に閾値を変える方法 なんかも学べますよ!🌈

もしこの続き(例えば「もっときれいにカードだけ抜く方法」)に興味あったら教えてくださいね!✨


質問があれば何でも聞いてください!🎯 (さらに深堀りしたい部分あれば教えて!)

Python Pillow画像をOpenCVでカード領域に分割する手順(切り出し編)


目的

Pillowで読み込んだ画像をOpenCVで処理し、カード領域を自動抽出する手順を解説します! 今回は特に「抽出したboxesからカード画像を個別に切り出す方法」にフォーカスします。


前提:boxesとは?

前回までで、画像からカード領域の矩形リスト boxes が作成されています。 boxes はリストで、各要素が (x, y, w, h) という形式です:

boxes = [(x1, y1, w1, h1), (x2, y2, w2, h2), ...]

ここで、

  • x, y は矩形の左上座標
  • w, h は幅と高さ

を意味します。


1. 元画像から矩形領域を切り出す

Pillow画像に対して、矩形領域 (x, y, x+w, y+h) を指定して切り出します。

from PIL import Image

# 元画像をPillowで開く(RGBA or RGB)
original_image = Image.open("image/sample.jpg").convert("RGBA")

# 例: boxesリストから切り出し
for idx, (x, y, w, h) in enumerate(boxes):
    cropped = original_image.crop((x, y, x + w, y + h))

    # 保存ファイル名を決める
    save_path = f"clips/card_{idx+1:02d}.png"
    cropped.save(save_path)
  • Image.crop((left, upper, right, lower)) を使って切り出します!
  • 保存する際はPNG形式を推奨(透明度情報も保持できるため)

2. 保存ファイルの命名規則

カードが複数あるので、保存するファイル名は一意にする必要があります。

例えば:

保存順ファイル名
1枚目card_01.png
2枚目card_02.png
3枚目card_03.png

3. 注意点と工夫

透明背景付き保存

  • convert("RGBA")しておけば、透明背景も維持して保存できます(特にPNG形式)

領域が小さいものを除外

  • whが小さすぎるものはフィルタリング済みですが、さらに厳しくすることも可能です

自動並び替え

  • boxesをX軸またはY軸順にソートしてから切り出すと、カード番号が自然な順番になります

例:

boxes = sorted(boxes, key=lambda b: (b[1], b[0]))  # まずY軸(行)、次にX軸(列)順

まとめ

  • boxesにはカード領域の(x, y, w, h)がリストされている
  • Image.crop()で簡単に矩形領域を切り出せる
  • 切り出した画像は番号付きで保存すれば管理しやすい
  • 透明度を保持したいならPNG保存がおすすめ

これで「画像からカードを個別ファイルに切り出す」処理がバッチリできるようになります!


次回:「カード画像を自動リサイズしてサムネイル一覧を作る」編へ続きます。

全体ソースコード

# 正式対応版 detect_and_save_cards_named()(Y→Xグループ化ベース)

import tkinter as tk
from tkinter import filedialog, Menu
from PIL import Image, ImageTk, ImageDraw
import os
import cv2
import numpy as np

class LoadFile:
    def __init__(self):
        self.image = None
        self.path = None

    def open_image(self):
        path = filedialog.askopenfilename(filetypes=[("画像ファイル", "*.jpg *.jpeg *.png")])
        if path:
            self.path = path
            self.image = Image.open(path).convert("RGBA")
            return self.image
        return None

    def save_image(self, save_path=None):
        if self.image:
            if save_path:
                self.image.save(save_path)
            elif self.path:
                self.image.save(self.path)

class MainWindow:
    def __init__(self, root):
        self.root = root
        self.root.geometry("1400x700+400+10")
        self.root.title("トランプ画像ビューア")
        self.loader = LoadFile()
        self.zoom_ratio = 0.8
        self.original_image = None
        self.tk_image = None
        self.setup_ui()

    def setup_ui(self):
        menubar = Menu(self.root)
        file_menu = Menu(menubar, tearoff=0)
        file_menu.add_command(label="開く", command=self.open_file)
        file_menu.add_separator()
        file_menu.add_command(label="終了", command=self.root.quit)
        menubar.add_cascade(label="ファイル", menu=file_menu)
        self.root.config(menu=menubar)

        self.menuFrame = tk.Frame(self.root, bg="lightgray", width=1024, height=100)
        self.menuFrame.place(x=10, y=10)
        tk.Button(self.menuFrame, text="-", command=self.zoom_out).place(x=10, y=30, width=40, height=40)
        tk.Button(self.menuFrame, text="<>", command=self.zoom_full).place(x=60, y=30, width=40, height=40)
        tk.Button(self.menuFrame, text="+", command=self.zoom_in).place(x=110, y=30, width=40, height=40)
        tk.Button(self.menuFrame, text="ファイル読み込み", command=self.open_file).place(x=160, y=30, width=100, height=40)
        tk.Button(self.menuFrame, text="背景を消す", command=self.erase_background).place(x=270, y=30, width=100, height=40)
        tk.Button(self.menuFrame, text="切り出し", command=self.detect_and_save_cards_named).place(x=380, y=30, width=100, height=40)
        tk.Button(self.menuFrame, text="保存(背景PNG)", command=self.save_png).place(x=490, y=30, width=130, height=40)

        self.message_label = tk.Label(self.menuFrame, text="", bg="lightgray", fg="darkgreen", font=("Arial", 10))
        self.message_label.place(x=520, y=75)

        self.mx, self.my, self.mw, self.mh = 10, 110, 1024, 500
        self.mainCanvas = tk.Canvas(self.root, bg="#333333", width=self.mw, height=self.mh)
        self.mainCanvas.place(x=self.mx, y=self.my)

        self.subCanvasFrame = tk.Frame(self.root)
        self.subCanvasFrame.place(x=1050, y=110, width=300, height=500)

        self.subCanvas = tk.Canvas(self.subCanvasFrame, bg="#999999", width=280, height=500, scrollregion=(0,0,280,780))
        self.v_scrollbar = tk.Scrollbar(self.subCanvasFrame, orient="vertical", command=self.subCanvas.yview)
        self.subCanvas.configure(yscrollcommand=self.v_scrollbar.set)
        self.v_scrollbar.pack(side="right", fill="y")
        self.subCanvas.pack(side="left", fill="both", expand=True)

        self.subCanvasContent = tk.Frame(self.subCanvas, bg="#999999")
        self.subCanvas.create_window((0, 0), window=self.subCanvasContent, anchor="nw")

        self.mainCanvas.bind("<MouseWheel>", self.on_mousewheel)

    def open_file(self):
        image = self.loader.open_image()
        if image:
            self.original_image = image.copy()
            self.zoom_ratio = 0.8
            self.display_image()
            self.message_label.config(text="画像を読み込みました。")

    def zoom_in(self, center=None):
        self.zoom_ratio = min(3.0, self.zoom_ratio + 0.1)
        self.display_image(center)

    def zoom_out(self, center=None):
        self.zoom_ratio = max(0.1, self.zoom_ratio - 0.1)
        self.display_image(center)

    def zoom_full(self):
        self.zoom_ratio = 0.8
        self.display_image()

    def on_mousewheel(self, event):
        if event.state & 0x0004:
            canvas_x = self.mainCanvas.canvasx(event.x)
            canvas_y = self.mainCanvas.canvasy(event.y)
            if event.delta > 0:
                self.zoom_in((canvas_x, canvas_y))
            else:
                self.zoom_out((canvas_x, canvas_y))

    def erase_background(self):
        if not self.original_image:
            return

        img = self.original_image.copy()
        pixels = img.load()
        w, h = img.size

        for y in range(h):
            for x in range(w):
                r, g, b, a = pixels[x, y]
                if 100 < r < 160 and g < 70 and b < 70:
                    pixels[x, y] = (0, 0, 0, 0)

        self.original_image = img
        self.loader.image = img
        self.display_image()


    def detect_and_save_cards_named(self):
        if not self.original_image:
            return

        os.makedirs("clips", exist_ok=True)
        # # PIL → OpenCV画像へ Pillow 画像 を NumPy( RGB ) 形式に変換
        cv_img = np.array(self.original_image.convert("RGB"))
        # グレイスケールに変換
        gray = cv2.cvtColor(cv_img, cv2.COLOR_RGB2GRAY)
        # 二値化 明るい背景(白カード)を検出
        _, thresh = cv2.threshold(gray, 220, 255, cv2.THRESH_BINARY)
        # 輪郭検出
        contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        # カードのサイズをフィルタリング boxes = [(x,y,w,h), (x,y,w,h), ...]
        boxes = [cv2.boundingRect(cnt) for cnt in contours if cv2.boundingRect(cnt)[2] > 50 and cv2.boundingRect(cnt)[3] > 80]

        # Yグループ化
        boxes = sorted(boxes, key=lambda b: b[1])  # Yでまず並べる
        row_tolerance = 30
        rows = []
        for box in boxes:
            y = box[1]
            for row in rows:
                if abs(row[0][1] - y) < row_tolerance:
                    row.append(box)
                    break
            else:
                rows.append([box])
        # Xグループ化        # カードの名前を付けて保存
        suits = ['H', 'D', 'C', 'S']
        joker_boxes = []

        count = 0
        for i, suit in enumerate(suits):
            if i >= len(rows):
                continue
            row = sorted(rows[i], key=lambda b: b[0])  # X順に並べ替え
            for j, box in enumerate(row):
                x, y, w, h = box
                cropped = self.original_image.crop((x, y, x + w, y + h))
                if j < 13:
                    filename = f"{suit}-{j+1:02d}.png"
                else:
                    filename = f"J-{len(joker_boxes)+1:02d}.png"
                    joker_boxes.append(box)
                self.saveClip(cropped, filename)
                count += 1

        self.message_label.config(text=f"{count} 枚のカードを保存しました。")
        self.load_clips()

    def saveClip(self, cropped_img, filename):
        save_path = os.path.join("clips", filename)
        cropped_img.save(save_path)

    def save_png(self):
        if self.original_image:
            path = filedialog.asksaveasfilename(defaultextension=".png", filetypes=[("PNG files", "*.png")])
            if path:
                self.original_image.save(path, format="PNG")

    def display_image(self, center=None):
        if self.original_image is None:
            return
        new_w = int(self.original_image.width * self.zoom_ratio)
        new_h = int(self.original_image.height * self.zoom_ratio)
        resized = self.original_image.resize((new_w, new_h))
        self.tk_image = ImageTk.PhotoImage(resized)
        self.mainCanvas.delete("all")
        anchor_x = self.mw // 2 if center is None else self.mw // 2 - (center[0] - self.mw // 2)
        anchor_y = self.mh // 2 if center is None else self.mh // 2 - (center[1] - self.mh // 2)
        self.mainCanvas.create_image(anchor_x, anchor_y, anchor="center", image=self.tk_image)

    def load_clips(self):
        for widget in self.subCanvasContent.winfo_children():
            widget.destroy()

        suits = ['H', 'D', 'C', 'S', 'J']
        cards = {suit: [] for suit in suits}

        for f in sorted(os.listdir("clips")):
            if not f.lower().endswith((".png", ".jpg", ".jpeg")):
                continue
            name = os.path.splitext(f)[0]
            parts = name.split("-")
            if len(parts) == 2 and parts[0] in suits:
                cards[parts[0]].append(f)
            elif len(parts) == 1 and parts[0] == "J":
                cards['J'].append(f)

        images = []
        card_w, card_h = 40, 55

        for row in range(13):
            for col, suit in enumerate(suits):
                if row < len(cards[suit]):
                    fname = cards[suit][row]
                    img_path = os.path.join("clips", fname)
                    img = Image.open(img_path).resize((card_w, card_h))
                    tk_img = ImageTk.PhotoImage(img)
                    images.append(tk_img)

                    lbl = tk.Label(self.subCanvasContent, image=tk_img, bg="#999999")
                    lbl.grid(row=row * 2, column=col, padx=2, pady=2)

                    txt = tk.Label(self.subCanvasContent, text=fname[:-4], bg="#999999", fg="white", font=("Arial", 6))
                    txt.grid(row=row * 2 + 1, column=col, padx=2, pady=(0, 5))

        self.subCanvas.image_cache = images
        self.subCanvas.update_idletasks()
        self.subCanvas.configure(scrollregion=self.subCanvas.bbox("all"))

class App:
    def __init__(self):
        self.root = tk.Tk()
        self.window = MainWindow(self.root)

    def run(self):
        self.root.mainloop()

if __name__ == "__main__":
    app = App()
    app.run()
よかったらシェアしてね!
目次