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.
- {multi_puzzle_solver-0.9.15.dist-info → multi_puzzle_solver-0.9.20.dist-info}/METADATA +509 -11
- {multi_puzzle_solver-0.9.15.dist-info → multi_puzzle_solver-0.9.20.dist-info}/RECORD +14 -10
- puzzle_solver/__init__.py +5 -1
- puzzle_solver/core/utils_ortools.py +21 -17
- puzzle_solver/puzzles/galaxies/galaxies.py +2 -2
- puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +1 -1
- puzzle_solver/puzzles/norinori/norinori.py +66 -220
- puzzle_solver/puzzles/slant/parse_map/parse_map.py +133 -0
- puzzle_solver/puzzles/slant/slant.py +117 -0
- puzzle_solver/puzzles/slitherlink/slitherlink.py +248 -0
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +37 -4
- puzzle_solver/puzzles/unequal/unequal.py +128 -0
- {multi_puzzle_solver-0.9.15.dist-info → multi_puzzle_solver-0.9.20.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-0.9.15.dist-info → multi_puzzle_solver-0.9.20.dist-info}/top_level.txt +0 -0
|
@@ -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.
|
|
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
|
-
|
|
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')
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|