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,133 +1,32 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import time
|
|
3
1
|
from dataclasses import dataclass
|
|
4
|
-
from typing import Optional, Union
|
|
5
2
|
|
|
6
|
-
from ortools.sat.python import cp_model
|
|
7
3
|
import numpy as np
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
8
5
|
|
|
9
|
-
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char,
|
|
10
|
-
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
# a shape on the 2d board is just a set of positions
|
|
14
|
-
Shape = frozenset[Pos]
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def polyominoes(N):
|
|
18
|
-
"""Generate all polyominoes of size N. Every rotation and reflection is considered different and included in the result.
|
|
19
|
-
Translation is not considered different and is removed from the result (otherwise the result would be infinite).
|
|
20
|
-
|
|
21
|
-
Below is the number of unique polyominoes of size N (not including rotations and reflections) and the lenth of the returned result (which includes all rotations and reflections)
|
|
22
|
-
N name #shapes #results
|
|
23
|
-
1 monomino 1 1
|
|
24
|
-
2 domino 1 2
|
|
25
|
-
3 tromino 2 6
|
|
26
|
-
4 tetromino 5 19
|
|
27
|
-
5 pentomino 12 63
|
|
28
|
-
6 hexomino 35 216
|
|
29
|
-
7 heptomino 108 760
|
|
30
|
-
8 octomino 369 2,725
|
|
31
|
-
9 nonomino 1,285 9,910
|
|
32
|
-
10 decomino 4,655 36,446
|
|
33
|
-
11 undecomino 17,073 135,268
|
|
34
|
-
12 dodecomino 63,600 505,861
|
|
35
|
-
Source: https://en.wikipedia.org/wiki/Polyomino
|
|
36
|
-
|
|
37
|
-
Args:
|
|
38
|
-
N (int): The size of the polyominoes to generate.
|
|
39
|
-
|
|
40
|
-
Returns:
|
|
41
|
-
set[(frozenset[Pos], int)]: A set of all polyominoes of size N (rotated and reflected up to D4 symmetry) along with a unique ID for each polyomino.
|
|
42
|
-
"""
|
|
43
|
-
assert N >= 1, 'N cannot be less than 1'
|
|
44
|
-
# need a frozenset because regular sets are not hashable
|
|
45
|
-
shapes: set[Shape] = {frozenset({Pos(0, 0)})}
|
|
46
|
-
for i in range(1, N):
|
|
47
|
-
next_shapes: set[Shape] = set()
|
|
48
|
-
for s in shapes:
|
|
49
|
-
# frontier: all 4-neighbors of existing cells not already in the shape
|
|
50
|
-
frontier = {get_next_pos(pos, direction)
|
|
51
|
-
for pos in s
|
|
52
|
-
for direction in Direction
|
|
53
|
-
if get_next_pos(pos, direction) not in s}
|
|
54
|
-
for cell in frontier:
|
|
55
|
-
t = s | {cell}
|
|
56
|
-
# normalize by translation only: shift so min x,y is (0,0). This removes translational symmetries.
|
|
57
|
-
minx = min(pos.x for pos in t)
|
|
58
|
-
miny = min(pos.y for pos in t)
|
|
59
|
-
t0 = frozenset(Pos(x=pos.x - minx, y=pos.y - miny) for pos in t)
|
|
60
|
-
next_shapes.add(t0)
|
|
61
|
-
shapes = next_shapes
|
|
62
|
-
# shapes is now complete, now classify up to D4 symmetry (rotations/reflections), translations ignored
|
|
63
|
-
mats = (
|
|
64
|
-
( 1, 0, 0, 1), # regular
|
|
65
|
-
(-1, 0, 0, 1), # reflect about x
|
|
66
|
-
( 1, 0, 0,-1), # reflect about y
|
|
67
|
-
(-1, 0, 0,-1), # reflect about x and y
|
|
68
|
-
# trnaspose then all 4 above
|
|
69
|
-
( 0, 1, 1, 0), ( 0, 1, -1, 0), ( 0,-1, 1, 0), ( 0,-1, -1, 0),
|
|
70
|
-
)
|
|
71
|
-
# compute canonical representative for each shape (lexicographically smallest normalized transform)
|
|
72
|
-
shape_to_canon: dict[Shape, tuple[Pos, ...]] = {}
|
|
73
|
-
for s in shapes:
|
|
74
|
-
reps: list[tuple[Pos, ...]] = []
|
|
75
|
-
for a, b, c, d in mats:
|
|
76
|
-
pts = {Pos(x=a*p.x + b*p.y, y=c*p.x + d*p.y) for p in s}
|
|
77
|
-
minx = min(p.x for p in pts)
|
|
78
|
-
miny = min(p.y for p in pts)
|
|
79
|
-
rep = tuple(sorted(Pos(x=p.x - minx, y=p.y - miny) for p in pts))
|
|
80
|
-
reps.append(rep)
|
|
81
|
-
canon = min(reps)
|
|
82
|
-
shape_to_canon[s] = canon
|
|
83
|
-
|
|
84
|
-
canon_set = set(shape_to_canon.values())
|
|
85
|
-
canon_to_id = {canon: i for i, canon in enumerate(sorted(canon_set))}
|
|
86
|
-
result = {(s, canon_to_id[shape_to_canon[s]]) for s in shapes}
|
|
87
|
-
return result
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
@dataclass(frozen=True)
|
|
91
|
-
class SingleSolution:
|
|
92
|
-
assignment: dict[Pos, Union[str, int]]
|
|
93
|
-
all_other_variables: dict
|
|
94
|
-
|
|
95
|
-
def get_hashable_solution(self) -> str:
|
|
96
|
-
result = []
|
|
97
|
-
for pos, v in self.assignment.items():
|
|
98
|
-
result.append((pos.x, pos.y, v))
|
|
99
|
-
return json.dumps(result, sort_keys=True)
|
|
100
|
-
|
|
6
|
+
from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, set_char, polyominoes, in_bounds, get_next_pos, Direction
|
|
7
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint
|
|
101
8
|
|
|
102
9
|
|
|
103
10
|
@dataclass
|
|
104
11
|
class ShapeOnBoard:
|
|
105
12
|
is_active: cp_model.IntVar
|
|
106
|
-
|
|
107
|
-
shape_id: int
|
|
13
|
+
orientation: str
|
|
108
14
|
body: set[Pos]
|
|
109
|
-
|
|
15
|
+
disallow: set[Pos]
|
|
110
16
|
|
|
111
17
|
|
|
112
18
|
class Board:
|
|
113
|
-
def __init__(self, board: np.
|
|
19
|
+
def __init__(self, board: np.ndarray):
|
|
114
20
|
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
115
|
-
self.V = board.shape[0]
|
|
116
|
-
self.H = board.shape[1]
|
|
117
|
-
assert all((str(c.item()).isdecimal() for c in np.nditer(board))), 'board must contain only digits'
|
|
118
21
|
self.board = board
|
|
119
|
-
self.
|
|
120
|
-
|
|
121
|
-
|
|
22
|
+
self.V, self.H = board.shape
|
|
23
|
+
assert all((c == ' ') or str(c).isdecimal() for c in np.nditer(board)), "board must contain space or digits"
|
|
122
24
|
self.block_numbers = set([int(c.item()) for c in np.nditer(board)])
|
|
123
|
-
self.blocks = {i:
|
|
124
|
-
for cell in get_all_pos(self.V, self.H):
|
|
125
|
-
self.blocks[int(get_char(self.board, cell))].add(cell)
|
|
25
|
+
self.blocks = {i: [pos for pos in get_all_pos(self.V, self.H) if int(get_char(self.board, pos)) == i] for i in self.block_numbers}
|
|
126
26
|
|
|
127
27
|
self.model = cp_model.CpModel()
|
|
128
28
|
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
129
|
-
self.
|
|
130
|
-
self.shapes_on_board: list[ShapeOnBoard] = [] # will contain every possible shape on the board based on polyomino degrees
|
|
29
|
+
self.shapes_on_board: list[ShapeOnBoard] = []
|
|
131
30
|
|
|
132
31
|
self.create_vars()
|
|
133
32
|
self.init_shapes_on_board()
|
|
@@ -136,120 +35,67 @@ class Board:
|
|
|
136
35
|
def create_vars(self):
|
|
137
36
|
for pos in get_all_pos(self.V, self.H):
|
|
138
37
|
self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
|
|
139
|
-
# print('base vars:', len(self.model_vars))
|
|
140
38
|
|
|
141
39
|
def init_shapes_on_board(self):
|
|
142
|
-
for
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
continue
|
|
150
|
-
# 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
|
|
151
|
-
disallow_same_shape = set(get_next_pos(p, direction) for p in body for direction in Direction)
|
|
152
|
-
disallow_same_shape -= body
|
|
153
|
-
self.shapes_on_board.append(ShapeOnBoard(
|
|
154
|
-
is_active=self.model.NewBoolVar(f'{idx}:{translate}:is_active'),
|
|
155
|
-
shape=shape,
|
|
156
|
-
shape_id=shape_id,
|
|
157
|
-
body=body,
|
|
158
|
-
disallow_same_shape=disallow_same_shape,
|
|
159
|
-
))
|
|
160
|
-
# print('shapes on board:', len(self.shapes_on_board))
|
|
40
|
+
for pos in get_all_pos(self.V, self.H):
|
|
41
|
+
shape = self.get_shape(pos, 'horizontal')
|
|
42
|
+
if shape is not None:
|
|
43
|
+
self.shapes_on_board.append(shape)
|
|
44
|
+
shape = self.get_shape(pos, 'vertical')
|
|
45
|
+
if shape is not None:
|
|
46
|
+
self.shapes_on_board.append(shape)
|
|
161
47
|
|
|
162
48
|
def add_all_constraints(self):
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
#
|
|
170
|
-
self.
|
|
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
|
-
def
|
|
198
|
-
|
|
199
|
-
for shape_on_board in self.shapes_on_board:
|
|
200
|
-
similar_shapes = [s for s in self.shapes_on_board if s.shape_id == shape_on_board.shape_id]
|
|
201
|
-
for s in similar_shapes:
|
|
202
|
-
if shape_on_board.disallow_same_shape & s.body: # this shape disallows having s be on the board
|
|
203
|
-
self.model.Add(s.is_active == 0).OnlyEnforceIf(shape_on_board.is_active)
|
|
204
|
-
|
|
205
|
-
def disallow_shape(self, shape_to_disallow: Shape):
|
|
206
|
-
# for every position in the board, force sum of body < len(body)
|
|
207
|
-
for translate in get_all_pos(self.V, self.H):
|
|
208
|
-
cur_body = {get_pos(x=p.x + translate.x, y=p.y + translate.y) for p in shape_to_disallow}
|
|
209
|
-
if any(not in_bounds(p, self.V, self.H) for p in cur_body):
|
|
210
|
-
continue
|
|
211
|
-
self.model.Add(sum(self.model_vars[p] for p in cur_body) < len(cur_body))
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def solve_and_print(self, verbose: bool = True, max_solutions: Optional[int] = None, verbose_callback: Optional[bool] = None):
|
|
217
|
-
if verbose_callback is None:
|
|
218
|
-
verbose_callback = verbose
|
|
219
|
-
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
49
|
+
# if a piece is active then all its body is active and the disallow is inactive
|
|
50
|
+
for shape in self.shapes_on_board:
|
|
51
|
+
for pos in shape.body:
|
|
52
|
+
self.model.Add(self.model_vars[pos] == 1).OnlyEnforceIf(shape.is_active)
|
|
53
|
+
for pos in shape.disallow:
|
|
54
|
+
self.model.Add(self.model_vars[pos] == 0).OnlyEnforceIf(shape.is_active)
|
|
55
|
+
# if a spot is active then exactly one piece (with a body there) is active
|
|
56
|
+
for pos in get_all_pos(self.V, self.H):
|
|
57
|
+
pieces_on_pos = [shape for shape in self.shapes_on_board if pos in shape.body]
|
|
58
|
+
# if pos is on then exactly one shape is active. if pos is off then 0 shapes are active.
|
|
59
|
+
self.model.Add(sum(shape.is_active for shape in pieces_on_pos) == self.model_vars[pos])
|
|
60
|
+
# every region must have exactly 2 spots active.
|
|
61
|
+
for block in self.blocks.values():
|
|
62
|
+
self.model.Add(sum(self.model_vars[pos] for pos in block) == 2)
|
|
63
|
+
|
|
64
|
+
def get_shape(self, pos: Pos, orientation: str) -> Shape:
|
|
65
|
+
assert orientation in ['horizontal', 'vertical'], 'orientation must be horizontal or vertical'
|
|
66
|
+
if orientation == 'horizontal':
|
|
67
|
+
body = {pos, get_next_pos(pos, Direction.RIGHT)}
|
|
68
|
+
else:
|
|
69
|
+
body = {pos, get_next_pos(pos, Direction.DOWN)}
|
|
70
|
+
if any(not in_bounds(p, self.V, self.H) for p in body):
|
|
71
|
+
return None
|
|
72
|
+
disallow = set(get_next_pos(p, direction) for p in body for direction in Direction)
|
|
73
|
+
disallow = {p for p in disallow if p not in body and in_bounds(p, self.V, self.H)}
|
|
74
|
+
shape_on_board = ShapeOnBoard(
|
|
75
|
+
is_active=self.model.NewBoolVar(f'horizontal:{pos}'),
|
|
76
|
+
orientation='horizontal',
|
|
77
|
+
body=body,
|
|
78
|
+
disallow=disallow,
|
|
79
|
+
)
|
|
80
|
+
return shape_on_board
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def solve_and_print(self, verbose: bool = True):
|
|
84
|
+
def board_to_solution(board: "Board", solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
220
85
|
assignment: dict[Pos, int] = {}
|
|
221
|
-
for pos
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
return SingleSolution(assignment=assignment, all_other_variables=all_other_variables)
|
|
86
|
+
for pos in get_all_pos(self.V, self.H):
|
|
87
|
+
if solver.Value(self.model_vars[pos]) == 1:
|
|
88
|
+
assignment[pos] = get_char(self.board, pos)
|
|
89
|
+
return SingleSolution(assignment=assignment)
|
|
227
90
|
def callback(single_res: SingleSolution):
|
|
228
91
|
print("Solution found")
|
|
229
|
-
res = np.full((self.V, self.H), ' ', dtype=
|
|
230
|
-
for pos
|
|
231
|
-
c =
|
|
92
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
93
|
+
for pos in get_all_pos(self.V, self.H):
|
|
94
|
+
c = get_char(self.board, pos)
|
|
95
|
+
c = 'X' if pos in single_res.assignment else ' '
|
|
232
96
|
set_char(res, pos, c)
|
|
233
|
-
print('[
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
tic = time.time()
|
|
239
|
-
all_solutions = []
|
|
240
|
-
while True:
|
|
241
|
-
solutions = self.solve_and_print(verbose=False, verbose_callback=verbose, max_solutions=1)
|
|
242
|
-
if len(solutions) == 0:
|
|
243
|
-
break
|
|
244
|
-
all_solutions.extend(solutions)
|
|
245
|
-
assignment = solutions[0].assignment
|
|
246
|
-
# constrain the board to not return the same solution again
|
|
247
|
-
lits = [self.model_vars[p].Not() if assignment[p] == 1 else self.model_vars[p] for p in assignment.keys()]
|
|
248
|
-
self.model.AddBoolOr(lits)
|
|
249
|
-
self.model.ClearHints()
|
|
250
|
-
for k, v in solutions[0].all_other_variables['fc'].items():
|
|
251
|
-
self.model.AddHint(self.fc[k], v)
|
|
252
|
-
print(f'Solutions found: {len(all_solutions)}')
|
|
253
|
-
toc = time.time()
|
|
254
|
-
print(f'Time taken: {toc - tic:.2f} seconds')
|
|
255
|
-
return all_solutions
|
|
97
|
+
print('[')
|
|
98
|
+
for row in res:
|
|
99
|
+
print(" [ '" + "', '".join(row.tolist()) + "' ],")
|
|
100
|
+
print(']')
|
|
101
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from ortools.sat.python import cp_model
|
|
4
|
+
|
|
5
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_pos, Direction, get_row_pos, get_col_pos, get_next_pos, in_bounds, get_opposite_direction
|
|
6
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
CellBorder = tuple[Pos, Direction]
|
|
10
|
+
Corner = Pos
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Board:
|
|
14
|
+
def __init__(self, board: np.array):
|
|
15
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
16
|
+
assert all(c.item() == ' ' or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only spaces or digits'
|
|
17
|
+
self.V = board.shape[0]
|
|
18
|
+
self.H = board.shape[1]
|
|
19
|
+
self.board = board
|
|
20
|
+
self.cell_borders_to_corners: dict[CellBorder, set[Corner]] = defaultdict(set) # for every cell border, a set of all corners it is connected to
|
|
21
|
+
self.corners_to_cell_borders: dict[Corner, set[CellBorder]] = defaultdict(set) # opposite direction
|
|
22
|
+
|
|
23
|
+
self.model = cp_model.CpModel()
|
|
24
|
+
self.model_vars: dict[CellBorder, cp_model.IntVar] = {} # one entry for every unique variable in the model
|
|
25
|
+
self.cell_borders: dict[CellBorder, cp_model.IntVar] = {} # for every position and direction, one entry for that edge (thus the same edge variables are used in opposite directions of neighboring cells)
|
|
26
|
+
self.corner_vars: dict[Corner, set[cp_model.IntVar]] = defaultdict(set) # for every corner, one entry for each edge that touches the corner (i.e. 4 per corner unless on the border)
|
|
27
|
+
|
|
28
|
+
self.create_vars()
|
|
29
|
+
self.add_all_constraints()
|
|
30
|
+
|
|
31
|
+
def create_vars(self):
|
|
32
|
+
for pos in get_all_pos(self.V, self.H):
|
|
33
|
+
for direction in [Direction.RIGHT, Direction.DOWN]:
|
|
34
|
+
self.add_var(pos, direction)
|
|
35
|
+
for pos in get_row_pos(0, self.H):
|
|
36
|
+
self.add_var(pos, Direction.UP)
|
|
37
|
+
for pos in get_col_pos(0, self.V):
|
|
38
|
+
self.add_var(pos, Direction.LEFT)
|
|
39
|
+
|
|
40
|
+
def add_var(self, pos: Pos, direction: Direction):
|
|
41
|
+
cell_border = (pos, direction)
|
|
42
|
+
v = self.model.NewBoolVar(f'main:{cell_border}')
|
|
43
|
+
self.model_vars[cell_border] = v
|
|
44
|
+
self.add_cell_border_var(cell_border, v)
|
|
45
|
+
self.add_corner_vars(cell_border, v)
|
|
46
|
+
|
|
47
|
+
def add_cell_border_var(self, cell_border: CellBorder, var: cp_model.IntVar):
|
|
48
|
+
"""An edge belongs to two cells unless its on the border in which case it only belongs to one."""
|
|
49
|
+
pos, direction = cell_border
|
|
50
|
+
self.cell_borders[cell_border] = var
|
|
51
|
+
next_pos = get_next_pos(pos, direction)
|
|
52
|
+
if in_bounds(next_pos, self.V, self.H):
|
|
53
|
+
self.cell_borders[(next_pos, get_opposite_direction(direction))] = var
|
|
54
|
+
|
|
55
|
+
def add_corner_vars(self, cell_border: CellBorder, var: cp_model.IntVar):
|
|
56
|
+
"""
|
|
57
|
+
An edge always belongs to two corners. Note that the cell xi,yi has the 4 corners (xi,yi), (xi+1,yi), (xi,yi+1), (xi+1,yi+1). (memorize these 4 coordinates or the function wont make sense)
|
|
58
|
+
Thus corner index is +1 of board coordinates.
|
|
59
|
+
Never check for bounds here because an edge ALWAYS touches two corners AND because the +1 will make in_bounds return False when its still in bounds.
|
|
60
|
+
"""
|
|
61
|
+
pos, direction = cell_border
|
|
62
|
+
if direction == Direction.LEFT: # it touches me and (xi,yi+1)
|
|
63
|
+
corner1 = pos
|
|
64
|
+
corner2 = get_next_pos(pos, Direction.DOWN)
|
|
65
|
+
elif direction == Direction.UP: # it touches me and (xi+1,yi)
|
|
66
|
+
corner1 = pos
|
|
67
|
+
corner2 = get_next_pos(pos, Direction.RIGHT)
|
|
68
|
+
elif direction == Direction.RIGHT: # it touches (xi+1,yi) and (xi+1,yi+1)
|
|
69
|
+
corner1 = get_next_pos(pos, Direction.RIGHT)
|
|
70
|
+
corner2 = get_next_pos(corner1, Direction.DOWN)
|
|
71
|
+
elif direction == Direction.DOWN: # it touches (xi,yi+1) and (xi+1,yi+1)
|
|
72
|
+
corner1 = get_next_pos(pos, Direction.DOWN)
|
|
73
|
+
corner2 = get_next_pos(corner1, Direction.RIGHT)
|
|
74
|
+
else:
|
|
75
|
+
raise ValueError(f'Invalid direction: {direction}')
|
|
76
|
+
self.corner_vars[corner1].add(var)
|
|
77
|
+
self.corner_vars[corner2].add(var)
|
|
78
|
+
self.cell_borders_to_corners[cell_border].add(corner1)
|
|
79
|
+
self.cell_borders_to_corners[cell_border].add(corner2)
|
|
80
|
+
self.corners_to_cell_borders[corner1].add(cell_border)
|
|
81
|
+
self.corners_to_cell_borders[corner2].add(cell_border)
|
|
82
|
+
|
|
83
|
+
def add_all_constraints(self):
|
|
84
|
+
for pos in get_all_pos(self.V, self.H): # enforce cells with numbers
|
|
85
|
+
variables = [self.cell_borders[(pos, direction)] for direction in Direction if (pos, direction) in self.cell_borders]
|
|
86
|
+
val = get_char(self.board, pos)
|
|
87
|
+
if not val.isdecimal():
|
|
88
|
+
continue
|
|
89
|
+
self.model.Add(sum(variables) == int(val))
|
|
90
|
+
for corner in self.corner_vars: # a corder always has 0 or 2 active edges
|
|
91
|
+
g = self.model.NewBoolVar(f'corner_gate_{corner}')
|
|
92
|
+
self.model.Add(sum(self.corner_vars[corner]) == 0).OnlyEnforceIf(g.Not())
|
|
93
|
+
self.model.Add(sum(self.corner_vars[corner]) == 2).OnlyEnforceIf(g)
|
|
94
|
+
# single connected component
|
|
95
|
+
def is_neighbor(cb1: CellBorder, cb2: CellBorder) -> bool:
|
|
96
|
+
cb1_corners = self.cell_borders_to_corners[cb1]
|
|
97
|
+
cb2_corners = self.cell_borders_to_corners[cb2]
|
|
98
|
+
return len(cb1_corners & cb2_corners) > 0
|
|
99
|
+
force_connected_component(self.model, self.model_vars, is_neighbor=is_neighbor)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def solve_and_print(self, verbose: bool = True):
|
|
105
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
106
|
+
assignment: dict[Pos, str] = {}
|
|
107
|
+
for (pos, direction), var in board.model_vars.items():
|
|
108
|
+
if solver.value(var) == 1:
|
|
109
|
+
if pos not in assignment:
|
|
110
|
+
assignment[pos] = ''
|
|
111
|
+
assignment[pos] += direction.name[0]
|
|
112
|
+
return SingleSolution(assignment=assignment)
|
|
113
|
+
def callback(single_res: SingleSolution):
|
|
114
|
+
print("Solution found")
|
|
115
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
116
|
+
for pos in get_all_pos(self.V, self.H):
|
|
117
|
+
if pos not in single_res.assignment:
|
|
118
|
+
continue
|
|
119
|
+
c = ''.join(sorted(single_res.assignment[pos]))
|
|
120
|
+
set_char(res, pos, c)
|
|
121
|
+
print(render_grid(cell_flags=res, center_char=lambda c, r: self.board[r, c] if self.board[r, c] != ' ' else '·'))
|
|
122
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=999)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def render_grid(cell_flags: np.ndarray = None,
|
|
129
|
+
H: np.ndarray = None,
|
|
130
|
+
V: np.ndarray = None,
|
|
131
|
+
mark_centers: bool = True,
|
|
132
|
+
center_char: str = '·',
|
|
133
|
+
show_axes: bool = True,
|
|
134
|
+
scale_x: int = 2) -> str:
|
|
135
|
+
"""
|
|
136
|
+
AI generated this because I don't currently care about the details of rendering to the terminal and I did it in a quick and dirty way while the AI made it in a pretty way, and this looks good during my development.
|
|
137
|
+
cell_flags: np.ndarray of shape (N, N) with characters 'U', 'D', 'L', 'R' to represent the edges of the cells.
|
|
138
|
+
OR:
|
|
139
|
+
H: (N+1, N) horizontal edges between corners
|
|
140
|
+
V: (N, N+1) vertical edges between corners
|
|
141
|
+
scale_x: horizontal stretch factor (>=1). Try 2 or 3 for squarer cells.
|
|
142
|
+
"""
|
|
143
|
+
if cell_flags is not None:
|
|
144
|
+
N = cell_flags.shape[0]
|
|
145
|
+
H = np.zeros((N+1, N), dtype=bool)
|
|
146
|
+
V = np.zeros((N, N+1), dtype=bool)
|
|
147
|
+
for r in range(N):
|
|
148
|
+
for c in range(N):
|
|
149
|
+
s = cell_flags[r, c]
|
|
150
|
+
if 'U' in s: H[r, c] = True # edge between (r,c) and (r, c+1) above the cell
|
|
151
|
+
if 'D' in s: H[r+1, c] = True # edge below the cell
|
|
152
|
+
if 'L' in s: V[r, c] = True # edge left of the cell
|
|
153
|
+
if 'R' in s: V[r, c+1] = True # edge right of the cell
|
|
154
|
+
assert H is not None and V is not None, 'H and V must be provided'
|
|
155
|
+
# Bitmask for corner connections
|
|
156
|
+
U, R, D, L = 1, 2, 4, 8
|
|
157
|
+
JUNCTION = {
|
|
158
|
+
0: ' ',
|
|
159
|
+
U: '│', D: '│', U|D: '│',
|
|
160
|
+
L: '─', R: '─', L|R: '─',
|
|
161
|
+
U|R: '└', R|D: '┌', D|L: '┐', L|U: '┘',
|
|
162
|
+
U|D|L: '┤', U|D|R: '├', L|R|U: '┴', L|R|D: '┬',
|
|
163
|
+
U|R|D|L: '┼',
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
assert scale_x >= 1
|
|
167
|
+
N = V.shape[0]
|
|
168
|
+
assert H.shape == (N+1, N) and V.shape == (N, N+1)
|
|
169
|
+
|
|
170
|
+
rows = 2*N + 1
|
|
171
|
+
cols = 2*N*scale_x + 1 # stretched width
|
|
172
|
+
canvas = [[' ']*cols for _ in range(rows)]
|
|
173
|
+
|
|
174
|
+
def x_corner(c): # x of corner column c
|
|
175
|
+
return (2*c) * scale_x
|
|
176
|
+
def x_between(c,k): # kth in-between column (1..scale_x) between c and c+1 corners
|
|
177
|
+
return (2*c) * scale_x + k
|
|
178
|
+
|
|
179
|
+
# horizontal edges: fill the stretched band between corners with '─'
|
|
180
|
+
for r in range(N+1):
|
|
181
|
+
rr = 2*r
|
|
182
|
+
for c in range(N):
|
|
183
|
+
if H[r, c]:
|
|
184
|
+
# previously: for k in range(1, scale_x*2, 2):
|
|
185
|
+
for k in range(1, scale_x*2): # 1..(2*scale_x-1), no gaps
|
|
186
|
+
canvas[rr][x_between(c, k)] = '─'
|
|
187
|
+
|
|
188
|
+
# vertical edges: draw at the corner columns (no horizontal stretching needed)
|
|
189
|
+
for r in range(N):
|
|
190
|
+
rr = 2*r + 1
|
|
191
|
+
for c in range(N+1):
|
|
192
|
+
if V[r, c]:
|
|
193
|
+
canvas[rr][x_corner(c)] = '│'
|
|
194
|
+
|
|
195
|
+
# junctions at corners
|
|
196
|
+
for r in range(N+1):
|
|
197
|
+
rr = 2*r
|
|
198
|
+
for c in range(N+1):
|
|
199
|
+
m = 0
|
|
200
|
+
if r > 0 and V[r-1, c]: m |= U
|
|
201
|
+
if c < N and H[r, c]: m |= R
|
|
202
|
+
if r < N and V[r, c]: m |= D
|
|
203
|
+
if c > 0 and H[r, c-1]: m |= L
|
|
204
|
+
canvas[rr][x_corner(c)] = JUNCTION[m]
|
|
205
|
+
|
|
206
|
+
# centers (help count exact widths/heights)
|
|
207
|
+
if mark_centers:
|
|
208
|
+
for r in range(N):
|
|
209
|
+
rr = 2*r + 1
|
|
210
|
+
for c in range(N):
|
|
211
|
+
# center lies midway across the stretched span
|
|
212
|
+
xc = x_corner(c) + scale_x # middle-ish; works for any integer scale_x
|
|
213
|
+
canvas[rr][xc] = center_char if isinstance(center_char, str) else center_char(c, r)
|
|
214
|
+
|
|
215
|
+
# turn canvas rows into strings
|
|
216
|
+
art_rows = [''.join(row) for row in canvas]
|
|
217
|
+
|
|
218
|
+
if not show_axes:
|
|
219
|
+
return '\n'.join(art_rows)
|
|
220
|
+
|
|
221
|
+
# ── Axes ────────────────────────────────────────────────────────────────
|
|
222
|
+
gut = max(2, len(str(N-1))) # left gutter width
|
|
223
|
+
gutter = ' ' * gut
|
|
224
|
+
top_tens = list(gutter + ' ' * cols)
|
|
225
|
+
top_ones = list(gutter + ' ' * cols)
|
|
226
|
+
|
|
227
|
+
for c in range(N):
|
|
228
|
+
xc_center = x_corner(c) + scale_x
|
|
229
|
+
if N >= 10:
|
|
230
|
+
top_tens[gut + xc_center] = str((c // 10) % 10)
|
|
231
|
+
top_ones[gut + xc_center] = str(c % 10)
|
|
232
|
+
|
|
233
|
+
# tiny corner labels
|
|
234
|
+
if gut >= 2:
|
|
235
|
+
top_tens[gut-2:gut] = list(' ')
|
|
236
|
+
top_ones[gut-2:gut] = list(' ')
|
|
237
|
+
|
|
238
|
+
labeled = []
|
|
239
|
+
for r, line in enumerate(art_rows):
|
|
240
|
+
if r % 2 == 1: # cell-center row
|
|
241
|
+
label = str(r//2).rjust(gut)
|
|
242
|
+
else:
|
|
243
|
+
label = ' ' * gut
|
|
244
|
+
labeled.append(label + line)
|
|
245
|
+
|
|
246
|
+
return ''.join(top_tens) + '\n' + ''.join(top_ones) + '\n' + '\n'.join(labeled)
|
|
247
|
+
|
|
248
|
+
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Look at the ./input_output/ directory for examples of input images and output json files.
|
|
4
4
|
The output json is used in the test_solve.py file to test the solver.
|
|
5
5
|
"""
|
|
6
|
+
# import json
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
import numpy as np
|
|
8
9
|
cv = None
|
|
@@ -72,6 +73,8 @@ def mean_consecutives(arr: np.ndarray) -> np.ndarray:
|
|
|
72
73
|
return np.array(sums) // np.array(counts)
|
|
73
74
|
|
|
74
75
|
def dfs(x, y, out, output, current_num):
|
|
76
|
+
# if current_num == '48':
|
|
77
|
+
# print('dfs', x, y, current_num)
|
|
75
78
|
if x < 0 or x >= out.shape[1] or y < 0 or y >= out.shape[0]:
|
|
76
79
|
return
|
|
77
80
|
if out[y, x] != ' ':
|
|
@@ -136,6 +139,8 @@ def main(image):
|
|
|
136
139
|
cell = src[hidx1:hidx2, vidx1:vidx2]
|
|
137
140
|
mid_x = cell.shape[1] // 2
|
|
138
141
|
mid_y = cell.shape[0] // 2
|
|
142
|
+
# if j > height - 4 and i > width - 6:
|
|
143
|
+
# show_wait_destroy(f"cell_{i}_{j}", cell)
|
|
139
144
|
# show_wait_destroy(f"cell_{i}_{j}", cell)
|
|
140
145
|
cell = cv.bitwise_not(cell) # invert colors
|
|
141
146
|
top = cell[0:10, mid_y-5:mid_y+5]
|
|
@@ -156,10 +161,18 @@ def main(image):
|
|
|
156
161
|
axs[1, 0].set_title('Right')
|
|
157
162
|
axs[1, 1].hist(list(hists['bottom'].values()), bins=100)
|
|
158
163
|
axs[1, 1].set_title('Bottom')
|
|
164
|
+
global_target = None
|
|
165
|
+
# global_target = 28_000
|
|
159
166
|
target_top = np.mean(list(hists['top'].values()))
|
|
160
167
|
target_left = np.mean(list(hists['left'].values()))
|
|
161
168
|
target_right = np.mean(list(hists['right'].values()))
|
|
162
169
|
target_bottom = np.mean(list(hists['bottom'].values()))
|
|
170
|
+
if global_target is not None:
|
|
171
|
+
target_top = global_target
|
|
172
|
+
target_left = global_target
|
|
173
|
+
target_right = global_target
|
|
174
|
+
target_bottom = global_target
|
|
175
|
+
|
|
163
176
|
axs[0, 0].axvline(target_top, color='red')
|
|
164
177
|
axs[0, 1].axvline(target_left, color='red')
|
|
165
178
|
axs[1, 0].axvline(target_right, color='red')
|
|
@@ -185,12 +198,18 @@ def main(image):
|
|
|
185
198
|
print(' Sums: ', hists['top'][j, i], hists['left'][j, i], hists['right'][j, i], hists['bottom'][j, i])
|
|
186
199
|
|
|
187
200
|
current_count = 0
|
|
188
|
-
|
|
201
|
+
z_fill = 2
|
|
202
|
+
out = np.full_like(output['top'], ' ', dtype='U32')
|
|
189
203
|
for j in range(out.shape[0]):
|
|
204
|
+
if current_count > 99:
|
|
205
|
+
z_fill = 3
|
|
190
206
|
for i in range(out.shape[1]):
|
|
191
207
|
if out[j, i] == ' ':
|
|
192
|
-
|
|
208
|
+
if current_count == 48:
|
|
209
|
+
print(f"current_count: {current_count}, x: {i}, y: {j}")
|
|
210
|
+
dfs(i, j, out, output, str(current_count).zfill(z_fill))
|
|
193
211
|
current_count += 1
|
|
212
|
+
print(out)
|
|
194
213
|
|
|
195
214
|
with open(output_path, 'w') as f:
|
|
196
215
|
f.write('[\n')
|
|
@@ -202,6 +221,18 @@ def main(image):
|
|
|
202
221
|
f.write(']')
|
|
203
222
|
print('output json: ', output_path)
|
|
204
223
|
|
|
224
|
+
# with open(output_path.parent / 'debug.json', 'w') as f:
|
|
225
|
+
# debug_pos = {}
|
|
226
|
+
# for j in range(out.shape[0]):
|
|
227
|
+
# for i in range(out.shape[1]):
|
|
228
|
+
# out_str = ''
|
|
229
|
+
# out_str += 'T' if output['top'][j, i] else ''
|
|
230
|
+
# out_str += 'L' if output['left'][j, i] else ''
|
|
231
|
+
# out_str += 'R' if output['right'][j, i] else ''
|
|
232
|
+
# out_str += 'B' if output['bottom'][j, i] else ''
|
|
233
|
+
# debug_pos[f'{j}_{i}'] = out_str
|
|
234
|
+
# json.dump(debug_pos, f, indent=2)
|
|
235
|
+
|
|
205
236
|
if __name__ == '__main__':
|
|
206
237
|
# to run this script and visualize the output, in the root run:
|
|
207
238
|
# python .\src\puzzle_solver\puzzles\stitches\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
|
|
@@ -209,4 +240,6 @@ if __name__ == '__main__':
|
|
|
209
240
|
# main(Path(__file__).parent / 'input_output' / 'weekly_oct_3rd_2025.png')
|
|
210
241
|
# main(Path(__file__).parent / 'input_output' / 'star_battle_67f73ff90cd8cdb4b3e30f56f5261f4968f5dac940bc6.png')
|
|
211
242
|
# main(Path(__file__).parent / 'input_output' / 'LITS_MDoxNzksNzY3.png')
|
|
212
|
-
main(Path(__file__).parent / 'input_output' / 'lits_OTo3LDMwNiwwMTU=.png')
|
|
243
|
+
# main(Path(__file__).parent / 'input_output' / 'lits_OTo3LDMwNiwwMTU=.png')
|
|
244
|
+
# main(Path(__file__).parent / 'input_output' / 'norinori_501d93110d6b4b818c268378973afbf268f96cfa8d7b4.png')
|
|
245
|
+
main(Path(__file__).parent / 'input_output' / 'norinori_OTo0LDc0Miw5MTU.png')
|