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.

@@ -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, get_pos, in_bounds, Direction, get_next_pos
10
- from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
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
- shape: Shape
107
- shape_id: int
13
+ orientation: str
108
14
  body: set[Pos]
109
- disallow_same_shape: set[Pos]
15
+ disallow: set[Pos]
110
16
 
111
17
 
112
18
  class Board:
113
- def __init__(self, board: np.array, polyomino_degrees: int = 4):
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.polyomino_degrees = polyomino_degrees
120
- self.polyominoes = polyominoes(self.polyomino_degrees)
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: set() for i in self.block_numbers}
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.connected_components: dict[Pos, cp_model.IntVar] = {}
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 idx, (shape, shape_id) in enumerate(self.polyominoes):
143
- for translate in get_all_pos(self.V, self.H): # body of shape is translated to be at pos
144
- body = {get_pos(x=p.x + translate.x, y=p.y + translate.y) for p in shape}
145
- if any(not in_bounds(p, self.V, self.H) for p in body):
146
- continue
147
- # shape must be fully contained in one block
148
- if len(set(get_char(self.board, p) for p in body)) > 1:
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
- # RULES:
164
- # 1- You have to place one tetromino in each region in such a way that:
165
- # 2- 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
166
- # 3- The shaded cells should form a single connected area.
167
- # 4- 2x2 shaded areas are not allowed
168
-
169
- # each cell must be part of a shape, every shape must be fully on the board. Core constraint, otherwise shapes on the board make no sense.
170
- self.only_allow_shapes_on_board()
171
-
172
- self.force_one_shape_per_block() # Rule #1
173
- self.disallow_same_shape_touching() # Rule #2
174
- self.fc = force_connected_component(self.model, self.model_vars) # Rule #3
175
- # print('force connected vars:', len(fc))
176
- shape_2_by_2 = frozenset({Pos(0, 0), Pos(0, 1), Pos(1, 0), Pos(1, 1)})
177
- self.disallow_shape(shape_2_by_2) # Rule #4
178
-
179
-
180
- def only_allow_shapes_on_board(self):
181
- for shape_on_board in self.shapes_on_board:
182
- # if shape is active then all its body cells must be active
183
- self.model.Add(sum(self.model_vars[p] for p in shape_on_board.body) == len(shape_on_board.body)).OnlyEnforceIf(shape_on_board.is_active)
184
- # each cell must be part of a shape
185
- for p in get_all_pos(self.V, self.H):
186
- shapes_on_p = [s for s in self.shapes_on_board if p in s.body]
187
- self.model.Add(sum(s.is_active for s in shapes_on_p) == 1).OnlyEnforceIf(self.model_vars[p])
188
-
189
- def force_one_shape_per_block(self):
190
- # You have to place exactly one tetromino in each region
191
- for block_i in self.block_numbers:
192
- shapes_on_block = [s for s in self.shapes_on_board if s.body & self.blocks[block_i]]
193
- assert all(s.body.issubset(self.blocks[block_i]) for s in shapes_on_block), 'expected all shapes on block to be fully contained in the block'
194
- # print(f'shapes on block {block_i} has {len(shapes_on_block)} shapes')
195
- self.model.Add(sum(s.is_active for s in shapes_on_block) == 1)
196
-
197
- def disallow_same_shape_touching(self):
198
- # if shape is active then it must not touch any other shape of the same type
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, var in board.model_vars.items():
222
- assignment[pos] = solver.Value(var)
223
- all_other_variables = {
224
- 'fc': {k: solver.Value(v) for k, v in board.fc.items()}
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=str)
230
- for pos, val in single_res.assignment.items():
231
- c = 'X' if val == 1 else ' '
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('[\n' + '\n'.join([' ' + str(res[row].tolist()) + ',' for row in range(self.V)]) + '\n]')
234
- pass
235
- return generic_solve_all(self, board_to_solution, callback=callback if verbose_callback else None, verbose=verbose, max_solutions=max_solutions)
236
-
237
- def solve_then_constrain(self, verbose: bool = True):
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
- 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')