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