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形式)
領域が小さいものを除外
w
やh
が小さすぎるものはフィルタリング済みですが、さらに厳しくすることも可能です
自動並び替え
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形式)
領域が小さいものを除外
w
やh
が小さすぎるものはフィルタリング済みですが、さらに厳しくすることも可能です
自動並び替え
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()