File size: 11,833 Bytes
0d3476b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
from flask import Flask, request, jsonify
import base64
import io
from PIL import Image
import cv2
import numpy as np
import easyocr
import logging

from routes import app

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Initialize EasyOCR Reader
reader = easyocr.Reader(['en'], gpu=False)

def run_length_decode(encoded_bytes):
    if len(encoded_bytes) % 2 != 0:
        logger.error("Run-length encoded data length is not even.")
        raise ValueError("Run-length encoded data length is not even.")
    
    decoded = bytearray()
    for i in range(0, len(encoded_bytes), 2):
        count = encoded_bytes[i]
        byte = encoded_bytes[i + 1]
        decoded.extend([byte] * count)
    
    return bytes(decoded)

def remove_green_lines(cv_image):
    hsv = cv2.cvtColor(cv_image, cv2.COLOR_BGR2HSV)
    lower_green = np.array([40, 40, 40])
    upper_green = np.array([80, 255, 255])
    mask = cv2.inRange(hsv, lower_green, upper_green)
    mask_inv = cv2.bitwise_not(mask)
    img_no_green = cv2.bitwise_and(cv_image, cv_image, mask=mask_inv)
    return img_no_green

def preprocess_image(cv_image):
    gray = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY)
    blurred = cv2.GaussianBlur(gray, (7, 7), 0)
    thresh = cv2.adaptiveThreshold(
        blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
        cv2.THRESH_BINARY, blockSize=15, C=3)
    thresh = cv2.bitwise_not(thresh)
    kernel = np.ones((5,5), np.uint8)
    thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
    return thresh

def extract_sudoku_board(image, emptyCells, image_size):
    cv_image = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
    cv_image = remove_green_lines(cv_image)
    thresh = preprocess_image(cv_image)
    contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    if not contours:
        raise ValueError("No contours found in image")
    
    largest_contour = max(contours, key=cv2.contourArea)
    peri = cv2.arcLength(largest_contour, True)
    epsilon = 0.01 * peri
    approx = cv2.approxPolyDP(largest_contour, epsilon, True)

    if len(approx) != 4:
        raise ValueError("Sudoku grid not found (did not find 4 corners)")

    pts = approx.reshape(4, 2)
    rect = np.zeros((4, 2), dtype="float32")
    s = pts.sum(axis=1)
    rect[0] = pts[np.argmin(s)]
    rect[2] = pts[np.argmax(s)]
    diff = np.diff(pts, axis=1)
    rect[1] = pts[np.argmin(diff)]
    rect[3] = pts[np.argmax(diff)]

    (tl, tr, br, bl) = rect
    widthA = np.linalg.norm(br - bl)
    widthB = np.linalg.norm(tr - tl)
    maxWidth = max(int(widthA), int(widthB))
    heightA = np.linalg.norm(tr - br)
    heightB = np.linalg.norm(tl - bl)
    maxHeight = max(int(heightA), int(heightB))

    dst = np.array([
        [0, 0],
        [maxWidth - 1, 0],
        [maxWidth - 1, maxHeight - 1],
        [0, maxHeight - 1]
    ], dtype="float32")

    M = cv2.getPerspectiveTransform(rect, dst)
    warp = cv2.warpPerspective(cv_image, M, (maxWidth, maxHeight))

    # Determine Sudoku size based on image dimensions and emptyCells
    size = 4  # Default to 4x4
    if image_size > 40000:
        if image_size < 100000:
            size = 9
        else:
            size = 16
    
    # Secondary check based on emptyCells
    empty_cell_check = any(cell.get('x', 0) > 9 or cell.get('y', 0) > 9 for cell in emptyCells)
    if empty_cell_check:
        size = 16

    # Calculate cell size
    cell_size = maxWidth // size
    
    # Resize the warped grid to the expected size
    expected_size = size * cell_size
    warp = cv2.resize(warp, (expected_size, expected_size))

    # Convert warped image to grayscale and threshold
    warp_gray = cv2.cvtColor(warp, cv2.COLOR_BGR2GRAY)
    warp_blurred = cv2.GaussianBlur(warp_gray, (5, 5), 0)
    warp_thresh = cv2.adaptiveThreshold(
        warp_blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
        cv2.THRESH_BINARY, 11, 2)
    warp_thresh = cv2.bitwise_not(warp_thresh)

    board = []
    for y in range(size):
        row = []
        for x in range(size):
            start_x = x * cell_size
            start_y = y * cell_size
            end_x = (x + 1) * cell_size
            end_y = (y + 1) * cell_size
            
            cell = warp_thresh[start_y:end_y, start_x:end_x]
            digit = recognize_digit(cell, size)
            row.append(digit)
        board.append(row)
    
    return board, size

def count_connected_black_pixels(image, min_pixels=14):
    # Label the connected components in the binary image
    image = cv2.resize(image, (100, 100))

    num_labels, labels_im = cv2.connectedComponents(image, connectivity=8)
    
    # Save Image for inspection in img folder with randome name
    # cv2.imwrite("img/img" + str(np.random.randint(0, 1000)) + ".jpg", image)

    # Subtract 1 to ignore the background label (label=0 is the background)
    valid_components = 0
    
    # Iterate over all the labels and count those with at least min_pixels pixels
    for label in range(0, num_labels):
        if np.sum(labels_im == label) >= min_pixels:
            valid_components += 1
    
    valid_components = valid_components - 2

    if valid_components < 0:
        valid_components = 0
    
    return valid_components

