File size: 8,131 Bytes
799d6a7
 
 
 
 
 
 
 
 
98253f0
adea4a9
1d42af3
 
 
 
a7f0aae
0d5b69a
1d42af3
a7f0aae
 
1d42af3
98253f0
1d42af3
 
 
 
 
8b4ba30
 
 
1d42af3
 
 
 
a7f0aae
 
 
 
 
1d42af3
a7f0aae
 
 
 
 
 
 
 
 
799d6a7
 
a7f0aae
1d42af3
98253f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a73c25c
 
 
799d6a7
 
f8ebfdd
799d6a7
a73c25c
 
 
 
 
 
 
 
 
 
 
 
799d6a7
 
a73c25c
 
799d6a7
a73c25c
 
799d6a7
a0612ff
a73c25c
a0612ff
 
a73c25c
799d6a7
a73c25c
 
a0612ff
 
 
a73c25c
799d6a7
 
a0612ff
a73c25c
 
 
 
a0612ff
a73c25c
 
 
a0612ff
a73c25c
 
d9bbe60
a73c25c
 
 
a0612ff
a73c25c
 
 
 
a0612ff
a73c25c
 
 
 
a0612ff
a73c25c
 
 
 
 
 
 
 
 
 
 
 
 
9331987
a73c25c
 
 
0d5b69a
a73c25c
0d5b69a
 
 
 
 
 
 
104b989
 
 
 
 
 
 
0d5b69a
 
 
 
 
 
104b989
 
0d5b69a
 
 
 
 
 
 
 
 
a73c25c
 
 
 
 
 
a0612ff
8b4ba30
a73c25c
 
 
 
 
 
799d6a7
 
f8ebfdd
 
799d6a7
98253f0
a0612ff
799d6a7
98253f0
 
 
 
 
 
 
 
 
f8ebfdd
98253f0
 
 
 
 
f8ebfdd
98253f0
799d6a7
 
 
98253f0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
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()