Spaces:
Sleeping
Sleeping
| 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() | |