Skip to content

Commit 2172d8d

Browse files
committed
Update 06_SudokuProcessor.md
1 parent e6e7473 commit 2172d8d

1 file changed

Lines changed: 14 additions & 189 deletions

File tree

content/learning-paths/cross-platform/onnx/06_SudokuProcessor.md

Lines changed: 14 additions & 189 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ layout: "learningpathall"
1010

1111
In this section, we integrate all previous components into a complete Sudoku processing pipeline. Starting from a full Sudoku image, we detect and rectify the grid, split it into individual cells, recognize digits using the ONNX model, and finally solve the puzzle using a deterministic solver. By the end of this step, you will have an end-to-end system that takes a photograph of a Sudoku puzzle and produces a solved board, along with visual outputs for debugging and validation.
1212

13+
## Context
1314
So far, we have:
1415
1. Generated a synthetic, well-labeled Sudoku digit dataset,
1516
2. Trained a lightweight CNN (DigitNet) to recognize digits and blanks,
@@ -19,9 +20,9 @@ So far, we have:
1920
At this point, the digit recognizer is reliable in isolation. The remaining challenge is connecting vision with reasoning: extracting the Sudoku grid from an image, mapping each cell to a digit, and applying a solver. This section bridges that gap.
2021

2122
## Overview of the pipeline
22-
To implement the Sudoku processor, create a new file sudoku_processor.py and modify it as follows:
23+
To implement the Sudoku processor, create the file (sudoku_processor.py) and paste the implementation below:
2324

24-
```Python
25+
```python
2526
import cv2 as cv
2627
import numpy as np
2728
import onnxruntime as ort
@@ -70,7 +71,7 @@ class SudokuProcessor:
7071

7172
overlay_img = None
7273
if overlay and ok:
73-
overlay_img = self.overlay_solution(bgr, H, quad, board, solved)
74+
overlay_img = self.overlay_solution(bgr, H, board, solved)
7475

