CardCounter / app.py
PaulMartrenchar's picture
Test with whitening what is not black
1d42af3
import cv2
import numpy as np
import pytesseract
import re
import gradio as gr
# Regex pattern for allowed card values
VALID_PATTERN = re.compile(r"-?\d+")
def extract_skyjo_value(card_img):
h, w, _ = card_img.shape
# Crop the top-left corner
crop_margin_top = int(h / 11)
crop_margin_left = int(w / 7)
crop_size = int(w / 3)
roi = card_img[
crop_margin_top:crop_margin_top + crop_size,
crop_margin_left:crop_margin_left + crop_size
]
# Convert to grayscale
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
# Threshold to keep only black/dark pixels (adjust threshold value as needed)
_, thresh = cv2.threshold(gray, 50, 255, cv2.THRESH_BINARY_INV)
# Optional: morphological closing to fill small gaps
kernel = np.ones((2, 2), np.uint8)
thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
# Invert back to black on white for OCR
thresh = cv2.bitwise_not(thresh)
# Run OCR on the thresholded image
raw_text = pytesseract.image_to_string(
thresh,
config="--psm 10 --oem 3 -c tessedit_char_whitelist=-0123456789"
)
print("Raw OCR text:", raw_text)
# Extract numbers
matches = re.findall(r"-?\d+", raw_text)
for m in matches:
try:
val = int(m)
if -2 <= val <= 12:
return val
except ValueError:
continue
return None
def extract_flip7_value(card_img):
h, w, _ = card_img.shape
margin = 50
roi = card_img[margin:h-margin, margin:w-margin]
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (3,3), 0)
thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)[1]
raw_text = pytesseract.image_to_string(
thresh,
config="--psm 6 -c tessedit_char_whitelist=0123456789"
)
matches = re.findall(r"\d+", raw_text)
for m in matches:
try:
val = int(m)
if 0 <= val <= 12:
return val
except ValueError:
continue
return None
def detect_cards_and_sum(image, game):
"""
Returns: (num_cards, total, error_msg, annotated_rgb_image)
"""
try:
if image is None:
return 0, 0, "No image provided.", None
# --- helpers ---
def order_points(pts):
pts = np.array(pts, dtype="float32")
s = pts.sum(axis=1)
diff = np.diff(pts, axis=1)
tl = pts[np.argmin(s)]
br = pts[np.argmax(s)]
tr = pts[np.argmin(diff)]
bl = pts[np.argmax(diff)]
return np.array([tl, tr, br, bl], dtype="float32")
# accept both PIL and np image
if not isinstance(image, np.ndarray):
image = np.array(image.convert("RGB"))
img_bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
annotated = img_bgr.copy()
H, W = annotated.shape[:2]
img_area = float(H * W)
# --- edge map ---
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blur, 50, 150)
edges = cv2.dilate(edges, np.ones((3, 3), np.uint8), iterations=1)
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# sort largest to smallest
contours = sorted(contours, key=cv2.contourArea, reverse=True)
values = []
for cnt in contours:
rect = cv2.minAreaRect(cnt)
(cx, cy), (rw, rh), _ = rect
if rw == 0 or rh == 0:
continue
box = cv2.boxPoints(rect)
box = np.int32(box)
box_area = rw * rh
if box_area < 0.002 * img_area or box_area > 0.9 * img_area:
continue # too small or too big to be a card
# aspect ratio (card ~ 1.4-1.8 between long/short edges)
long_side = max(rw, rh)
short_side = min(rw, rh)
ratio = long_side / short_side
if ratio < 1.1 or ratio > 2.2:
continue
# rectangularity: contour area close to its minAreaRect area
cnt_area = cv2.contourArea(cnt)
if cnt_area / box_area < 0.5:
continue
# perspective warp to a canonical card size (2:3 ratio)
dst_w, dst_h = 300, 450
M = cv2.getPerspectiveTransform(order_points(box),
np.array([[0, 0],
[dst_w-1, 0],
[dst_w-1, dst_h-1],
[0, dst_h-1]], dtype="float32"))
card = cv2.warpPerspective(img_bgr, M, (dst_w, dst_h))
# read value (center crop OCR handled in your extract_* funcs)
if game == "Skyjo":
val = extract_skyjo_value(card)
else:
val = extract_flip7_value(card)
if val is None:
val = 100
values.append(val)
# --- Draw card outline ---
cv2.polylines(annotated, [box], True, (0, 255, 0), 3)
# --- Draw cropped region and value ---
h, w, _ = card.shape
crop_margin_top = int(h / 11)
crop_margin_left = int(w / 7)
crop_size = int(w / 3)
# Define crop region points (without homogeneous coordinate)
crop_tl = np.array([crop_margin_left, crop_margin_top], dtype="float32")
crop_br = np.array([crop_margin_left + crop_size, crop_margin_top + crop_size], dtype="float32")
# Reshape for perspectiveTransform: (N, 1, 2)
crop_tl_reshaped = crop_tl.reshape(-1, 1, 2)
crop_br_reshaped = crop_br.reshape(-1, 1, 2)
# Inverse perspective transform to get points in original image
M_inv = cv2.getPerspectiveTransform(
np.array([[0, 0], [dst_w-1, 0], [dst_w-1, dst_h-1], [0, dst_h-1]], dtype="float32"),
order_points(box)
)
crop_tl_orig = cv2.perspectiveTransform(crop_tl_reshaped, M_inv)
crop_br_orig = cv2.perspectiveTransform(crop_br_reshaped, M_inv)
# Draw rectangle around cropped region in original image
cv2.rectangle(
annotated,
tuple(crop_tl_orig[0][0].astype(int)),
tuple(crop_br_orig[0][0].astype(int)),
(255, 0, 0), 2
)
# --- Draw value near the card ---
moments = cv2.moments(box)
if moments["m00"] != 0:
tx = int(moments["m10"] / moments["m00"])
ty = int(moments["m01"] / moments["m00"])
else:
tx, ty = int(cx), int(cy)
cv2.putText(annotated, str(val), (tx-10, ty-10),
cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0, 0, 255), 3)
if not values:
# return the annotated image anyway so you can see what was (not) detected
return 0, 0, "No valid card values detected.", cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB)
return len(values), int(sum(values)), None, cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB)
except Exception as e:
return 0, 0, f"Error: {str(e)}", None
# Gradio UI
with gr.Blocks() as demo:
gr.Markdown("## Card Value Detector (Skyjo / Flip7)")
with gr.Row():
image_input = gr.Image(type="pil", label="Upload photo")
game_choice = gr.Radio(["Skyjo", "Flip7"], value="Skyjo", label="Game")
num_cards = gr.Number(label="Number of Cards Detected")
total = gr.Number(label="Total Sum")
error = gr.Textbox(label="Error Message")
annotated = gr.Image(type="numpy", label="Detected Cards with Values")
run_btn = gr.Button("Process")
run_btn.click(
fn=detect_cards_and_sum,
inputs=[image_input, game_choice],
outputs=[num_cards, total, error, annotated]
)
if __name__ == "__main__":
demo.launch()