multi-puzzle-solver 0.9.18__py3-none-any.whl → 0.9.22__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.
Potentially problematic release.
This version of multi-puzzle-solver might be problematic. Click here for more details.
- {multi_puzzle_solver-0.9.18.dist-info → multi_puzzle_solver-0.9.22.dist-info}/METADATA +339 -3
- {multi_puzzle_solver-0.9.18.dist-info → multi_puzzle_solver-0.9.22.dist-info}/RECORD +11 -8
- puzzle_solver/__init__.py +4 -1
- puzzle_solver/puzzles/norinori/norinori.py +66 -220
- puzzle_solver/puzzles/slitherlink/slitherlink.py +248 -0
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +36 -3
- puzzle_solver/puzzles/sudoku/sudoku.py +136 -23
- puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +169 -0
- puzzle_solver/puzzles/yin_yang/yin_yang.py +110 -0
- {multi_puzzle_solver-0.9.18.dist-info → multi_puzzle_solver-0.9.22.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-0.9.18.dist-info → multi_puzzle_solver-0.9.22.dist-info}/top_level.txt +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
from typing import Union
|
|
1
|
+
from typing import Union, Optional
|
|
2
2
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
from ortools.sat.python import cp_model
|
|
5
5
|
|
|
6
6
|
from puzzle_solver.core.utils import Pos, get_pos, get_all_pos, get_char, set_char, get_row_pos, get_col_pos
|
|
7
|
-
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
7
|
+
from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, or_constraint, SingleSolution
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def get_value(board: np.array, pos: Pos) -> Union[int, str]:
|
|
11
|
-
c = get_char(board, pos)
|
|
11
|
+
c = get_char(board, pos).lower()
|
|
12
12
|
if c == ' ':
|
|
13
13
|
return c
|
|
14
14
|
if str(c).isdecimal():
|
|
@@ -27,22 +27,40 @@ def set_value(board: np.array, pos: Pos, value: Union[int, str]):
|
|
|
27
27
|
set_char(board, pos, value)
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
def get_block_pos(i: int,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
def get_block_pos(i: int, Bv: int, Bh: int) -> list[Pos]:
|
|
31
|
+
# Think: Bv=3 and Bh=4 while the board has 4 vertical blocks and 3 horizontal blocks
|
|
32
|
+
top_left_x = (i%Bv)*Bh
|
|
33
|
+
top_left_y = (i//Bv)*Bv
|
|
34
|
+
return [get_pos(x=top_left_x + x, y=top_left_y + y) for x in range(Bh) for y in range(Bv)]
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
class Board:
|
|
37
|
-
def __init__(self, board: np.array):
|
|
38
|
+
def __init__(self, board: np.array, block_size: Optional[tuple[int, int]] = None, sandwich: Optional[dict[str, list[int]]] = None, unique_diagonal: bool = False):
|
|
38
39
|
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
39
40
|
assert board.shape[0] == board.shape[1], 'board must be square'
|
|
40
41
|
assert all(isinstance(i.item(), str) and len(i.item()) == 1 and (i.item().isalnum() or i.item() == ' ') for i in np.nditer(board)), 'board must contain only alphanumeric characters or space'
|
|
41
42
|
self.board = board
|
|
42
|
-
self.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
self.V, self.H = board.shape
|
|
44
|
+
if block_size is None:
|
|
45
|
+
B = np.sqrt(self.V) # block size
|
|
46
|
+
assert B.is_integer(), 'board size must be a perfect square or provide block_size'
|
|
47
|
+
Bv, Bh = int(B), int(B)
|
|
48
|
+
else:
|
|
49
|
+
Bv, Bh = block_size
|
|
50
|
+
assert Bv * Bh == self.V, 'block size must be a factor of board size'
|
|
51
|
+
# can be different in 4x3 for example
|
|
52
|
+
self.Bv = Bv
|
|
53
|
+
self.Bh = Bh
|
|
54
|
+
self.B = Bv * Bh # block count
|
|
55
|
+
if sandwich is not None:
|
|
56
|
+
assert set(sandwich.keys()) == set(['side', 'bottom']), 'sandwich must contain only side and bottom'
|
|
57
|
+
assert len(sandwich['side']) == self.H, 'side must be equal to board width'
|
|
58
|
+
assert len(sandwich['bottom']) == self.V, 'bottom must be equal to board height'
|
|
59
|
+
self.sandwich = sandwich
|
|
60
|
+
else:
|
|
61
|
+
self.sandwich = None
|
|
62
|
+
self.unique_diagonal = unique_diagonal
|
|
63
|
+
|
|
46
64
|
self.model = cp_model.CpModel()
|
|
47
65
|
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
48
66
|
|
|
@@ -50,28 +68,50 @@ class Board:
|
|
|
50
68
|
self.add_all_constraints()
|
|
51
69
|
|
|
52
70
|
def create_vars(self):
|
|
53
|
-
for pos in get_all_pos(self.
|
|
54
|
-
self.model_vars[pos] = self.model.NewIntVar(1, self.
|
|
71
|
+
for pos in get_all_pos(self.V, self.H):
|
|
72
|
+
self.model_vars[pos] = self.model.NewIntVar(1, self.B, f'{pos}')
|
|
55
73
|
|
|
56
74
|
def add_all_constraints(self):
|
|
57
75
|
# some squares are already filled
|
|
58
|
-
for pos in get_all_pos(self.
|
|
76
|
+
for pos in get_all_pos(self.V, self.H):
|
|
59
77
|
c = get_value(self.board, pos)
|
|
60
78
|
if c != ' ':
|
|
61
79
|
self.model.Add(self.model_vars[pos] == c)
|
|
62
80
|
# every number appears exactly once in each row, each column and each block
|
|
63
81
|
# each row
|
|
64
|
-
for row in range(self.
|
|
65
|
-
row_vars = [self.model_vars[pos] for pos in get_row_pos(row, self.
|
|
82
|
+
for row in range(self.V):
|
|
83
|
+
row_vars = [self.model_vars[pos] for pos in get_row_pos(row, H=self.H)]
|
|
66
84
|
self.model.AddAllDifferent(row_vars)
|
|
67
85
|
# each column
|
|
68
|
-
for col in range(self.
|
|
69
|
-
col_vars = [self.model_vars[pos] for pos in get_col_pos(col, self.
|
|
86
|
+
for col in range(self.H):
|
|
87
|
+
col_vars = [self.model_vars[pos] for pos in get_col_pos(col, V=self.V)]
|
|
70
88
|
self.model.AddAllDifferent(col_vars)
|
|
71
89
|
# each block
|
|
72
|
-
for block_i in range(self.
|
|
73
|
-
block_vars = [self.model_vars[p] for p in get_block_pos(block_i, self.
|
|
90
|
+
for block_i in range(self.B):
|
|
91
|
+
block_vars = [self.model_vars[p] for p in get_block_pos(block_i, Bv=self.Bv, Bh=self.Bh)]
|
|
74
92
|
self.model.AddAllDifferent(block_vars)
|
|
93
|
+
if self.sandwich is not None:
|
|
94
|
+
self.add_sandwich_constraints()
|
|
95
|
+
if self.unique_diagonal:
|
|
96
|
+
self.add_unique_diagonal_constraints()
|
|
97
|
+
|
|
98
|
+
def add_sandwich_constraints(self):
|
|
99
|
+
for c, clue in enumerate(self.sandwich['bottom']):
|
|
100
|
+
if clue is None or int(clue) < 0:
|
|
101
|
+
continue
|
|
102
|
+
col_vars = [self.model_vars[p] for p in get_col_pos(c, V=self.V)]
|
|
103
|
+
add_single_sandwich(col_vars, int(clue), self.model, name=f"sand_side_{c}")
|
|
104
|
+
for r, clue in enumerate(self.sandwich['side']):
|
|
105
|
+
if clue is None or int(clue) < 0:
|
|
106
|
+
continue
|
|
107
|
+
row_vars = [self.model_vars[p] for p in get_row_pos(r, H=self.H)]
|
|
108
|
+
add_single_sandwich(row_vars, int(clue), self.model, name=f"sand_bottom_{r}")
|
|
109
|
+
|
|
110
|
+
def add_unique_diagonal_constraints(self):
|
|
111
|
+
main_diagonal_vars = [self.model_vars[get_pos(x=i, y=i)] for i in range(min(self.V, self.H))]
|
|
112
|
+
self.model.AddAllDifferent(main_diagonal_vars)
|
|
113
|
+
anti_diagonal_vars = [self.model_vars[get_pos(x=i, y=self.V-i-1)] for i in range(min(self.V, self.H))]
|
|
114
|
+
self.model.AddAllDifferent(anti_diagonal_vars)
|
|
75
115
|
|
|
76
116
|
def solve_and_print(self, verbose: bool = True):
|
|
77
117
|
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
@@ -81,10 +121,83 @@ class Board:
|
|
|
81
121
|
return SingleSolution(assignment=assignment)
|
|
82
122
|
def callback(single_res: SingleSolution):
|
|
83
123
|
print("Solution found")
|
|
84
|
-
res = np.full((self.
|
|
85
|
-
for pos in get_all_pos(self.
|
|
124
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
125
|
+
for pos in get_all_pos(self.V, self.H):
|
|
86
126
|
c = get_value(self.board, pos)
|
|
87
127
|
c = single_res.assignment[pos]
|
|
88
128
|
set_value(res, pos, c)
|
|
89
129
|
print(res)
|
|
90
130
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def add_single_sandwich(vars_line: list[cp_model.IntVar], clue: int, model: cp_model.CpModel, name: str):
|
|
135
|
+
# VAR count:
|
|
136
|
+
# is_min: L
|
|
137
|
+
# is_max: L
|
|
138
|
+
# pos_min/max/lt: 1+1+1
|
|
139
|
+
# between: L
|
|
140
|
+
# a1/a2/case_a: L+L+L
|
|
141
|
+
# b1/b2/case_b: L+L+L
|
|
142
|
+
# take: L
|
|
143
|
+
# 10L+3 per 1 call of the function (i.e. per 1 line)
|
|
144
|
+
# entire board will have 2L lines (rows and columns)
|
|
145
|
+
# in total: 20L^2+6L
|
|
146
|
+
|
|
147
|
+
L = len(vars_line)
|
|
148
|
+
is_min = [model.NewBoolVar(f"{name}_ismin_{i}") for i in range(L)]
|
|
149
|
+
is_max = [model.NewBoolVar(f"{name}_ismax_{i}") for i in range(L)]
|
|
150
|
+
for i, v in enumerate(vars_line):
|
|
151
|
+
model.Add(v == 1).OnlyEnforceIf(is_min[i])
|
|
152
|
+
model.Add(v != 1).OnlyEnforceIf(is_min[i].Not())
|
|
153
|
+
model.Add(v == L).OnlyEnforceIf(is_max[i])
|
|
154
|
+
model.Add(v != L).OnlyEnforceIf(is_max[i].Not())
|
|
155
|
+
|
|
156
|
+
# index of the minimum and maximum values (sum of the values inbetween must = clue)
|
|
157
|
+
pos_min = model.NewIntVar(0, L - 1, f"{name}_pos_min")
|
|
158
|
+
pos_max = model.NewIntVar(0, L - 1, f"{name}_pos_max")
|
|
159
|
+
model.Add(pos_min == sum(i * is_min[i] for i in range(L)))
|
|
160
|
+
model.Add(pos_max == sum(i * is_max[i] for i in range(L)))
|
|
161
|
+
|
|
162
|
+
# used later to handle both cases (A. pos_min < pos_max and B. pos_max < pos_min)
|
|
163
|
+
lt = model.NewBoolVar(f"{name}_lt") # pos_min < pos_max ?
|
|
164
|
+
model.Add(pos_min < pos_max).OnlyEnforceIf(lt)
|
|
165
|
+
model.Add(pos_min >= pos_max).OnlyEnforceIf(lt.Not())
|
|
166
|
+
|
|
167
|
+
between = [model.NewBoolVar(f"{name}_between_{i}") for i in range(L)]
|
|
168
|
+
for i in range(L):
|
|
169
|
+
# Case A: pos_min < i < pos_max (AND lt is true)
|
|
170
|
+
a1 = model.NewBoolVar(f"{name}_a1_{i}") # pos_min < i
|
|
171
|
+
a2 = model.NewBoolVar(f"{name}_a2_{i}") # i < pos_max
|
|
172
|
+
|
|
173
|
+
model.Add(pos_min < i).OnlyEnforceIf(a1)
|
|
174
|
+
model.Add(pos_min >= i).OnlyEnforceIf(a1.Not())
|
|
175
|
+
model.Add(i < pos_max).OnlyEnforceIf(a2)
|
|
176
|
+
model.Add(i >= pos_max).OnlyEnforceIf(a2.Not())
|
|
177
|
+
|
|
178
|
+
case_a = model.NewBoolVar(f"{name}_caseA_{i}")
|
|
179
|
+
and_constraint(model, case_a, [lt, a1, a2])
|
|
180
|
+
|
|
181
|
+
# Case B: pos_max < i < pos_min (AND lt is false)
|
|
182
|
+
b1 = model.NewBoolVar(f"{name}_b1_{i}") # pos_max < i
|
|
183
|
+
b2 = model.NewBoolVar(f"{name}_b2_{i}") # i < pos_min
|
|
184
|
+
|
|
185
|
+
model.Add(pos_max < i).OnlyEnforceIf(b1)
|
|
186
|
+
model.Add(pos_max >= i).OnlyEnforceIf(b1.Not())
|
|
187
|
+
model.Add(i < pos_min).OnlyEnforceIf(b2)
|
|
188
|
+
model.Add(i >= pos_min).OnlyEnforceIf(b2.Not())
|
|
189
|
+
|
|
190
|
+
case_b = model.NewBoolVar(f"{name}_caseB_{i}")
|
|
191
|
+
and_constraint(model, case_b, [lt.Not(), b1, b2])
|
|
192
|
+
|
|
193
|
+
# between[i] is true if we're in case A or case B
|
|
194
|
+
or_constraint(model, between[i], [case_a, case_b])
|
|
195
|
+
|
|
196
|
+
# sum values at indices that are "between"
|
|
197
|
+
take = [model.NewIntVar(0, L, f"{name}_take_{i}") for i in range(L)]
|
|
198
|
+
for i, v in enumerate(vars_line):
|
|
199
|
+
# take[i] = v if between[i] else 0
|
|
200
|
+
model.Add(take[i] == v).OnlyEnforceIf(between[i])
|
|
201
|
+
model.Add(take[i] == 0).OnlyEnforceIf(between[i].Not())
|
|
202
|
+
|
|
203
|
+
model.Add(sum(take) == clue)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
|
|
2
|
+
def extract_lines(bw):
|
|
3
|
+
horizontal = np.copy(bw)
|
|
4
|
+
vertical = np.copy(bw)
|
|
5
|
+
|
|
6
|
+
cols = horizontal.shape[1]
|
|
7
|
+
horizontal_size = max(5, cols // 20)
|
|
8
|
+
h_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (horizontal_size, 1))
|
|
9
|
+
horizontal = cv2.erode(horizontal, h_kernel)
|
|
10
|
+
horizontal = cv2.dilate(horizontal, h_kernel)
|
|
11
|
+
h_means = np.mean(horizontal, axis=1)
|
|
12
|
+
h_idx = np.where(h_means > np.percentile(h_means, 70))[0]
|
|
13
|
+
|
|
14
|
+
rows = vertical.shape[0]
|
|
15
|
+
verticalsize = max(5, rows // 20)
|
|
16
|
+
v_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, verticalsize))
|
|
17
|
+
vertical = cv2.erode(vertical, v_kernel)
|
|
18
|
+
vertical = cv2.dilate(vertical, v_kernel)
|
|
19
|
+
v_means = np.mean(vertical, axis=0)
|
|
20
|
+
v_idx = np.where(v_means > np.percentile(v_means, 70))[0]
|
|
21
|
+
return h_idx, v_idx
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cluster_line_indices(indices, min_run=3):
|
|
25
|
+
"""Group consecutive indices into line positions (take the mean of each run)."""
|
|
26
|
+
if len(indices) == 0:
|
|
27
|
+
return []
|
|
28
|
+
indices = np.sort(indices)
|
|
29
|
+
runs = []
|
|
30
|
+
run = [indices[0]]
|
|
31
|
+
for k in indices[1:]:
|
|
32
|
+
if k == run[-1] + 1:
|
|
33
|
+
run.append(k)
|
|
34
|
+
else:
|
|
35
|
+
if len(run) >= min_run:
|
|
36
|
+
runs.append(int(np.mean(run)))
|
|
37
|
+
run = [k]
|
|
38
|
+
if len(run) >= min_run:
|
|
39
|
+
runs.append(int(np.mean(run)))
|
|
40
|
+
# De-duplicate lines that are too close (rare)
|
|
41
|
+
dedup = []
|
|
42
|
+
for x in runs:
|
|
43
|
+
if not dedup or x - dedup[-1] > 2:
|
|
44
|
+
dedup.append(x)
|
|
45
|
+
return dedup
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def extract_yinyang_board(image_path, debug=False):
|
|
49
|
+
# Load and pre-process
|
|
50
|
+
img = cv2.imread(str(image_path))
|
|
51
|
+
assert img is not None, f"Failed to read image: {image_path}"
|
|
52
|
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
53
|
+
|
|
54
|
+
# Light grid lines → enhance lines using adaptive threshold
|
|
55
|
+
# (binary inverted so lines/dots become white)
|
|
56
|
+
bw = cv2.adaptiveThreshold(
|
|
57
|
+
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
58
|
+
cv2.THRESH_BINARY_INV, 35, 5
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Detect grid line indices (no guessing)
|
|
62
|
+
h_idx, v_idx = extract_lines(bw)
|
|
63
|
+
print(f"h_idx: {h_idx}")
|
|
64
|
+
print(f"v_idx: {v_idx}")
|
|
65
|
+
h_lines = h_idx
|
|
66
|
+
v_lines = v_idx
|
|
67
|
+
# h_lines = _cluster_line_indices(h_idx)
|
|
68
|
+
# v_lines = _cluster_line_indices(v_idx)
|
|
69
|
+
assert len(h_lines) >= 2 and len(v_lines) >= 2, "Could not detect grid lines"
|
|
70
|
+
|
|
71
|
+
# Cells are spans between successive grid lines
|
|
72
|
+
N_rows = len(h_lines) - 1
|
|
73
|
+
N_cols = len(v_lines) - 1
|
|
74
|
+
board = np.full((N_rows, N_cols), ' ', dtype='<U1')
|
|
75
|
+
|
|
76
|
+
# For robust per-cell analysis, also create a "dots" image with grid erased
|
|
77
|
+
# Remove thickened grid from bw
|
|
78
|
+
# Build masks for horizontal/vertical lines (reusing kernels sized by image dims)
|
|
79
|
+
cols = bw.shape[1]
|
|
80
|
+
rows = bw.shape[0]
|
|
81
|
+
h_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (max(5, cols // 20), 1))
|
|
82
|
+
v_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(5, rows // 20)))
|
|
83
|
+
horiz = cv2.morphologyEx(bw, cv2.MORPH_OPEN, h_kernel)
|
|
84
|
+
vert = cv2.morphologyEx(bw, cv2.MORPH_OPEN, v_kernel)
|
|
85
|
+
grid = cv2.bitwise_or(horiz, vert)
|
|
86
|
+
dots = cv2.bitwise_and(bw, cv2.bitwise_not(grid)) # mostly circles remain
|
|
87
|
+
|
|
88
|
+
# Iterate cells
|
|
89
|
+
print(f"N_rows: {N_rows}, N_cols: {N_cols}")
|
|
90
|
+
print(f"h_lines: {h_lines}")
|
|
91
|
+
print(f"v_lines: {v_lines}")
|
|
92
|
+
for r in range(N_rows):
|
|
93
|
+
y0, y1 = h_lines[r], h_lines[r + 1]
|
|
94
|
+
# shrink ROI to avoid line bleed
|
|
95
|
+
y0i = max(y0 + 2, 0)
|
|
96
|
+
y1i = max(min(y1 - 2, dots.shape[0]), y0i + 1)
|
|
97
|
+
for c in range(N_cols):
|
|
98
|
+
x0, x1 = v_lines[c], v_lines[c + 1]
|
|
99
|
+
x0i = max(x0 + 2, 0)
|
|
100
|
+
x1i = max(min(x1 - 2, dots.shape[1]), x0i + 1)
|
|
101
|
+
|
|
102
|
+
roi_gray = gray[y0i:y1i, x0i:x1i]
|
|
103
|
+
roi_dots = dots[y0i:y1i, x0i:x1i]
|
|
104
|
+
area = roi_dots.shape[0] * roi_dots.shape[1]
|
|
105
|
+
if area == 0:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# If no meaningful foreground, it's empty
|
|
109
|
+
fg_area = int(np.count_nonzero(roi_dots))
|
|
110
|
+
if fg_area < 0.03 * area:
|
|
111
|
+
board[r, c] = ' '
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Segment the largest blob (circle) inside the cell
|
|
115
|
+
contours, _ = cv2.findContours(roi_dots, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
116
|
+
if not contours:
|
|
117
|
+
board[r, c] = ' '
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
cnt = max(contours, key=cv2.contourArea)
|
|
121
|
+
if cv2.contourArea(cnt) < 0.02 * area:
|
|
122
|
+
board[r, c] = ' '
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
mask = np.zeros_like(roi_dots)
|
|
126
|
+
cv2.drawContours(mask, [cnt], -1, 255, thickness=-1)
|
|
127
|
+
|
|
128
|
+
mean_inside = float(cv2.mean(roi_gray, mask=mask)[0])
|
|
129
|
+
|
|
130
|
+
# Heuristic: black stones have dark interior; white stones bright interior
|
|
131
|
+
# (grid background is white; outlines contribute little to mean)
|
|
132
|
+
board[r, c] = 'B' if mean_inside < 150 else 'W'
|
|
133
|
+
non_empty_rows = []
|
|
134
|
+
non_empty_cols = []
|
|
135
|
+
for r in range(N_rows):
|
|
136
|
+
if not all(board[r, :] == ' '):
|
|
137
|
+
non_empty_rows.append(r)
|
|
138
|
+
for c in range(N_cols):
|
|
139
|
+
if not all(board[:, c] == ' '):
|
|
140
|
+
non_empty_cols.append(c)
|
|
141
|
+
board = board[non_empty_rows, :][:, non_empty_cols]
|
|
142
|
+
|
|
143
|
+
if debug:
|
|
144
|
+
for row in board:
|
|
145
|
+
print(row.tolist())
|
|
146
|
+
output_path = Path(__file__).parent / "input_output" / (image_path.stem + ".json")
|
|
147
|
+
with open(output_path, 'w') as f:
|
|
148
|
+
f.write('[\n')
|
|
149
|
+
for i, row in enumerate(board):
|
|
150
|
+
f.write(' ' + str(row.tolist()).replace("'", '"'))
|
|
151
|
+
if i != len(board) - 1:
|
|
152
|
+
f.write(',')
|
|
153
|
+
f.write('\n')
|
|
154
|
+
f.write(']')
|
|
155
|
+
print('output json: ', output_path)
|
|
156
|
+
|
|
157
|
+
return board
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
# python .\src\puzzle_solver\puzzles\yin_yang\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
|
|
161
|
+
import cv2
|
|
162
|
+
import numpy as np
|
|
163
|
+
from pathlib import Path
|
|
164
|
+
image_path = Path(__file__).parent / "input_output" / "MzoyLDcwMSw2NTY=.png"
|
|
165
|
+
# image_path = Path(__file__).parent / "input_output" / "Njo5MDcsNDk4.png"
|
|
166
|
+
# image_path = Path(__file__).parent / "input_output" / "MTE6Niw0NjEsMTIx.png"
|
|
167
|
+
assert image_path.exists(), f"Image file does not exist: {image_path}"
|
|
168
|
+
board = extract_yinyang_board(image_path, debug=True)
|
|
169
|
+
print(board)
|
|
@@ -0,0 +1,110 @@
|
|
|
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, set_char, 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
|
+
|
|
9
|
+
|
|
10
|
+
class Board:
|
|
11
|
+
def __init__(self, board: np.array):
|
|
12
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
13
|
+
assert all(c.item() in [' ', 'B', 'W'] for c in np.nditer(board)), 'board must contain only space, B, or W'
|
|
14
|
+
self.board = board
|
|
15
|
+
self.V, self.H = board.shape
|
|
16
|
+
self.model = cp_model.CpModel()
|
|
17
|
+
self.B: dict[Pos, cp_model.IntVar] = {}
|
|
18
|
+
self.W: dict[Pos, cp_model.IntVar] = {}
|
|
19
|
+
|
|
20
|
+
self.create_vars()
|
|
21
|
+
self.add_all_constraints()
|
|
22
|
+
|
|
23
|
+
def create_vars(self):
|
|
24
|
+
for pos in get_all_pos(self.V, self.H):
|
|
25
|
+
self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
|
|
26
|
+
|
|
27
|
+
def add_all_constraints(self):
|
|
28
|
+
self.force_clues()
|
|
29
|
+
self.disallow_2x2()
|
|
30
|
+
self.disallow_checkers()
|
|
31
|
+
self.force_connected_component()
|
|
32
|
+
self.force_border_transitions()
|
|
33
|
+
|
|
34
|
+
def force_clues(self):
|
|
35
|
+
for pos in get_all_pos(self.V, self.H): # force clues
|
|
36
|
+
c = get_char(self.board, pos)
|
|
37
|
+
if c not in ['B', 'W']:
|
|
38
|
+
continue
|
|
39
|
+
self.model.Add(self.B[pos] == (c == 'B'))
|
|
40
|
+
|
|
41
|
+
def disallow_2x2(self):
|
|
42
|
+
for pos in get_all_pos(self.V, self.H): # disallow 2x2 (WW/WW) and (BB/BB)
|
|
43
|
+
tl = pos
|
|
44
|
+
tr = get_next_pos(pos, Direction.RIGHT)
|
|
45
|
+
bl = get_next_pos(pos, Direction.DOWN)
|
|
46
|
+
br = get_next_pos(bl, Direction.RIGHT)
|
|
47
|
+
if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
|
|
48
|
+
continue
|
|
49
|
+
self.model.AddBoolOr([self.B[tl], self.B[tr], self.B[bl], self.B[br]])
|
|
50
|
+
self.model.AddBoolOr([self.B[tl].Not(), self.B[tr].Not(), self.B[bl].Not(), self.B[br].Not()])
|
|
51
|
+
|
|
52
|
+
def disallow_checkers(self):
|
|
53
|
+
# from https://ralphwaldo.github.io/yinyang_summary.html
|
|
54
|
+
for pos in get_all_pos(self.V, self.H): # disallow (WB/BW) and (BW/WB)
|
|
55
|
+
tl = pos
|
|
56
|
+
tr = get_next_pos(pos, Direction.RIGHT)
|
|
57
|
+
bl = get_next_pos(pos, Direction.DOWN)
|
|
58
|
+
br = get_next_pos(bl, Direction.RIGHT)
|
|
59
|
+
if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
|
|
60
|
+
continue
|
|
61
|
+
self.model.AddBoolOr([self.B[tl], self.B[tr].Not(), self.B[bl].Not(), self.B[br]]) # disallow (WB/BW)
|
|
62
|
+
self.model.AddBoolOr([self.B[tl].Not(), self.B[tr], self.B[bl], self.B[br].Not()]) # disallow (BW/WB)
|
|
63
|
+
|
|
64
|
+
def force_connected_component(self):
|
|
65
|
+
# force single connected component for both colors
|
|
66
|
+
force_connected_component(self.model, self.B)
|
|
67
|
+
force_connected_component(self.model, {k: v.Not() for k, v in self.B.items()})
|
|
68
|
+
|
|
69
|
+
def force_border_transitions(self):
|
|
70
|
+
# from https://ralphwaldo.github.io/yinyang_summary.html
|
|
71
|
+
# The border cells cannot be split into four (or more) separate blocks of colours
|
|
72
|
+
# It is therefore either split into two blocks (one of each colour), or is just a single block of one colour or the other
|
|
73
|
+
border_cells = [] # go in a ring clockwise from top left
|
|
74
|
+
for x in range(self.H):
|
|
75
|
+
border_cells.append(get_pos(x=x, y=0))
|
|
76
|
+
for y in range(1, self.V):
|
|
77
|
+
border_cells.append(get_pos(x=self.H-1, y=y))
|
|
78
|
+
for x in range(self.H-2, -1, -1):
|
|
79
|
+
border_cells.append(get_pos(x=x, y=self.V-1))
|
|
80
|
+
for y in range(self.V-2, 0, -1):
|
|
81
|
+
border_cells.append(get_pos(x=0, y=y))
|
|
82
|
+
# tie the knot
|
|
83
|
+
border_cells.append(border_cells[0])
|
|
84
|
+
# unequal sum is 0 or 2
|
|
85
|
+
deltas = []
|
|
86
|
+
for i in range(len(border_cells)-1):
|
|
87
|
+
aux = self.model.NewBoolVar(f'border_transition_{i}') # i is black while i+1 is white
|
|
88
|
+
and_constraint(self.model, aux, [self.B[border_cells[i]], self.B[border_cells[i+1]].Not()])
|
|
89
|
+
deltas.append(aux)
|
|
90
|
+
self.model.Add(lxp.Sum(deltas) <= 1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def solve_and_print(self, verbose: bool = True):
|
|
94
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
95
|
+
assignment: dict[Pos, int] = {}
|
|
96
|
+
for pos, var in board.B.items():
|
|
97
|
+
assignment[pos] = 'B' if solver.BooleanValue(var) else 'W'
|
|
98
|
+
return SingleSolution(assignment=assignment)
|
|
99
|
+
def callback(single_res: SingleSolution):
|
|
100
|
+
print("Solution found")
|
|
101
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
102
|
+
for pos in get_all_pos(self.V, self.H):
|
|
103
|
+
c = get_char(self.board, pos)
|
|
104
|
+
c = single_res.assignment[pos]
|
|
105
|
+
set_char(res, pos, c)
|
|
106
|
+
print('[')
|
|
107
|
+
for row in res:
|
|
108
|
+
print(" [ '" + "', '".join(row.tolist()) + "' ],")
|
|
109
|
+
print(']')
|
|
110
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
File without changes
|
|
File without changes
|