# Test the function on a preprocessed image
def recognize_digit(cell_image, slots = 4):
    """

    Recognizes the digit in a Sudoku cell. If no digit is detected, it counts black pixel groups (dots).

    """
    # Preprocess cell image for digit detection
    if slots == 4:
        cell_resized = cv2.resize(cell_image, (100, 100))
    elif slots == 9:
        cell_resized = cv2.resize(cell_image, (300, 300))
    else:
        cell_resized = cv2.resize(cell_image, (500, 500))
    cell_padded = cv2.copyMakeBorder(
        cell_resized, 10, 10, 10, 10, cv2.BORDER_CONSTANT, value=255)
    cell_rgb = cv2.cvtColor(cell_padded, cv2.COLOR_GRAY2RGB)    

    # Use EasyOCR to detect text
    result = reader.readtext(cell_rgb, detail=0, allowlist='0123456789')
    
    if result:
        digit = max(result, key=len)
        if digit.isdigit():
            return int(digit)
    
    # If no digit is detected, count connected black pixel components (dots)
    logger.info("No digit detected. Counting black pixel groups.")
    # Save Image for inspection
    dots_count = count_connected_black_pixels(cell_padded)
    
    logger.info(f"Detected {dots_count} connected black pixel groups (dots).")
    
    return dots_count if dots_count > 0 else 0  # Default to 0 if nothing is recognized

def is_valid_general(board, row, col, num, size):
    # Check row and column
    for i in range(size):
        if board[row][i] == num or board[i][col] == num:
            return False
    # Check subgrid
    subgrid_size = int(np.sqrt(size))
    start_row, start_col = subgrid_size * (row // subgrid_size), subgrid_size * (col // subgrid_size)
    for y in range(start_row, start_row + subgrid_size):
        for x in range(start_col, start_col + subgrid_size):
            if board[y][x] == num:
                return False
    return True

def solve_sudoku_general(board, size):
    for row in range(size):
        for col in range(size):
            if board[row][col] == 0:
                for num in range(1, size + 1):
                    if is_valid_general(board, row, col, num, size):
                        board[row][col] = num
                        if solve_sudoku_general(board, size):
                            return True
                        board[row][col] = 0
                return False
    return True

def calculate_sum(board, empty_cells):
    total = 0
    for cell in empty_cells:
        x = cell.get('x')
        y = cell.get('y')
        
        # Validate the coordinates
        if x is None or y is None:
            raise ValueError(f"Empty cell coordinates missing: {cell}")
        if not (0 <= y < len(board)) or not (0 <= x < len(board[0])):
            raise ValueError(f"Cell location out of bounds: x={x}, y={y}")

        total += board[y][x]
    
    return total

@app.route('/sudoku', methods=['POST'])
def sudoku_endpoint():
    payload = request.json

    logger.info("Received payload: %s", payload)
    
    # Extract fields
    sudoku_id = payload.get('id')
    encoded_str = payload.get('encoded')
    img_length = payload.get('imgLength')
    empty_cells = payload.get('emptyCells')
    
    # Validate required fields
    if not all([sudoku_id, encoded_str, img_length, empty_cells]):
        missing = [field for field in ['id', 'encoded', 'imgLength', 'emptyCells'] if not payload.get(field)]
        logger.error(f"Missing required fields: {missing}")
        return jsonify({"error": f"Missing required fields: {missing}"}), 400
    
    # Base64 Decode First
    try:
        run_length_encoded_bytes = base64.b64decode(encoded_str)
        logger.info("Base64 decoding successful.")
    except Exception as e:
        logger.error(f"Base64 decoding failed: {e}")
        return jsonify({"error": f"Base64 decoding failed: {str(e)}"}), 400
    
    # Run-Length Decode Second
    try:
        image_bytes = run_length_decode(run_length_encoded_bytes)
        logger.info("Run-length decoding successful.")
    except Exception as e:
        logger.error(f"Run-length decoding failed: {e}")
        return jsonify({"error": f"Run-length decoding failed: {str(e)}"}), 400
    
    # Verify imgLength
    if len(image_bytes) != img_length:
        logger.error(f"Decoded image length mismatch: expected {img_length}, got {len(image_bytes)}")
        return jsonify({"error": f"Decoded image length mismatch: expected {img_length}, got {len(image_bytes)}"}), 400
    
    # Open image using PIL
    try:
        image = Image.open(io.BytesIO(image_bytes))
        # Save Image for inspection
        image.save("image_main.jpg")
        logger.info("Image opened successfully.")
    except Exception as e:
        logger.error(f"Failed to open image: {e}")
        return jsonify({"error": f"Failed to open image: {str(e)}"}), 400
    
    # Extract Sudoku board
    try:
        board, size = extract_sudoku_board(image, empty_cells, img_length)
        logger.info(f"Sudoku board extracted successfully. Size: {size}x{size}")
    except Exception as e:
        logger.error(f"Failed to extract Sudoku board: {e}")
        return jsonify({"error": f"Failed to extract Sudoku board: {str(e)}"}), 400
    
    # Solve Sudoku
    try:
        logger.info("Sudoku board before solving:")
        for row in board:
            logger.info(row)
        solved = solve_sudoku_general(board, size)
        if not solved:
            logger.error("Sudoku cannot be solved.")
            return jsonify({"error": "Sudoku cannot be solved"}), 400
        logger.info("Sudoku solved successfully.")
    except Exception as e:
        logger.error(f"Sudoku solving failed: {e}")
        return jsonify({"error": f"Sudoku solving failed: {str(e)}"}), 400
    
    # Calculate sum
    try:
        total_sum = calculate_sum(board, empty_cells)
        logger.info(f"Sum of specified cells: {total_sum}")
    except Exception as e:
        logger.error(f"Sum calculation failed: {e}")
        return jsonify({"error": f"Sum calculation failed: {str(e)}"}), 400
    
    # Prepare response
    response = {
        "answer": board,
        "sum": total_sum
    }
    
    logger.info(f"Response prepared successfully for Sudoku ID: {sudoku_id}")
    
    return jsonify(response), 200