multi-puzzle-solver 1.1.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multi_puzzle_solver-1.1.8.dist-info/METADATA +4326 -0
- multi_puzzle_solver-1.1.8.dist-info/RECORD +106 -0
- multi_puzzle_solver-1.1.8.dist-info/WHEEL +5 -0
- multi_puzzle_solver-1.1.8.dist-info/top_level.txt +1 -0
- puzzle_solver/__init__.py +184 -0
- puzzle_solver/core/utils.py +298 -0
- puzzle_solver/core/utils_ortools.py +333 -0
- puzzle_solver/core/utils_visualizer.py +575 -0
- puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
- puzzle_solver/puzzles/aquarium/aquarium.py +97 -0
- puzzle_solver/puzzles/area_51/area_51.py +159 -0
- puzzle_solver/puzzles/battleships/battleships.py +139 -0
- puzzle_solver/puzzles/binairo/binairo.py +98 -0
- puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
- puzzle_solver/puzzles/black_box/black_box.py +243 -0
- puzzle_solver/puzzles/branches/branches.py +64 -0
- puzzle_solver/puzzles/bridges/bridges.py +104 -0
- puzzle_solver/puzzles/chess_range/chess_melee.py +6 -0
- puzzle_solver/puzzles/chess_range/chess_range.py +406 -0
- puzzle_solver/puzzles/chess_range/chess_solo.py +9 -0
- puzzle_solver/puzzles/chess_sequence/chess_sequence.py +262 -0
- puzzle_solver/puzzles/circle_9/circle_9.py +44 -0
- puzzle_solver/puzzles/clouds/clouds.py +81 -0
- puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
- puzzle_solver/puzzles/cow_and_cactus/cow_and_cactus.py +66 -0
- puzzle_solver/puzzles/dominosa/dominosa.py +67 -0
- puzzle_solver/puzzles/filling/filling.py +94 -0
- puzzle_solver/puzzles/flip/flip.py +64 -0
- puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
- puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +197 -0
- puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
- puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
- puzzle_solver/puzzles/guess/guess.py +232 -0
- puzzle_solver/puzzles/heyawake/heyawake.py +152 -0
- puzzle_solver/puzzles/hidden_stars/hidden_stars.py +52 -0
- puzzle_solver/puzzles/hidoku/hidoku.py +59 -0
- puzzle_solver/puzzles/inertia/inertia.py +121 -0
- puzzle_solver/puzzles/inertia/parse_map/parse_map.py +207 -0
- puzzle_solver/puzzles/inertia/tsp.py +400 -0
- puzzle_solver/puzzles/kakurasu/kakurasu.py +38 -0
- puzzle_solver/puzzles/kakuro/kakuro.py +81 -0
- puzzle_solver/puzzles/kakuro/krypto_kakuro.py +95 -0
- puzzle_solver/puzzles/keen/keen.py +76 -0
- puzzle_solver/puzzles/kropki/kropki.py +94 -0
- puzzle_solver/puzzles/light_up/light_up.py +58 -0
- puzzle_solver/puzzles/linesweeper/linesweeper.py +71 -0
- puzzle_solver/puzzles/link_a_pix/link_a_pix.py +91 -0
- puzzle_solver/puzzles/lits/lits.py +138 -0
- puzzle_solver/puzzles/magnets/magnets.py +96 -0
- puzzle_solver/puzzles/map/map.py +56 -0
- puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
- puzzle_solver/puzzles/mathrax/mathrax.py +93 -0
- puzzle_solver/puzzles/minesweeper/minesweeper.py +123 -0
- puzzle_solver/puzzles/mosaic/mosaic.py +38 -0
- puzzle_solver/puzzles/n_queens/n_queens.py +71 -0
- puzzle_solver/puzzles/nonograms/nonograms.py +121 -0
- puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -0
- puzzle_solver/puzzles/norinori/norinori.py +96 -0
- puzzle_solver/puzzles/number_path/number_path.py +76 -0
- puzzle_solver/puzzles/numbermaze/numbermaze.py +97 -0
- puzzle_solver/puzzles/nurikabe/nurikabe.py +130 -0
- puzzle_solver/puzzles/palisade/palisade.py +91 -0
- puzzle_solver/puzzles/pearl/pearl.py +107 -0
- puzzle_solver/puzzles/pipes/pipes.py +82 -0
- puzzle_solver/puzzles/range/range.py +59 -0
- puzzle_solver/puzzles/rectangles/rectangles.py +128 -0
- puzzle_solver/puzzles/ripple_effect/ripple_effect.py +83 -0
- puzzle_solver/puzzles/rooms/rooms.py +75 -0
- puzzle_solver/puzzles/schurs_numbers/schurs_numbers.py +73 -0
- puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
- puzzle_solver/puzzles/shingoki/shingoki.py +116 -0
- puzzle_solver/puzzles/signpost/signpost.py +93 -0
- puzzle_solver/puzzles/singles/singles.py +53 -0
- puzzle_solver/puzzles/slant/parse_map/parse_map.py +135 -0
- puzzle_solver/puzzles/slant/slant.py +111 -0
- puzzle_solver/puzzles/slitherlink/slitherlink.py +130 -0
- puzzle_solver/puzzles/snail/snail.py +97 -0
- puzzle_solver/puzzles/split_ends/split_ends.py +93 -0
- puzzle_solver/puzzles/star_battle/star_battle.py +75 -0
- puzzle_solver/puzzles/star_battle/star_battle_shapeless.py +7 -0
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +267 -0
- puzzle_solver/puzzles/stitches/stitches.py +96 -0
- puzzle_solver/puzzles/sudoku/sudoku.py +267 -0
- puzzle_solver/puzzles/suguru/suguru.py +55 -0
- puzzle_solver/puzzles/suko/suko.py +54 -0
- puzzle_solver/puzzles/tapa/tapa.py +97 -0
- puzzle_solver/puzzles/tatami/tatami.py +64 -0
- puzzle_solver/puzzles/tents/tents.py +80 -0
- puzzle_solver/puzzles/thermometers/thermometers.py +82 -0
- puzzle_solver/puzzles/towers/towers.py +89 -0
- puzzle_solver/puzzles/tracks/tracks.py +88 -0
- puzzle_solver/puzzles/trees_logic/trees_logic.py +48 -0
- puzzle_solver/puzzles/troix/dumplings.py +7 -0
- puzzle_solver/puzzles/troix/troix.py +75 -0
- puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
- puzzle_solver/puzzles/undead/undead.py +130 -0
- puzzle_solver/puzzles/unequal/unequal.py +128 -0
- puzzle_solver/puzzles/unruly/unruly.py +54 -0
- puzzle_solver/puzzles/vectors/vectors.py +94 -0
- puzzle_solver/puzzles/vermicelli/vermicelli.py +74 -0
- puzzle_solver/puzzles/walls/walls.py +52 -0
- puzzle_solver/puzzles/yajilin/yajilin.py +87 -0
- puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +172 -0
- puzzle_solver/puzzles/yin_yang/yin_yang.py +103 -0
- puzzle_solver/utils/etc/parser/board_color_digit.py +497 -0
- puzzle_solver/utils/visualizer.py +155 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from ortools.sat.python import cp_model
|
|
4
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
5
|
+
|
|
6
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, in_bounds, Direction, get_next_pos, get_pos
|
|
7
|
+
from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
|
|
8
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Board:
|
|
12
|
+
def __init__(self, board: np.array):
|
|
13
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
14
|
+
assert all(c.item() in [' ', 'B', 'W'] for c in np.nditer(board)), 'board must contain only space, B, or W'
|
|
15
|
+
self.board = board
|
|
16
|
+
self.V, self.H = board.shape
|
|
17
|
+
self.model = cp_model.CpModel()
|
|
18
|
+
self.B: dict[Pos, cp_model.IntVar] = {}
|
|
19
|
+
self.W: dict[Pos, cp_model.IntVar] = {}
|
|
20
|
+
|
|
21
|
+
self.create_vars()
|
|
22
|
+
self.add_all_constraints()
|
|
23
|
+
|
|
24
|
+
def create_vars(self):
|
|
25
|
+
for pos in get_all_pos(self.V, self.H):
|
|
26
|
+
self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
|
|
27
|
+
|
|
28
|
+
def add_all_constraints(self):
|
|
29
|
+
self.force_clues()
|
|
30
|
+
self.disallow_2x2()
|
|
31
|
+
self.disallow_checkers()
|
|
32
|
+
self.force_connected_component()
|
|
33
|
+
self.force_border_transitions()
|
|
34
|
+
|
|
35
|
+
def force_clues(self):
|
|
36
|
+
for pos in get_all_pos(self.V, self.H): # force clues
|
|
37
|
+
c = get_char(self.board, pos)
|
|
38
|
+
if c not in ['B', 'W']:
|
|
39
|
+
continue
|
|
40
|
+
self.model.Add(self.B[pos] == (c == 'B'))
|
|
41
|
+
|
|
42
|
+
def disallow_2x2(self):
|
|
43
|
+
for pos in get_all_pos(self.V, self.H): # disallow 2x2 (WW/WW) and (BB/BB)
|
|
44
|
+
tl = pos
|
|
45
|
+
tr = get_next_pos(pos, Direction.RIGHT)
|
|
46
|
+
bl = get_next_pos(pos, Direction.DOWN)
|
|
47
|
+
br = get_next_pos(bl, Direction.RIGHT)
|
|
48
|
+
if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
|
|
49
|
+
continue
|
|
50
|
+
self.model.AddBoolOr([self.B[tl], self.B[tr], self.B[bl], self.B[br]])
|
|
51
|
+
self.model.AddBoolOr([self.B[tl].Not(), self.B[tr].Not(), self.B[bl].Not(), self.B[br].Not()])
|
|
52
|
+
|
|
53
|
+
def disallow_checkers(self):
|
|
54
|
+
# from https://ralphwaldo.github.io/yinyang_summary.html
|
|
55
|
+
for pos in get_all_pos(self.V, self.H): # disallow (WB/BW) and (BW/WB)
|
|
56
|
+
tl = pos
|
|
57
|
+
tr = get_next_pos(pos, Direction.RIGHT)
|
|
58
|
+
bl = get_next_pos(pos, Direction.DOWN)
|
|
59
|
+
br = get_next_pos(bl, Direction.RIGHT)
|
|
60
|
+
if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
|
|
61
|
+
continue
|
|
62
|
+
self.model.AddBoolOr([self.B[tl], self.B[tr].Not(), self.B[bl].Not(), self.B[br]]) # disallow (WB/BW)
|
|
63
|
+
self.model.AddBoolOr([self.B[tl].Not(), self.B[tr], self.B[bl], self.B[br].Not()]) # disallow (BW/WB)
|
|
64
|
+
|
|
65
|
+
def force_connected_component(self):
|
|
66
|
+
# force single connected component for both colors
|
|
67
|
+
force_connected_component(self.model, self.B)
|
|
68
|
+
force_connected_component(self.model, {k: v.Not() for k, v in self.B.items()})
|
|
69
|
+
|
|
70
|
+
def force_border_transitions(self):
|
|
71
|
+
# from https://ralphwaldo.github.io/yinyang_summary.html
|
|
72
|
+
# The border cells cannot be split into four (or more) separate blocks of colours
|
|
73
|
+
# It is therefore either split into two blocks (one of each colour), or is just a single block of one colour or the other
|
|
74
|
+
border_cells = [] # go in a ring clockwise from top left
|
|
75
|
+
for x in range(self.H):
|
|
76
|
+
border_cells.append(get_pos(x=x, y=0))
|
|
77
|
+
for y in range(1, self.V):
|
|
78
|
+
border_cells.append(get_pos(x=self.H-1, y=y))
|
|
79
|
+
for x in range(self.H-2, -1, -1):
|
|
80
|
+
border_cells.append(get_pos(x=x, y=self.V-1))
|
|
81
|
+
for y in range(self.V-2, 0, -1):
|
|
82
|
+
border_cells.append(get_pos(x=0, y=y))
|
|
83
|
+
# tie the knot
|
|
84
|
+
border_cells.append(border_cells[0])
|
|
85
|
+
# unequal sum is 0 or 2
|
|
86
|
+
deltas = []
|
|
87
|
+
for i in range(len(border_cells)-1):
|
|
88
|
+
aux = self.model.NewBoolVar(f'border_transition_{i}') # i is black while i+1 is white
|
|
89
|
+
and_constraint(self.model, aux, [self.B[border_cells[i]], self.B[border_cells[i+1]].Not()])
|
|
90
|
+
deltas.append(aux)
|
|
91
|
+
self.model.Add(lxp.Sum(deltas) <= 1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def solve_and_print(self, verbose: bool = True):
|
|
95
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
96
|
+
assignment: dict[Pos, int] = {}
|
|
97
|
+
for pos, var in board.B.items():
|
|
98
|
+
assignment[pos] = 'B' if solver.BooleanValue(var) else 'W'
|
|
99
|
+
return SingleSolution(assignment=assignment)
|
|
100
|
+
def callback(single_res: SingleSolution):
|
|
101
|
+
print("Solution found")
|
|
102
|
+
print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 'B'))
|
|
103
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
import numpy as np
|
|
4
|
+
from PIL import Image
|
|
5
|
+
|
|
6
|
+
cv2 = None
|
|
7
|
+
plt = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DIGITS_DIR = Path(__file__).parent / "digits"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ------------------------------------------------------------------
|
|
14
|
+
# helpers
|
|
15
|
+
# ------------------------------------------------------------------
|
|
16
|
+
def cluster_positions(lines, axis=0, tol=3):
|
|
17
|
+
"""Group nearly identical x (or y) positions into one line."""
|
|
18
|
+
pts = []
|
|
19
|
+
for x1, y1, x2, y2 in lines:
|
|
20
|
+
pts.append(x1 if axis == 0 else y1)
|
|
21
|
+
pts = sorted(pts)
|
|
22
|
+
groups = []
|
|
23
|
+
current = [pts[0]]
|
|
24
|
+
for p in pts[1:]:
|
|
25
|
+
if abs(p - current[-1]) <= tol:
|
|
26
|
+
current.append(p)
|
|
27
|
+
else:
|
|
28
|
+
groups.append(current)
|
|
29
|
+
current = [p]
|
|
30
|
+
groups.append(current)
|
|
31
|
+
return [int(np.mean(g)) for g in groups]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def detect_color_simple(cell_bgr):
|
|
36
|
+
"""Roughly decide whether the cell text is orange, yellow or black."""
|
|
37
|
+
hsv = cv2.cvtColor(cell_bgr, cv2.COLOR_BGR2HSV)
|
|
38
|
+
h = hsv[:, :, 0]
|
|
39
|
+
s = hsv[:, :, 1]
|
|
40
|
+
v = hsv[:, :, 2]
|
|
41
|
+
# print(f'H: {h}')
|
|
42
|
+
# print(f'S: {s}')
|
|
43
|
+
# print(f'V: {v}')
|
|
44
|
+
|
|
45
|
+
mask = (h != 0) | (s != 0) | (v != 255)
|
|
46
|
+
if not np.any(mask):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
mh = int(np.mean(h[mask]))
|
|
50
|
+
ms = int(np.mean(s[mask]))
|
|
51
|
+
mv = int(np.mean(v[mask]))
|
|
52
|
+
# print('masked %: ', np.sum(mask) / mask.size * 100)
|
|
53
|
+
|
|
54
|
+
return (mh, ms, mv)
|
|
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
|
+
def normalize_cell(img_bgr, size=28):
|
|
105
|
+
"""
|
|
106
|
+
Take a BGR cell image, make it grayscale, binarize, and resize to a fixed size.
|
|
107
|
+
Returns a 2D uint8 array (0 or 255).
|
|
108
|
+
"""
|
|
109
|
+
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
|
|
110
|
+
|
|
111
|
+
# light background / dark text is common, so threshold with OTSU
|
|
112
|
+
# blur a bit first
|
|
113
|
+
_, bw = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
114
|
+
|
|
115
|
+
# Sometimes text becomes white on black, sometimes black on white.
|
|
116
|
+
# Let's make "ink" be black (0) and background be white (255).
|
|
117
|
+
# Heuristic: if the mean is low, invert.
|
|
118
|
+
if bw.mean() < 127:
|
|
119
|
+
bw = 255 - bw
|
|
120
|
+
|
|
121
|
+
# resize to fixed size
|
|
122
|
+
bw_resized = cv2.resize(bw, (size, size), interpolation=cv2.INTER_AREA)
|
|
123
|
+
|
|
124
|
+
return bw_resized
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@lru_cache(maxsize=1)
|
|
128
|
+
def _load_digit_templates(size=28):
|
|
129
|
+
"""
|
|
130
|
+
Load all images from ./digits/ and normalize them to the same shape.
|
|
131
|
+
Returns list of (name_without_ext, normalized_image, full_path).
|
|
132
|
+
Cached so we don’t reload every call — but if we add new digits,
|
|
133
|
+
we can clear the cache.
|
|
134
|
+
"""
|
|
135
|
+
entries = []
|
|
136
|
+
for p in DIGITS_DIR.iterdir():
|
|
137
|
+
if not p.is_file():
|
|
138
|
+
continue
|
|
139
|
+
if p.suffix.lower() not in [".png", ".jpg", ".jpeg", ".bmp"]:
|
|
140
|
+
continue
|
|
141
|
+
img = cv2.imread(str(p), cv2.IMREAD_GRAYSCALE)
|
|
142
|
+
if img is None:
|
|
143
|
+
continue
|
|
144
|
+
digit = int(p.stem.split("_")[0])
|
|
145
|
+
# ensure same size / format
|
|
146
|
+
if img.shape != (size, size):
|
|
147
|
+
img = cv2.resize(img, (size, size), interpolation=cv2.INTER_AREA)
|
|
148
|
+
entries.append((digit, img, p))
|
|
149
|
+
return entries
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _save_digit_image(norm_img, label, size=28):
|
|
153
|
+
"""
|
|
154
|
+
Save normalized img to ./digits/ as `{label}.png`.
|
|
155
|
+
If file exists, append a counter.
|
|
156
|
+
"""
|
|
157
|
+
label = str(label).strip()
|
|
158
|
+
out_path = DIGITS_DIR / f"{label}.png"
|
|
159
|
+
if out_path.exists():
|
|
160
|
+
# find a free name like label_1.png, label_2.png, ...
|
|
161
|
+
i = 1
|
|
162
|
+
while True:
|
|
163
|
+
cand = DIGITS_DIR / f"{label}_{i}.png"
|
|
164
|
+
if not cand.exists():
|
|
165
|
+
out_path = cand
|
|
166
|
+
break
|
|
167
|
+
i += 1
|
|
168
|
+
|
|
169
|
+
cv2.imwrite(str(out_path), norm_img)
|
|
170
|
+
# refresh cache since we added a new file
|
|
171
|
+
_load_digit_templates.cache_clear()
|
|
172
|
+
return out_path
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def classify_digit(cell_bgr, visualize=False, size=28, dist_thresh=500):
|
|
176
|
+
"""
|
|
177
|
+
1. normalize the cell
|
|
178
|
+
2. compare pixel-wise to files in ./digits/
|
|
179
|
+
3. if close enough -> return that filename (digit)
|
|
180
|
+
4. else -> ask user in terminal, save, return label
|
|
181
|
+
|
|
182
|
+
Returns the recognized digit as string.
|
|
183
|
+
"""
|
|
184
|
+
norm = normalize_cell(cell_bgr, size=size)
|
|
185
|
+
|
|
186
|
+
# load templates
|
|
187
|
+
templates = _load_digit_templates(size=size)
|
|
188
|
+
|
|
189
|
+
best_label = None
|
|
190
|
+
best_dist = float("inf")
|
|
191
|
+
|
|
192
|
+
if templates:
|
|
193
|
+
for label, tmpl_img, _ in templates:
|
|
194
|
+
# simple pixel-wise L1 distance
|
|
195
|
+
dist = np.sum(np.abs(norm.astype(np.int16) - tmpl_img.astype(np.int16)))
|
|
196
|
+
if dist < best_dist:
|
|
197
|
+
best_dist = dist
|
|
198
|
+
best_label = label
|
|
199
|
+
|
|
200
|
+
# if it's close enough, accept
|
|
201
|
+
if best_dist <= dist_thresh:
|
|
202
|
+
return best_label
|
|
203
|
+
|
|
204
|
+
# otherwise we need user input
|
|
205
|
+
if visualize:
|
|
206
|
+
print(f"Unknown digit encountered., best_label: {best_label}, best_dist: {best_dist}, dist_thresh: {dist_thresh}")
|
|
207
|
+
show_wait_destroy("unknown digit", norm)
|
|
208
|
+
|
|
209
|
+
print("\n[Digit classifier] Unknown digit encountered.")
|
|
210
|
+
print(f"Best match dist = {best_dist:.0f} (threshold={dist_thresh}), so asking user.")
|
|
211
|
+
user_label = input("Enter the digit/label for this cell (e.g. 0-9 or letter): ").strip()
|
|
212
|
+
if not user_label:
|
|
213
|
+
user_label = "unknown"
|
|
214
|
+
|
|
215
|
+
saved_path = _save_digit_image(norm, user_label, size=size)
|
|
216
|
+
print(f"Saved new digit template to: {saved_path}")
|
|
217
|
+
|
|
218
|
+
return user_label
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def show_wait_destroy(winname, img):
|
|
222
|
+
cv2.imshow(winname, img)
|
|
223
|
+
cv2.moveWindow(winname, 500, 1000)
|
|
224
|
+
cv2.waitKey(0)
|
|
225
|
+
cv2.destroyWindow(winname)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def cluster_triplets(
|
|
230
|
+
triplets,
|
|
231
|
+
k_min=2,
|
|
232
|
+
k_max=8,
|
|
233
|
+
dim_reduce="pca",
|
|
234
|
+
random_state=42,
|
|
235
|
+
show_plot=True,):
|
|
236
|
+
from sklearn.cluster import KMeans
|
|
237
|
+
from sklearn.decomposition import PCA
|
|
238
|
+
from sklearn.manifold import TSNE
|
|
239
|
+
|
|
240
|
+
X = np.array(triplets, dtype=float)
|
|
241
|
+
n_samples = X.shape[0]
|
|
242
|
+
k_max = min(k_max, n_samples)
|
|
243
|
+
ks = list(range(k_min, k_max + 1))
|
|
244
|
+
|
|
245
|
+
sse = []
|
|
246
|
+
kmeans_models = {}
|
|
247
|
+
for k in ks:
|
|
248
|
+
km = KMeans(n_clusters=k, n_init="auto", random_state=random_state)
|
|
249
|
+
km.fit(X)
|
|
250
|
+
sse.append(km.inertia_)
|
|
251
|
+
kmeans_models[k] = km
|
|
252
|
+
|
|
253
|
+
x1, y1 = ks[0], sse[0]
|
|
254
|
+
x2, y2 = ks[-1], sse[-1]
|
|
255
|
+
distances = []
|
|
256
|
+
for x, y in zip(ks, sse):
|
|
257
|
+
num = abs((y2 - y1) * x - (x2 - x1) * y + x2*y1 - y2*x1)
|
|
258
|
+
den = np.sqrt((y2 - y1)**2 + (x2 - x1)**2)
|
|
259
|
+
distances.append(num / den)
|
|
260
|
+
|
|
261
|
+
best_idx = int(np.argmax(distances))
|
|
262
|
+
best_k = ks[best_idx]
|
|
263
|
+
best_model = kmeans_models[best_k]
|
|
264
|
+
labels = best_model.labels_
|
|
265
|
+
|
|
266
|
+
if show_plot:
|
|
267
|
+
if dim_reduce.lower() == "tsne":
|
|
268
|
+
reducer = TSNE(n_components=2, random_state=random_state, init="pca")
|
|
269
|
+
X_2d = reducer.fit_transform(X)
|
|
270
|
+
else:
|
|
271
|
+
reducer = PCA(n_components=2, random_state=random_state)
|
|
272
|
+
X_2d = reducer.fit_transform(X)
|
|
273
|
+
plt.figure(figsize=(6, 5))
|
|
274
|
+
scatter = plt.scatter(
|
|
275
|
+
X_2d[:, 0], X_2d[:, 1],
|
|
276
|
+
c=labels,
|
|
277
|
+
s=50
|
|
278
|
+
)
|
|
279
|
+
plt.title(f"Clusters (k={best_k})")
|
|
280
|
+
plt.xlabel("Component 1")
|
|
281
|
+
plt.ylabel("Component 2")
|
|
282
|
+
plt.colorbar(scatter, label="Cluster")
|
|
283
|
+
plt.tight_layout()
|
|
284
|
+
plt.show()
|
|
285
|
+
|
|
286
|
+
return labels, best_k
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def parse_board(image_path):
|
|
291
|
+
"""Main function: detects grid, loops through cells, returns 2D array."""
|
|
292
|
+
global cv2, plt
|
|
293
|
+
|
|
294
|
+
VISUALIZE_LINES = False
|
|
295
|
+
|
|
296
|
+
import cv2
|
|
297
|
+
import matplotlib.pyplot as plt
|
|
298
|
+
assert image_path.exists(), f"Image file does not exist: {image_path}"
|
|
299
|
+
if image_path.suffix.lower() == '.pdf':
|
|
300
|
+
image_bgr = pdf_to_cv_img(image_path)
|
|
301
|
+
else:
|
|
302
|
+
image_bgr = cv2.imread(image_path)
|
|
303
|
+
gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
|
|
304
|
+
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
|
|
305
|
+
if VISUALIZE_LINES:
|
|
306
|
+
show_wait_destroy("edges", edges)
|
|
307
|
+
lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=80, minLineLength=150, maxLineGap=5)
|
|
308
|
+
vert, horiz = [], []
|
|
309
|
+
for l in lines:
|
|
310
|
+
x1, y1, x2, y2 = l[0]
|
|
311
|
+
if abs(x1 - x2) < 5:
|
|
312
|
+
vert.append((x1, y1, x2, y2))
|
|
313
|
+
elif abs(y1 - y2) < 5:
|
|
314
|
+
horiz.append((x1, y1, x2, y2))
|
|
315
|
+
|
|
316
|
+
# add_borders = False
|
|
317
|
+
add_borders = True
|
|
318
|
+
clip_ends = False
|
|
319
|
+
# clip_ends = True
|
|
320
|
+
|
|
321
|
+
x_lines = cluster_positions(vert, axis=0, tol=3)
|
|
322
|
+
y_lines = cluster_positions(horiz, axis=1, tol=3)
|
|
323
|
+
if add_borders:
|
|
324
|
+
x_lines = [0] + x_lines + [image_bgr.shape[1]]
|
|
325
|
+
y_lines = [0] + y_lines + [image_bgr.shape[0]]
|
|
326
|
+
if clip_ends:
|
|
327
|
+
x_lines = x_lines[1:-1]
|
|
328
|
+
y_lines = y_lines[1:-1]
|
|
329
|
+
print(f"x_lines: {x_lines}")
|
|
330
|
+
print(f"y_lines: {y_lines}")
|
|
331
|
+
# visualize the lines
|
|
332
|
+
if VISUALIZE_LINES:
|
|
333
|
+
for x in x_lines:
|
|
334
|
+
cv2.line(image_bgr, (x, 0), (x, image_bgr.shape[0]), (0, 0, 255), 2)
|
|
335
|
+
for y in y_lines:
|
|
336
|
+
cv2.line(image_bgr, (0, y), (image_bgr.shape[1], y), (0, 0, 255), 2)
|
|
337
|
+
show_wait_destroy("image_bgr", image_bgr)
|
|
338
|
+
|
|
339
|
+
n_rows = len(y_lines) - 1
|
|
340
|
+
n_cols = len(x_lines) - 1
|
|
341
|
+
colors = []
|
|
342
|
+
cell_ri_ci = []
|
|
343
|
+
digit_ri_ci = []
|
|
344
|
+
for ri in range(n_rows):
|
|
345
|
+
print('--------------------------------')
|
|
346
|
+
print(f'row: {ri}')
|
|
347
|
+
for ci in range(n_cols):
|
|
348
|
+
print(f'percentage complete: {(ri * n_cols + ci) / (n_rows * n_cols):.1%}')
|
|
349
|
+
# print(f"ri: {ri}, ci: {ci}")
|
|
350
|
+
y1, y2 = y_lines[ri], y_lines[ri + 1]
|
|
351
|
+
x1, x2 = x_lines[ci], x_lines[ci + 1]
|
|
352
|
+
if y2 - y1 <= 0 or x2 - x1 <= 0:
|
|
353
|
+
continue
|
|
354
|
+
cell = image_bgr[y1:y2, x1:x2]
|
|
355
|
+
|
|
356
|
+
# make a mask for “anything that looks like text”
|
|
357
|
+
hsv = cv2.cvtColor(cell, cv2.COLOR_BGR2HSV)
|
|
358
|
+
s = hsv[:, :, 1]
|
|
359
|
+
v = hsv[:, :, 2]
|
|
360
|
+
mask = ((s > 20) | (v < 210)).astype(np.uint8) * 255
|
|
361
|
+
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8), iterations=1)
|
|
362
|
+
|
|
363
|
+
cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
364
|
+
cell_area = cell.shape[0] * cell.shape[1]
|
|
365
|
+
|
|
366
|
+
parts = []
|
|
367
|
+
for c in cnts:
|
|
368
|
+
x, y, wc, hc = cv2.boundingRect(c)
|
|
369
|
+
# print(f"x: {x}, y: {y}, wc: {wc}, hc: {hc}")
|
|
370
|
+
if wc * hc > cell_area * 0.6:
|
|
371
|
+
continue
|
|
372
|
+
if wc * hc < 20:
|
|
373
|
+
continue
|
|
374
|
+
if wc < cell.shape[1] * 0.1:
|
|
375
|
+
continue
|
|
376
|
+
if hc < cell.shape[0] * 0.1:
|
|
377
|
+
continue
|
|
378
|
+
parts.append((x, y, wc, hc))
|
|
379
|
+
|
|
380
|
+
if not parts:
|
|
381
|
+
# print(f"no parts for ri: {ri}, ci: {ci}")
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
color = detect_color_simple(cell)
|
|
385
|
+
assert color is not None, f"color is None for ri: {ri}, ci: {ci}"
|
|
386
|
+
colors.append(color)
|
|
387
|
+
cell_ri_ci.append((ri, ci))
|
|
388
|
+
digit = classify_digit(cell, visualize=True)
|
|
389
|
+
digit_ri_ci.append((ri, ci, digit))
|
|
390
|
+
print(f"digit: {digit}")
|
|
391
|
+
# show_wait_destroy("cell", cell)
|
|
392
|
+
|
|
393
|
+
min_ri = min(ri for ri, _ in cell_ri_ci)
|
|
394
|
+
max_ri = max(ri for ri, _ in cell_ri_ci)
|
|
395
|
+
min_ci = min(ci for _, ci in cell_ri_ci)
|
|
396
|
+
max_ci = max(ci for _, ci in cell_ri_ci)
|
|
397
|
+
num_r = max_ri - min_ri + 1
|
|
398
|
+
num_c = max_ci - min_ci + 1
|
|
399
|
+
|
|
400
|
+
# print(f"colors: {colors}")
|
|
401
|
+
print(len(colors))
|
|
402
|
+
# print(colors)
|
|
403
|
+
l, k = cluster_triplets(colors, show_plot=False)
|
|
404
|
+
random_colors = [tuple(int(x*8)+128 for x in np.random.randint(0, 128//8, 3)) for _ in range(k)]
|
|
405
|
+
board = np.full((num_r, num_c), '', dtype=object)
|
|
406
|
+
for i, (ri, ci) in enumerate(cell_ri_ci):
|
|
407
|
+
label = l[i]
|
|
408
|
+
digit = digit_ri_ci[i][2]
|
|
409
|
+
board[ri-min_ri, ci-min_ci] = f"{label}_{digit}"
|
|
410
|
+
# y1, y2 = y_lines[ri], y_lines[ri + 1]
|
|
411
|
+
# x1, x2 = x_lines[ci], x_lines[ci + 1]
|
|
412
|
+
# circle_x = (x1 + x2) // 2
|
|
413
|
+
# circle_y = (y1 + y2) // 2
|
|
414
|
+
# circle_radius = min(x2 - x1, y2 - y1) // 2
|
|
415
|
+
# cv2.circle(image_bgr, (circle_x, circle_y), circle_radius, random_colors[label], 30)
|
|
416
|
+
# for i, (ri, ci) in enumerate(cell_ri_ci):
|
|
417
|
+
# digit = digit_ri_ci[i][2]
|
|
418
|
+
# y1, y2 = y_lines[ri], y_lines[ri + 1]
|
|
419
|
+
# x1, x2 = x_lines[ci], x_lines[ci + 1]
|
|
420
|
+
# circle_x = (x1 + x2) // 2
|
|
421
|
+
# circle_y = (y1 + y2) // 2
|
|
422
|
+
# cv2.putText(image_bgr, str(digit), (circle_x-5, circle_y+5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (250, 0, 0), 2)
|
|
423
|
+
# show_wait_destroy("image", image_bgr)
|
|
424
|
+
print(board.shape)
|
|
425
|
+
print('[')
|
|
426
|
+
for row in board:
|
|
427
|
+
row = [f"'{c}'" + ' ' * (4 - len(c)) for c in row]
|
|
428
|
+
print(" [ " + ", ".join(row) + " ],")
|
|
429
|
+
print(' ]')
|
|
430
|
+
return board
|
|
431
|
+
|
|
432
|
+
def pdf_to_cv_img(pdf_path, dpi=200, page=0):
|
|
433
|
+
"""
|
|
434
|
+
Convert a PDF page to a CV2 BGR image using a given dpi (default 200).
|
|
435
|
+
Uses pdf2image to do the conversion. Assumes pdf2image and pillow are installed.
|
|
436
|
+
Returns a numpy (H, W, 3) dtype=uint8 BGR (cv2) image.
|
|
437
|
+
"""
|
|
438
|
+
from pdf2image import convert_from_path
|
|
439
|
+
|
|
440
|
+
# Convert PDF to PIL Images, pick specified page
|
|
441
|
+
pil_images = convert_from_path(str(pdf_path), dpi=dpi, first_page=page+1, last_page=page+1)
|
|
442
|
+
if not pil_images:
|
|
443
|
+
raise RuntimeError(f"No pages rendered from {pdf_path}")
|
|
444
|
+
|
|
445
|
+
pil_img = pil_images[0]
|
|
446
|
+
# Convert PIL RGB image to numpy array, then to BGR for OpenCV
|
|
447
|
+
img = np.array(pil_img)
|
|
448
|
+
if img.ndim == 2: # grayscale, expand to 3 channels
|
|
449
|
+
img = np.stack([img]*3, axis=-1)
|
|
450
|
+
elif img.shape[2] == 4:
|
|
451
|
+
img = img[:,:,:3]
|
|
452
|
+
# PIL is RGB; OpenCV expects BGR
|
|
453
|
+
img = img[:, :, ::-1].copy()
|
|
454
|
+
return img
|
|
455
|
+
|
|
456
|
+
def mark_white_runs(inp_path, out_path):
|
|
457
|
+
img = Image.open(inp_path).convert("RGB")
|
|
458
|
+
img_to_edit = Image.open(inp_path).convert("RGB")
|
|
459
|
+
px = img.load()
|
|
460
|
+
px_to_edit = img_to_edit.load()
|
|
461
|
+
w, h = img.size
|
|
462
|
+
# vertical
|
|
463
|
+
x = 0
|
|
464
|
+
while x < w:
|
|
465
|
+
if all(px[x, y] == (255, 255, 255) for y in range(h)):
|
|
466
|
+
start = x
|
|
467
|
+
x += 1
|
|
468
|
+
while x < w and all(px[x, y] == (255, 255, 255) for y in range(h)):
|
|
469
|
+
x += 1
|
|
470
|
+
mid = (start + x - 1) // 2
|
|
471
|
+
for y in range(h): px_to_edit[mid, y] = (0, 0, 0)
|
|
472
|
+
else:
|
|
473
|
+
x += 1
|
|
474
|
+
# horizontal
|
|
475
|
+
y = 0
|
|
476
|
+
while y < h:
|
|
477
|
+
if all(px[x, y] == (255, 255, 255) for x in range(w)):
|
|
478
|
+
start = y
|
|
479
|
+
y += 1
|
|
480
|
+
while y < h and all(px[x, y] == (255, 255, 255) for x in range(w)):
|
|
481
|
+
y += 1
|
|
482
|
+
mid = (start + y - 1) // 2
|
|
483
|
+
for x in range(w): px_to_edit[x, mid] = (0, 0, 0)
|
|
484
|
+
else:
|
|
485
|
+
y += 1
|
|
486
|
+
img_to_edit.save(out_path)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
if __name__ == "__main__":
|
|
490
|
+
# python .\src\puzzle_solver\utils\etc\parser\board_color_digit.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
|
|
491
|
+
# board = parse_board(Path(__file__).parent / 'board.png')
|
|
492
|
+
# board = parse_board(Path(__file__).parent / 'Screenshot 2025-11-04 025046.png')
|
|
493
|
+
# board = parse_board(Path(__file__).parent / 'Screenshot 2025-11-04 030025.png')
|
|
494
|
+
inp = Path(__file__).parent / 'Screenshot 2025-11-05 at 18-41-57 Special Monthly Dominosa.png'
|
|
495
|
+
outp = inp.with_suffix('.marked.png')
|
|
496
|
+
mark_white_runs(inp, outp)
|
|
497
|
+
board = parse_board(outp)
|