multi-puzzle-solver 0.9.15__py3-none-any.whl → 0.9.20__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.

@@ -0,0 +1,117 @@
1
+ from typing import Literal, Optional, Union
2
+ from dataclasses import dataclass
3
+
4
+ import numpy as np
5
+ from ortools.sat.python import cp_model
6
+
7
+ from puzzle_solver.core.utils import Pos, get_all_pos, set_char, in_bounds, get_pos
8
+ from puzzle_solver.core.utils_ortools import force_no_loops, generic_solve_all, SingleSolution
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Node:
13
+ """The grid is represented as a graph of cells connected to corners."""
14
+ node_type: Union[Literal["Cell"], Literal["Corner"]]
15
+ pos: Pos
16
+ slant: Union[Literal["//"], Literal["\\"], None]
17
+
18
+ def get_neighbors(self, board_nodes: dict[tuple[str, Pos, Optional[str]], "Node"]) -> list["Node"]:
19
+ if self.node_type == "Cell" and self.slant == "//":
20
+ n1 = board_nodes[("Corner", get_pos(self.pos.x+1, self.pos.y), None)]
21
+ n2 = board_nodes[("Corner", get_pos(self.pos.x, self.pos.y+1), None)]
22
+ return [n1, n2]
23
+ elif self.node_type == "Cell" and self.slant == "\\":
24
+ n1 = board_nodes[("Corner", get_pos(self.pos.x, self.pos.y), None)]
25
+ n2 = board_nodes[("Corner", get_pos(self.pos.x+1, self.pos.y+1), None)]
26
+ return [n1, n2]
27
+ elif self.node_type == "Corner":
28
+ # 4 cells, 2 cells per slant
29
+ n1 = ("Cell", get_pos(self.pos.x-1, self.pos.y-1), "\\")
30
+ n2 = ("Cell", get_pos(self.pos.x, self.pos.y-1), "//")
31
+ n3 = ("Cell", get_pos(self.pos.x-1, self.pos.y), "//")
32
+ n4 = ("Cell", get_pos(self.pos.x, self.pos.y), "\\")
33
+ return {board_nodes[n] for n in [n1, n2, n3, n4] if n in board_nodes}
34
+
35
+
36
+ class Board:
37
+ def __init__(self, numbers: Union[list[tuple[Pos, int]], np.array], V: int = None, H: int = None):
38
+ if isinstance(numbers, np.ndarray):
39
+ V, H = numbers.shape
40
+ V = V - 1
41
+ H = H - 1
42
+ numbers = [(get_pos(x=pos[1], y=pos[0]), int(d)) for pos, d in np.ndenumerate(numbers) if str(d).isdecimal()]
43
+ numbers = [(p, n) for p, n in numbers if n >= 0]
44
+ else:
45
+ assert V is not None and H is not None, 'V and H must be provided if numbers is not a numpy array'
46
+ assert V >= 1 and H >= 1, 'V and H must be at least 1'
47
+ assert all(isinstance(number, int) and number >= 0 for (pos, number) in numbers), 'numbers must be a list of integers'
48
+ self.V = V
49
+ self.H = H
50
+ self.numbers = numbers
51
+ self.pos_to_number: dict[Pos, int] = {pos: number for pos, number in numbers}
52
+
53
+ self.model = cp_model.CpModel()
54
+ self.model_vars: dict[tuple[Pos, str], cp_model.IntVar] = {}
55
+ self.nodes: dict[Node, cp_model.IntVar] = {}
56
+ self.neighbor_dict: dict[Node, set[Node]] = {}
57
+
58
+ self.create_vars()
59
+ self.add_all_constraints()
60
+
61
+
62
+ def create_vars(self):
63
+ for pos in get_all_pos(self.V, self.H):
64
+ self.model_vars[(pos, '//')] = self.model.NewBoolVar(f'{pos}://')
65
+ self.model_vars[(pos, '\\')] = self.model.NewBoolVar(f'{pos}:\\')
66
+ self.model.AddExactlyOne([self.model_vars[(pos, '//')], self.model_vars[(pos, '\\')]])
67
+ for (pos, slant), v in self.model_vars.items():
68
+ self.nodes[Node(node_type="Cell", pos=pos, slant=slant)] = v
69
+ for pos in get_all_pos(self.V + 1, self.H + 1):
70
+ self.nodes[Node(node_type="Corner", pos=pos, slant=None)] = self.model.NewConstant(1)
71
+
72
+
73
+ def add_all_constraints(self):
74
+ for pos, number in self.pos_to_number.items():
75
+ # pos is a position on the intersection of 4 cells
76
+ # when pos is (xi, yi) then it gets a +1 contribution for each:
77
+ # - cell (xi-1, yi-1) is a "\\"
78
+ # - cell (xi, yi) is a "\\"
79
+ # - cell (xi, yi-1) is a "//"
80
+ # - cell (xi-1, yi) is a "//"
81
+ xi, yi = pos.x, pos.y
82
+ tl_pos = get_pos(xi-1, yi-1)
83
+ br_pos = get_pos(xi, yi)
84
+ tr_pos = get_pos(xi, yi-1)
85
+ bl_pos = get_pos(xi-1, yi)
86
+ tl_var = self.model_vars[(tl_pos, '\\')] if in_bounds(tl_pos, self.V, self.H) else 0
87
+ br_var = self.model_vars[(br_pos, '\\')] if in_bounds(br_pos, self.V, self.H) else 0
88
+ tr_var = self.model_vars[(tr_pos, '//')] if in_bounds(tr_pos, self.V, self.H) else 0
89
+ bl_var = self.model_vars[(bl_pos, '//')] if in_bounds(bl_pos, self.V, self.H) else 0
90
+ self.model.Add(sum([tl_var, tr_var, bl_var, br_var]) == number)
91
+ board_nodes = {(node.node_type, node.pos, node.slant): node for node in self.nodes.keys()}
92
+ self.neighbor_dict = {node: node.get_neighbors(board_nodes) for node in self.nodes.keys()}
93
+ no_loops_vars = force_no_loops(self.model, self.nodes, is_neighbor=lambda n1, n2: n1 in self.neighbor_dict[n2])
94
+ self.no_loops_vars = no_loops_vars
95
+
96
+
97
+ def solve_and_print(self, verbose: bool = True):
98
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
99
+ assignment: dict[Pos, int] = {}
100
+ # graph = {node: solver.Value(var) for node, var in board.nodes.items()}
101
+ for (pos, s), var in board.model_vars.items():
102
+ if solver.Value(var) == 1:
103
+ assignment[pos] = s
104
+ for p in get_all_pos(self.V, self.H):
105
+ assert p in assignment, f'position {p} is not assigned a number'
106
+ return SingleSolution(assignment=assignment)
107
+ def callback(single_res: SingleSolution):
108
+ print("Solution found")
109
+ res = np.full((self.V, self.H), ' ', dtype=object)
110
+ for pos in get_all_pos(self.V, self.H):
111
+ set_char(res, pos, '/' if single_res.assignment[pos] == '//' else '\\')
112
+ print('[')
113
+ for row in range(self.V):
114
+ line = ' [ ' + ' '.join(res[row].tolist()) + ' ]'
115
+ print(line)
116
+ print(']')
117
+ 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
+
@@ -1,8 +1,9 @@
1
1
  """
2
- This file is a simple helper that parses the images from https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/inertia.html and converts them to a json file.
2
+ This file is a simple helper that parses the images from https://www.puzzle-stitches.com/ and converts them to a json file.
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
- out = np.full_like(output['top'], ' ', dtype='U2')
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
- dfs(i, j, out, output, str(current_count).zfill(2))
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')
@@ -0,0 +1,128 @@
1
+ import numpy as np
2
+ from ortools.sat.python import cp_model
3
+
4
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_row_pos, get_col_pos, set_char, get_pos, get_char, Direction, in_bounds, get_next_pos
5
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
6
+
7
+
8
+ def parse_board(board: np.array) -> tuple[np.array, list[tuple[Pos, Pos, str]]]:
9
+ """Returns the internal board and a list for every pair of positions (p1, p2, comparison_type) where p1 < p2 if comparison_type is '<' otherwise abs(p1 - p2)==1 if comparison_type is '|'"""
10
+ V = int(np.ceil(board.shape[0] / 2))
11
+ H = int(np.ceil(board.shape[1] / 2))
12
+ internal_board = np.full((V, H), ' ', dtype=object)
13
+ pairs = []
14
+ for row_i in range(board.shape[0]):
15
+ for col_i in range(board.shape[1]):
16
+ cell = board[row_i, col_i]
17
+ if row_i % 2 == 0 and col_i % 2 == 0: # number or empty cell
18
+ if cell == ' ':
19
+ continue
20
+ # map A to 10, B to 11, etc.
21
+ if str(cell).isalpha() and len(str(cell)) == 1:
22
+ cell = ord(cell.upper()) - ord('A') + 10
23
+ assert str(cell).isdecimal(), f'expected number at {row_i, col_i}, got {cell}'
24
+ internal_board[row_i // 2, col_i // 2] = int(cell)
25
+ elif row_i % 2 == 0 and col_i % 2 == 1: # horizontal comparison
26
+ assert cell in ['<', '>', '|', ' '], f'expected <, >, |, or empty cell at {row_i, col_i}, got {cell}'
27
+ if cell == ' ':
28
+ continue
29
+ p1 = get_pos(x=col_i // 2, y=row_i // 2)
30
+ p2 = get_pos(x=p1.x + 1, y=p1.y)
31
+ if cell == '<':
32
+ pairs.append((p1, p2, '<'))
33
+ elif cell == '>':
34
+ pairs.append((p2, p1, '<'))
35
+ elif cell == '|':
36
+ pairs.append((p1, p2, '|'))
37
+ else:
38
+ raise ValueError(f'unexpected cell {cell} at {row_i, col_i}')
39
+ elif row_i % 2 == 1 and col_i % 2 == 0: # vertical comparison
40
+ assert cell in ['∧', '∨', 'U', 'D', 'V', 'n', '-', '|', ' '], f'expected ∧, ∨, U, D, V, n, -, |, or empty cell at {row_i, col_i}, got {cell}'
41
+ if cell == ' ':
42
+ continue
43
+ p1 = get_pos(x=col_i // 2, y=row_i // 2)
44
+ p2 = get_pos(x=p1.x, y=p1.y + 1)
45
+ if cell in ['∨', 'U', 'V']:
46
+ pairs.append((p2, p1, '<'))
47
+ elif cell in ['∧', 'D', 'n']:
48
+ pairs.append((p1, p2, '<'))
49
+ elif cell in ['-', '|']:
50
+ pairs.append((p1, p2, '|'))
51
+ else:
52
+ raise ValueError(f'unexpected cell {cell} at {row_i, col_i}')
53
+ else:
54
+ assert cell in [' ', '.', 'X'], f'expected empty cell or dot or X at unused corner {row_i, col_i}, got {cell}'
55
+ return internal_board, pairs
56
+
57
+ class Board:
58
+ def __init__(self, board: np.array, adjacent_mode: bool = False, include_zero_before_letter: bool = True):
59
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
60
+ assert board.shape[0] > 0 and board.shape[1] > 0, 'board must be non-empty'
61
+ self.board, self.pairs = parse_board(board)
62
+ self.adjacent_mode = adjacent_mode
63
+ self.V, self.H = self.board.shape
64
+ self.lb = 1
65
+ self.N = max(self.V, self.H)
66
+ if include_zero_before_letter and self.N > 9: # zero is introduced when board gets to 10, then we add 1 letter after that
67
+ self.lb = 0
68
+ self.N -= 1
69
+
70
+ self.model = cp_model.CpModel()
71
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
72
+ self.create_vars()
73
+ self.add_all_constraints()
74
+
75
+ def create_vars(self):
76
+ for pos in get_all_pos(self.V, self.H):
77
+ self.model_vars[pos] = self.model.NewIntVar(self.lb, self.N, f'{pos}')
78
+
79
+ def add_all_constraints(self):
80
+ for row_i in range(self.V):
81
+ self.model.AddAllDifferent([self.model_vars[pos] for pos in get_row_pos(row_i, self.H)])
82
+ for col_i in range(self.H):
83
+ self.model.AddAllDifferent([self.model_vars[pos] for pos in get_col_pos(col_i, self.V)])
84
+ for pos in get_all_pos(self.V, self.H):
85
+ c = get_char(self.board, pos)
86
+ if str(c).isdecimal():
87
+ self.model.Add(self.model_vars[pos] == int(c))
88
+
89
+ for p1, p2, comparison_type in self.pairs:
90
+ assert comparison_type in ['<', '|'], f'SHOULD NEVER HAPPEN: invalid comparison type {comparison_type}, expected < or |'
91
+ if comparison_type == '<':
92
+ self.model.Add(self.model_vars[p1] < self.model_vars[p2])
93
+ elif comparison_type == '|':
94
+ aux = self.model.NewIntVar(0, 2*self.N, f'aux_{p1}_{p2}')
95
+ self.model.AddAbsEquality(aux, self.model_vars[p1] - self.model_vars[p2])
96
+ self.model.Add(aux == 1)
97
+ if self.adjacent_mode:
98
+ # in adjacent mode, there is strict NON adjacency if a | does not exist
99
+ all_pairs = {(p1, p2) for p1, p2, _ in self.pairs}
100
+ for pos in get_all_pos(self.V, self.H):
101
+ for direction in [Direction.RIGHT, Direction.DOWN]:
102
+ neighbor = get_next_pos(pos, direction)
103
+ if not in_bounds(neighbor, self.V, self.H):
104
+ continue
105
+ if (pos, neighbor) in all_pairs:
106
+ continue
107
+ assert (neighbor, pos) not in all_pairs, f'SHOULD NEVER HAPPEN: both {pos}->{neighbor} and {neighbor}->{pos} are in the same pair'
108
+ aux = self.model.NewIntVar(0, 2*self.N, f'aux_{pos}_{neighbor}')
109
+ self.model.AddAbsEquality(aux, self.model_vars[pos] - self.model_vars[neighbor])
110
+ self.model.Add(aux != 1)
111
+
112
+ def solve_and_print(self, verbose: bool = True):
113
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
114
+ assignment: dict[Pos, int] = {}
115
+ for pos, var in board.model_vars.items():
116
+ assignment[pos] = solver.Value(var)
117
+ return SingleSolution(assignment=assignment)
118
+ def callback(single_res: SingleSolution):
119
+ print("Solution found")
120
+ res = np.full((self.V, self.H), ' ', dtype=object)
121
+ for pos in get_all_pos(self.V, self.H):
122
+ set_char(res, pos, str(single_res.assignment[pos]))
123
+ print('[')
124
+ for row in range(self.V):
125
+ line = ' [ ' + ' '.join(res[row].tolist()) + ' ]'
126
+ print(line)
127
+ print(']')
128
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)