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()