7576
debug = {
7677
"warped": warped,
@@ -218,7 +219,7 @@ class SudokuProcessor:
218219
# -----------------------------
219220
# Overlay
220221
# -----------------------------
221-
def overlay_solution(self, original_bgr, H, quad, board, solved):
222+
def overlay_solution(self, original_bgr, H, board, solved):
222223
"""
223224
Overlays ONLY the filled-in digits (where original board has 0).
224225
"""
@@ -340,116 +341,19 @@ The first task is to locate the Sudoku grid in the image. We convert the image t
340341

341342
Once the four corners are identified, we compute a perspective transform and warp the grid into a square image. This rectified representation removes camera tilt and perspective distortion, allowing all subsequent steps to assume a fixed geometry.
342343

343-
```Python
344-
def detect_and_warp_board(self, bgr: np.ndarray):
345-
"""
346-
Finds the largest Sudoku-like quadrilateral and warps it to a square.
347-
Returns warped_board, homography, quad_points.
348-
"""
349-
gray = cv.cvtColor(bgr, cv.COLOR_BGR2GRAY)
350-
blur = cv.GaussianBlur(gray, (5, 5), 0)
351-
352-
# Strong binary image helps contour finding (works well for printed grids)
353-
thr = cv.adaptiveThreshold(
354-
blur, 255, cv.ADAPTIVE_THRESH_GAUSSIAN_C, cv.THRESH_BINARY_INV, 31, 7
355-
)
356-
357-
# Remove small noise, connect lines a bit
358-
kernel = cv.getStructuringElement(cv.MORPH_RECT, (3, 3))
359-
thr = cv.morphologyEx(thr, cv.MORPH_CLOSE, kernel, iterations=2)
360-
361-
contours, _ = cv.findContours(thr, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
362-
if not contours:
363-
raise RuntimeError("No contours found. Try a clearer image or different thresholding.")
364-
365-
# Pick the largest contour that approximates to 4 points
366-
contours = sorted(contours, key=cv.contourArea, reverse=True)
367-
quad = None
368-
for c in contours[:20]:
369-
peri = cv.arcLength(c, True)
370-
approx = cv.approxPolyDP(c, 0.02 * peri, True)
371-
if len(approx) == 4:
372-
quad = approx.reshape(4, 2).astype(np.float32)
373-
break
374-
375-
if quad is None:
376-
raise RuntimeError("Could not find a 4-corner Sudoku grid. Try a more fronto-parallel image.")
377-
378-
quad = order_quad_points(quad)
379-
380-
dst = np.array(
381-
[[0, 0], [self.warp_size - 1, 0], [self.warp_size - 1, self.warp_size - 1], [0, self.warp_size - 1]],
382-
dtype=np.float32,
383-
)
384-
H = cv.getPerspectiveTransform(quad, dst)
385-
warped = cv.warpPerspective(bgr, H, (self.warp_size, self.warp_size))
386-
387-
return warped, H, quad
388-
```
344+
We order the four corners consistently (top-left → top-right → bottom-right → bottom-left) before computing the perspective transform.
389345

390346
## Splitting the grid into cells
391347
After rectification, the grid is divided evenly into a 9×9 array. Each cell is cropped based on its row and column index. At this stage, every cell corresponds to one Sudoku position and is ready for preprocessing and classification.
392348

393-
```Python
394-
def split_cells(self, warped_bgr: np.ndarray):
395-
"""
396-
Splits a rectified square board into 81 cell images.
397-
Returns list of (r, c, cell_bgr).
398-
"""
399-
cells = []
400-
step = self.warp_size // 9
401-
for r in range(9):
402-
for c in range(9):
403-
y0, y1 = r * step, (r + 1) * step
404-
x0, x1 = c * step, (c + 1) * step
405-
cell = warped_bgr[y0:y1, x0:x1].copy()
406-
cells.append((r, c, cell))
407-
return cells
408-
```
409-
410349
Each cell undergoes light preprocessing before inference:
411350
* Conversion to grayscale,
412351
* Cropping of a small margin to suppress grid lines,
413352
* Adaptive thresholding and morphological cleanup to isolate printed digits,
414353
* Resizing to the model’s input size (28×28),
415354
* Normalization to match the training distribution.
416355

417-
```Python
418-
def preprocess_cell(self, cell_bgr: np.ndarray):
419-
"""
420-
Produces a 28x28 float32 tensor in the same normalization as training:
421-
grayscale -> [0,1] -> normalize to [-1,1] via (x-0.5)/0.5
422-
Also tries to suppress grid lines / borders by cropping margins.
423-
"""
424-
g = cv.cvtColor(cell_bgr, cv.COLOR_BGR2GRAY)
425-
426-
# Crop a margin to remove grid lines/borders
427-
h, w = g.shape
428-
m = int(0.12 * min(h, w)) # ~12% margin
429-
g = g[m:h - m, m:w - m]
430-
431-
# Binarize & clean (helps isolate printed digits)
432-
g_blur = cv.GaussianBlur(g, (3, 3), 0)
433-
bw = cv.adaptiveThreshold(g_blur, 255, cv.ADAPTIVE_THRESH_GAUSSIAN_C, cv.THRESH_BINARY_INV, 21, 5)
434-
435-
# Remove small specks
436-
bw = cv.morphologyEx(bw, cv.MORPH_OPEN, np.ones((2, 2), np.uint8), iterations=1)
437-
438-
# If almost empty => likely blank
439-
if (bw > 0).sum() < 15:
440-
# Return a near-empty input; classifier should produce blank
441-
resized = cv.resize(g, (self.input_size, self.input_size), interpolation=cv.INTER_AREA)
442-
else:
443-
# Use bw mask to focus on digit; keep as grayscale for the model
444-
resized = cv.resize(g, (self.input_size, self.input_size), interpolation=cv.INTER_AREA)
445-
446-
x = resized.astype(np.float32) / 255.0
447-
x = (x - 0.5) / 0.5 # [-1,1]
448-
x = x[None, None, :, :] # [1,1,H,W]
449-
return x
450-
```
451-
452-
Cells with very little foreground content are treated as blank candidates, reducing false digit detections in empty cells.
356+
We crop a margin to suppress grid lines, because grid strokes can dominate the digit pixels and cause systematic misclassification. Cells with very little foreground content are treated as blank candidates, reducing false digit detections in empty cells.
453357

454358
## Batched ONNX inference
455359
All 81 cell tensors are stacked into a single batch and passed to ONNX Runtime in one call. Because the model was exported with a dynamic batch dimension, this batched inference is efficient and mirrors how the model will be used in production.
@@ -460,100 +364,21 @@ The result is a 9×9 board where:
460364
* 0 represents a blank cell,
461365
* 1–9 represent recognized digits.
462366

463-
```Python
464-
def recognize_board(self, cells):
465-
"""
466-
Runs batched ONNX inference on 81 cells and returns:
467-
board[9][9] with 0 for blank
468-
conf[9][9] with max softmax probability
469-
"""
470-
xs = []
471-
coords = []
472-
for r, c, cell in cells:
473-
coords.append((r, c))
474-
xs.append(self.preprocess_cell(cell))
475-
476-
X = np.concatenate(xs, axis=0).astype(np.float32) # [81,1,28,28]
477-
logits = self.sess.run([self.output_name], {self.input_name: X})[0] # [81,10]
478-
probs = softmax(logits, axis=1)
479-
pred = probs.argmax(axis=1)
480-
conf = probs.max(axis=1)
481-
482-
board = [[0 for _ in range(9)] for _ in range(9)]
483-
conf_grid = [[0.0 for _ in range(9)] for _ in range(9)]
484-
for i, (r, c) in enumerate(coords):
485-
p = int(pred[i])
486-
cf = float(conf[i])
487-
488-
# Optional safety: low-confidence => blank
489-
if cf < self.blank_conf_threshold:
490-
p = self.blank_class
491-
492-
board[r][c] = p
493-
conf_grid[r][c] = cf
494-
495-
return board, conf_grid
496-
```
497-
498367
## Solving the Sudoku
499368
With the recognized board constructed, we apply a classic backtracking Sudoku solver. This solver deterministically fills empty cells while respecting Sudoku constraints (row, column, and 3×3 block rules).
500369

501370
If the solver succeeds, we obtain a complete solution. If it fails, the failure usually indicates one or more recognition errors, which can be diagnosed using the intermediate visual outputs.
502371

503-
```Python
504-
def solve_sudoku(board):
505-
pos = find_empty(board)
506-
if pos is None:
507-
return True
508-
r, c = pos
509-
for v in range(1, 10):
510-
if valid(board, r, c, v):
511-
board[r][c] = v
512-
if solve_sudoku(board):
513-
return True
514-
board[r][c] = 0
515-
return False
516-
517-
518-
def find_empty(board):
519-
for r in range(9):
520-
for c in range(9):
521-
if board[r][c] == 0:
522-
return (r, c)
523-
return None
524-
525-
526-
def valid(board, r, c, v):
527-
# row
528-
for j in range(9):
529-
if board[r][j] == v:
530-
return False
531-
# col
532-
for i in range(9):
533-
if board[i][c] == v:
534-
return False
535-
# box
536-
br, bc = 3 * (r // 3), 3 * (c // 3)
537-
for i in range(br, br + 3):
538-
for j in range(bc, bc + 3):
539-
if board[i][j] == v:
540-
return False
541-
return True
542-
```
543-
544372
## Visualization and outputs
545-
To aid debugging and demonstration, the processor produces several visual artifacts:
546-
1. The rectified (warped) Sudoku grid,
547-
2. A clean rendering of the recognized board,
548-
3. A clean rendering of the solved board,
549-
4. An overlay of the solved digits on top of the original image.
550-
551-
These outputs make it easy to understand how each stage behaves and are particularly useful when testing on real camera images.
552-
373+
The processor saves several artifacts to help debugging and demonstration:
374+
- `artifacts/warped.png` – rectified top-down view of the Sudoku grid.
375+
- `artifacts/overlay_solution.png` – solution digits overlaid onto the original image (if solved).
376+
- (Optional) `artifacts/recognized_board.png`, `artifacts/solved_board.png`, `artifacts/boards_side_by_side.png` – clean board renderings if you enabled those helpers.
553377

554378
## Running the processor
555379
A small driver script (05_RunSudokuProcessor.py) demonstrates how to use the SudokuProcessor:
556-
```Python
380+
381+
```python
557382
import os
558383
import cv2 as cv
559384

@@ -576,7 +401,7 @@ def print_board(board, title="Board"):
576401
def main():
577402
# Use any image path you like:
578403
# - a real photo
579-
# - a synthetic grid from Step 3A, e.g. data/grids/val/000001_cam.png
404+
# - a synthetic grid, e.g. data/grids/val/000001_cam.png
580405
img_path = "data/grids/val/000001_cam.png"
581406
onnx_path = os.path.join("artifacts", "sudoku_digitnet.onnx")
582407

0 commit comments

Comments
 (0)