multi-puzzle-solver 1.1.8__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.
Files changed (106) hide show
  1. multi_puzzle_solver-1.1.8.dist-info/METADATA +4326 -0
  2. multi_puzzle_solver-1.1.8.dist-info/RECORD +106 -0
  3. multi_puzzle_solver-1.1.8.dist-info/WHEEL +5 -0
  4. multi_puzzle_solver-1.1.8.dist-info/top_level.txt +1 -0
  5. puzzle_solver/__init__.py +184 -0
  6. puzzle_solver/core/utils.py +298 -0
  7. puzzle_solver/core/utils_ortools.py +333 -0
  8. puzzle_solver/core/utils_visualizer.py +575 -0
  9. puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
  10. puzzle_solver/puzzles/aquarium/aquarium.py +97 -0
  11. puzzle_solver/puzzles/area_51/area_51.py +159 -0
  12. puzzle_solver/puzzles/battleships/battleships.py +139 -0
  13. puzzle_solver/puzzles/binairo/binairo.py +98 -0
  14. puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
  15. puzzle_solver/puzzles/black_box/black_box.py +243 -0
  16. puzzle_solver/puzzles/branches/branches.py +64 -0
  17. puzzle_solver/puzzles/bridges/bridges.py +104 -0
  18. puzzle_solver/puzzles/chess_range/chess_melee.py +6 -0
  19. puzzle_solver/puzzles/chess_range/chess_range.py +406 -0
  20. puzzle_solver/puzzles/chess_range/chess_solo.py +9 -0
  21. puzzle_solver/puzzles/chess_sequence/chess_sequence.py +262 -0
  22. puzzle_solver/puzzles/circle_9/circle_9.py +44 -0
  23. puzzle_solver/puzzles/clouds/clouds.py +81 -0
  24. puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
  25. puzzle_solver/puzzles/cow_and_cactus/cow_and_cactus.py +66 -0
  26. puzzle_solver/puzzles/dominosa/dominosa.py +67 -0
  27. puzzle_solver/puzzles/filling/filling.py +94 -0
  28. puzzle_solver/puzzles/flip/flip.py +64 -0
  29. puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
  30. puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +197 -0
  31. puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
  32. puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
  33. puzzle_solver/puzzles/guess/guess.py +232 -0
  34. puzzle_solver/puzzles/heyawake/heyawake.py +152 -0
  35. puzzle_solver/puzzles/hidden_stars/hidden_stars.py +52 -0
  36. puzzle_solver/puzzles/hidoku/hidoku.py +59 -0
  37. puzzle_solver/puzzles/inertia/inertia.py +121 -0
  38. puzzle_solver/puzzles/inertia/parse_map/parse_map.py +207 -0
  39. puzzle_solver/puzzles/inertia/tsp.py +400 -0
  40. puzzle_solver/puzzles/kakurasu/kakurasu.py +38 -0
  41. puzzle_solver/puzzles/kakuro/kakuro.py +81 -0
  42. puzzle_solver/puzzles/kakuro/krypto_kakuro.py +95 -0
  43. puzzle_solver/puzzles/keen/keen.py +76 -0
  44. puzzle_solver/puzzles/kropki/kropki.py +94 -0
  45. puzzle_solver/puzzles/light_up/light_up.py +58 -0
  46. puzzle_solver/puzzles/linesweeper/linesweeper.py +71 -0
  47. puzzle_solver/puzzles/link_a_pix/link_a_pix.py +91 -0
  48. puzzle_solver/puzzles/lits/lits.py +138 -0
  49. puzzle_solver/puzzles/magnets/magnets.py +96 -0
  50. puzzle_solver/puzzles/map/map.py +56 -0
  51. puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
  52. puzzle_solver/puzzles/mathrax/mathrax.py +93 -0
  53. puzzle_solver/puzzles/minesweeper/minesweeper.py +123 -0
  54. puzzle_solver/puzzles/mosaic/mosaic.py +38 -0
  55. puzzle_solver/puzzles/n_queens/n_queens.py +71 -0
  56. puzzle_solver/puzzles/nonograms/nonograms.py +121 -0
  57. puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -0
  58. puzzle_solver/puzzles/norinori/norinori.py +96 -0
  59. puzzle_solver/puzzles/number_path/number_path.py +76 -0
  60. puzzle_solver/puzzles/numbermaze/numbermaze.py +97 -0
  61. puzzle_solver/puzzles/nurikabe/nurikabe.py +130 -0
  62. puzzle_solver/puzzles/palisade/palisade.py +91 -0
  63. puzzle_solver/puzzles/pearl/pearl.py +107 -0
  64. puzzle_solver/puzzles/pipes/pipes.py +82 -0
  65. puzzle_solver/puzzles/range/range.py +59 -0
  66. puzzle_solver/puzzles/rectangles/rectangles.py +128 -0
  67. puzzle_solver/puzzles/ripple_effect/ripple_effect.py +83 -0
  68. puzzle_solver/puzzles/rooms/rooms.py +75 -0
  69. puzzle_solver/puzzles/schurs_numbers/schurs_numbers.py +73 -0
  70. puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
  71. puzzle_solver/puzzles/shingoki/shingoki.py +116 -0
  72. puzzle_solver/puzzles/signpost/signpost.py +93 -0
  73. puzzle_solver/puzzles/singles/singles.py +53 -0
  74. puzzle_solver/puzzles/slant/parse_map/parse_map.py +135 -0
  75. puzzle_solver/puzzles/slant/slant.py +111 -0
  76. puzzle_solver/puzzles/slitherlink/slitherlink.py +130 -0
  77. puzzle_solver/puzzles/snail/snail.py +97 -0
  78. puzzle_solver/puzzles/split_ends/split_ends.py +93 -0
  79. puzzle_solver/puzzles/star_battle/star_battle.py +75 -0
  80. puzzle_solver/puzzles/star_battle/star_battle_shapeless.py +7 -0
  81. puzzle_solver/puzzles/stitches/parse_map/parse_map.py +267 -0
  82. puzzle_solver/puzzles/stitches/stitches.py +96 -0
  83. puzzle_solver/puzzles/sudoku/sudoku.py +267 -0
  84. puzzle_solver/puzzles/suguru/suguru.py +55 -0
  85. puzzle_solver/puzzles/suko/suko.py +54 -0
  86. puzzle_solver/puzzles/tapa/tapa.py +97 -0
  87. puzzle_solver/puzzles/tatami/tatami.py +64 -0
  88. puzzle_solver/puzzles/tents/tents.py +80 -0
  89. puzzle_solver/puzzles/thermometers/thermometers.py +82 -0
  90. puzzle_solver/puzzles/towers/towers.py +89 -0
  91. puzzle_solver/puzzles/tracks/tracks.py +88 -0
  92. puzzle_solver/puzzles/trees_logic/trees_logic.py +48 -0
  93. puzzle_solver/puzzles/troix/dumplings.py +7 -0
  94. puzzle_solver/puzzles/troix/troix.py +75 -0
  95. puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
  96. puzzle_solver/puzzles/undead/undead.py +130 -0
  97. puzzle_solver/puzzles/unequal/unequal.py +128 -0
  98. puzzle_solver/puzzles/unruly/unruly.py +54 -0
  99. puzzle_solver/puzzles/vectors/vectors.py +94 -0
  100. puzzle_solver/puzzles/vermicelli/vermicelli.py +74 -0
  101. puzzle_solver/puzzles/walls/walls.py +52 -0
  102. puzzle_solver/puzzles/yajilin/yajilin.py +87 -0
  103. puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +172 -0
  104. puzzle_solver/puzzles/yin_yang/yin_yang.py +103 -0
  105. puzzle_solver/utils/etc/parser/board_color_digit.py +497 -0
  106. puzzle_solver/utils/visualizer.py +155 -0
@@ -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)
@@ -0,0 +1,54 @@
1
+ import numpy as np
2
+ from ortools.sat.python import cp_model
3
+ from ortools.sat.python.cp_model import LinearExpr as lxp
4
+
5
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_row_pos, get_col_pos, in_bounds, Direction, get_next_pos, get_pos
6
+ from puzzle_solver.core.utils_visualizer import combined_function
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
8
+
9
+
10
+ class Board:
11
+ def __init__(self, board: np.array):
12
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
13
+ assert board.shape[0] % 2 == 0 and board.shape[1] % 2 == 0, 'board must have even number of rows and columns'
14
+ self.board = board
15
+ self.V, self.H = board.shape
16
+ self.model = cp_model.CpModel()
17
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
18
+
19
+ self.create_vars()
20
+ self.add_all_constraints()
21
+
22
+ def create_vars(self):
23
+ for pos in get_all_pos(self.V, self.H):
24
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
25
+
26
+ def add_all_constraints(self):
27
+ for pos in get_all_pos(self.V, self.H):
28
+ # enforce hints
29
+ c = get_char(self.board, pos)
30
+ if c.strip():
31
+ self.model.Add(self.model_vars[pos] == (c.strip() == 'B'))
32
+ # no three consecutive squares, horizontally or vertically, are the same colour
33
+ for direction in [Direction.RIGHT, Direction.DOWN]:
34
+ var_list = [pos]
35
+ for _ in range(2):
36
+ var_list.append(get_next_pos(var_list[-1], direction))
37
+ if all(in_bounds(v, self.V, self.H) for v in var_list):
38
+ self.model.Add(lxp.Sum([self.model_vars[v] for v in var_list]) != 0)
39
+ self.model.Add(lxp.Sum([self.model_vars[v] for v in var_list]) != 3)
40
+ # each row and column contains the same number of black and white squares.
41
+ for col in range(self.H):
42
+ var_list = [self.model_vars[pos] for pos in get_col_pos(col, self.V)]
43
+ self.model.Add(lxp.Sum(var_list) == self.V // 2)
44
+ for row in range(self.V):
45
+ var_list = [self.model_vars[pos] for pos in get_row_pos(row, self.H)]
46
+ self.model.Add(lxp.Sum(var_list) == self.H // 2)
47
+
48
+ def solve_and_print(self, verbose: bool = True):
49
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
50
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
51
+ def callback(single_res: SingleSolution):
52
+ print("Solution found")
53
+ print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1))
54
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,94 @@
1
+ from dataclasses import dataclass
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+
6
+ from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_char, get_next_pos, get_pos, Shape, in_bounds
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
8
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
9
+
10
+
11
+ Block = tuple[Pos, int] # a block has a position and a number
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ShapeOnBoard:
16
+ is_active: cp_model.IntVar
17
+ uid: str
18
+ number: int
19
+ shape: Shape
20
+
21
+
22
+ def get_unblocked_ray(block: Block, direction: Direction, board: np.array) -> tuple[Pos, ...]:
23
+ out = []
24
+ pos = block[0]
25
+ while True:
26
+ pos = get_next_pos(pos, direction)
27
+ if not in_bounds(pos, board.shape[0], board.shape[1]):
28
+ break
29
+ if get_char(board, pos).strip() != '':
30
+ break
31
+ out.append(pos)
32
+ return tuple(out)
33
+
34
+
35
+ def find_quadruplets(target: int, limits: tuple[int, int, int, int]):
36
+ """
37
+ Find all quadruplets (a, b, c, d) such that a + b + c + d = target and a, b, c, d are in the given limits.
38
+ This is used to get all possible lengths for the four vectors coming out of a block and the limits are the maximum length of the vectors in each direction.
39
+ """
40
+ for a in range(limits[0] + 1):
41
+ for b in range(limits[1] + 1):
42
+ for c in range(limits[2] + 1):
43
+ d = target - (a + b + c)
44
+ if 0 <= d <= limits[3]:
45
+ yield (a, b, c, d)
46
+
47
+
48
+ class Board:
49
+ def __init__(self, board: np.array):
50
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
51
+ assert all((c.item().strip() in ['', '#']) or str(c.item()).strip().isdecimal() for c in np.nditer(board)), 'board must contain only space, #, or digits'
52
+ self.board = board
53
+ self.V, self.H = board.shape
54
+
55
+ self.model = cp_model.CpModel()
56
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
57
+ self.blocks: list[Block] = [(pos, int(get_char(self.board, pos).strip())) for pos in get_all_pos(self.V, self.H) if str(get_char(self.board, pos).strip()).isdecimal()]
58
+ self.blocks_to_shapes: dict[Block, set[ShapeOnBoard]] = {b: set() for b in self.blocks}
59
+ self.pos_to_shapes: dict[Pos, set[ShapeOnBoard]] = {p: set() for p in get_all_pos(self.V, self.H)}
60
+ self.init_shapes()
61
+ self.add_all_constraints()
62
+
63
+ def init_shapes(self):
64
+ for block in self.blocks:
65
+ direction_rays = [get_unblocked_ray(block, direction, self.board) for direction in Direction]
66
+ num = block[1]
67
+ for quadruplet in find_quadruplets(num, tuple(len(ray) for ray in direction_rays)):
68
+ flat_pos_set = set(p for i in range(4) for p in direction_rays[i][:quadruplet[i]])
69
+ shape = frozenset(flat_pos_set | {block[0]})
70
+ uid = f'{block[0]}:{len(self.blocks_to_shapes[block])}'
71
+ shape_on_board = ShapeOnBoard(is_active=self.model.NewBoolVar(f'{uid}:is_active'), shape=shape, uid=uid, number=num)
72
+ self.blocks_to_shapes[block].add(shape_on_board)
73
+ for p in shape:
74
+ self.pos_to_shapes[p].add(shape_on_board)
75
+
76
+ def add_all_constraints(self):
77
+ for block in self.blocks: # every block has exactly one shape active
78
+ self.model.AddExactlyOne(shape.is_active for shape in self.blocks_to_shapes[block])
79
+ for pos in get_all_pos(self.V, self.H): # every position has at most one shape active
80
+ self.model.AddAtMostOne(shape.is_active for shape in self.pos_to_shapes[pos])
81
+
82
+ def solve_and_print(self, verbose: bool = True):
83
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
84
+ return SingleSolution(assignment={pos: (s.uid, s.number) for pos in get_all_pos(board.V, board.H) for s in board.pos_to_shapes[pos] if solver.Value(s.is_active) == 1})
85
+ def callback(single_res: SingleSolution):
86
+ print("Solution found")
87
+ arr = np.array([[single_res.assignment.get(get_pos(x=c, y=r), '') for c in range(self.H)] for r in range(self.V)])
88
+ print(combined_function(self.V, self.H,
89
+ cell_flags=id_board_to_wall_fn(arr),
90
+ # center_char=lambda r, c: str(single_res.assignment.get(get_pos(x=c, y=r), (None, '#'))[1]), # display number on every filled position
91
+ center_char=lambda r, c: str(self.board[r, c]).strip(), # only display the block number
92
+ text_on_shaded_cells=False
93
+ ))
94
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,74 @@
1
+ from collections import defaultdict
2
+ import numpy as np
3
+ from ortools.sat.python import cp_model
4
+
5
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, Direction, get_next_pos, get_opposite_direction, get_pos, in_bounds
6
+ from puzzle_solver.core.utils_ortools import force_connected_component, generic_solve_all, SingleSolution
7
+ from puzzle_solver.core.utils_visualizer import combined_function
8
+
9
+
10
+ class Board:
11
+ def __init__(self, walls: np.array):
12
+ assert walls.ndim == 2, f'walls must be 2d, got {walls.ndim}'
13
+ assert all((len(c.item().strip()) <= 2) and all(ch in ['U', 'L', 'D', 'R'] for ch in c.item().strip()) for c in np.nditer(walls)), 'walls must contain only U, L, D, R'
14
+ self.walls = walls
15
+ self.V, self.H = walls.shape
16
+
17
+ self.model = cp_model.CpModel()
18
+ self.cell_active: dict[Pos, cp_model.IntVar] = {}
19
+ self.cell_direction: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
20
+
21
+ self.create_vars()
22
+ self.add_all_constraints()
23
+
24
+ def create_vars(self):
25
+ for pos in get_all_pos(self.V, self.H):
26
+ for direction in Direction:
27
+ next_pos = get_next_pos(pos, direction)
28
+ opposite_direction = get_opposite_direction(direction)
29
+ if (next_pos, opposite_direction) in self.cell_direction:
30
+ self.cell_direction[(pos, direction)] = self.cell_direction[(next_pos, opposite_direction)]
31
+ else:
32
+ self.cell_direction[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
33
+
34
+ def add_all_constraints(self):
35
+ # force the already given walls
36
+ str_to_direction = {'U': Direction.UP, 'L': Direction.LEFT, 'D': Direction.DOWN, 'R': Direction.RIGHT}
37
+ for pos in get_all_pos(self.V, self.H):
38
+ for char in get_char(self.walls, pos).strip():
39
+ self.model.Add(self.cell_direction[(pos, str_to_direction[char])] == 0)
40
+
41
+ # all cells have exactly 2 directions
42
+ for pos in get_all_pos(self.V, self.H):
43
+ s = sum([self.cell_direction[(pos, direction)] for direction in Direction])
44
+ self.model.Add(s == 2)
45
+
46
+ # cant point to border
47
+ for pos in get_all_pos(self.V, self.H):
48
+ for direction in Direction:
49
+ next_pos = get_next_pos(pos, direction)
50
+ if not in_bounds(next_pos, self.V, self.H):
51
+ self.model.Add(self.cell_direction[(pos, direction)] == 0)
52
+
53
+ # force single connected component
54
+ def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
55
+ p1, d1 = pd1
56
+ p2, d2 = pd2
57
+ if p1 == p2: # same position, different direction, is neighbor
58
+ return True
59
+ if get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1):
60
+ return True
61
+ return False
62
+ force_connected_component(self.model, self.cell_direction, is_neighbor=is_neighbor)
63
+
64
+ def solve_and_print(self, verbose: bool = True):
65
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
66
+ assignment: dict[Pos, str] = defaultdict(str)
67
+ for (pos, direction), var in board.cell_direction.items():
68
+ assignment[pos] += direction.name[0] if solver.BooleanValue(var) else ''
69
+ return SingleSolution(assignment=assignment)
70
+ def callback(single_res: SingleSolution):
71
+ print("Solution found")
72
+ print(combined_function(self.V, self.H, cell_flags=lambda r, c: self.walls[r, c],
73
+ special_content=lambda r, c: single_res.assignment[get_pos(x=c, y=r)].strip()))
74
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=20)
@@ -0,0 +1,52 @@
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_char, Direction, get_pos, get_ray
5
+ from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution
6
+ from puzzle_solver.core.utils_visualizer import combined_function
7
+
8
+
9
+ class Board:
10
+ def __init__(self, board: np.array):
11
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
12
+ assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
13
+ self.board = board
14
+ self.V, self.H = board.shape
15
+ self.model = cp_model.CpModel()
16
+ self.horiz_vars: dict[Pos, cp_model.IntVar] = {}
17
+ self.vert_vars: dict[Pos, cp_model.IntVar] = {}
18
+ self.create_vars()
19
+ self.add_all_constraints()
20
+
21
+ def create_vars(self):
22
+ for pos in get_all_pos(self.V, self.H):
23
+ self.horiz_vars[pos] = self.model.NewBoolVar(f'{pos}:horiz')
24
+ self.vert_vars[pos] = self.model.NewBoolVar(f'{pos}:vert')
25
+
26
+ def add_all_constraints(self):
27
+ for pos in get_all_pos(self.V, self.H):
28
+ c = get_char(self.board, pos)
29
+ if not str(c).isdecimal():
30
+ self.model.AddExactlyOne([self.horiz_vars[pos], self.vert_vars[pos]])
31
+ continue
32
+ self.model.Add(self.horiz_vars[pos] + self.vert_vars[pos] == 0) # spot with number has to be blank
33
+ self.range_clue(pos, int(c))
34
+
35
+ def range_clue(self, pos: Pos, k: int):
36
+ vis_vars: list[cp_model.IntVar] = []
37
+ d_to_var = {Direction.UP: self.vert_vars, Direction.DOWN: self.vert_vars, Direction.LEFT: self.horiz_vars, Direction.RIGHT: self.horiz_vars}
38
+ for direction in Direction: # Build visibility chains in four direction
39
+ ray = get_ray(pos, direction, self.V, self.H) # cells outward
40
+ for idx in range(len(ray)):
41
+ v = self.model.NewBoolVar(f"vis[{pos}]->({direction.name})[{idx}]")
42
+ and_constraint(self.model, target=v, cs=[d_to_var[direction][p] for p in ray[:idx+1]])
43
+ vis_vars.append(v)
44
+ self.model.Add(sum(vis_vars) == int(k)) # Sum of visible whites = 1 (itself) + sum(chains) == k
45
+
46
+ def solve_and_print(self, verbose: bool = True):
47
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
48
+ return SingleSolution(assignment={pos: 'LR'*solver.Value(board.horiz_vars[pos]) + 'UD'*solver.Value(board.vert_vars[pos]) for pos in get_all_pos(board.V, board.H)})
49
+ def callback(single_res: SingleSolution):
50
+ print("Solution found")
51
+ print(combined_function(self.V, self.H, center_char=lambda r, c: str(self.board[r, c]).strip(), special_content=lambda r, c: single_res.assignment[get_pos(x=c, y=r)].strip()))
52
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,87 @@
1
+ from collections import defaultdict
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+ from ortools.sat.python.cp_model import LinearExpr as lxp
6
+
7
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, Direction, in_bounds, get_next_pos, get_opposite_direction, get_pos, get_neighbors4, get_ray
8
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
9
+ from puzzle_solver.core.utils_visualizer import combined_function
10
+
11
+
12
+ class Board:
13
+ def __init__(self, board: np.array):
14
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
15
+ self.board = board
16
+ self.V, self.H = board.shape
17
+
18
+ self.model = cp_model.CpModel()
19
+ self.model_vars: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
20
+ self.create_vars()
21
+ self.add_all_constraints()
22
+
23
+ def create_vars(self):
24
+ for pos in get_all_pos(self.V, self.H):
25
+ self.model_vars[(pos, 'FILLED')] = self.model.NewBoolVar(f'{pos}:FILLED')
26
+ self.model_vars[(pos, 'NUMBER')] = self.model.NewBoolVar(f'{pos}:NUMBER')
27
+ for direction in Direction:
28
+ next_pos = get_next_pos(pos, direction)
29
+ opposite_direction = get_opposite_direction(direction)
30
+ if (next_pos, opposite_direction) in self.model_vars:
31
+ self.model_vars[(pos, direction)] = self.model_vars[(next_pos, opposite_direction)]
32
+ elif not in_bounds(next_pos, self.V, self.H):
33
+ self.model_vars[(pos, direction)] = self.model.NewConstant(0)
34
+ else:
35
+ self.model_vars[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
36
+
37
+ def add_all_constraints(self):
38
+ pos_in_ray = set() # keep track of all positions that are hit by rays; if a position has no ray then it cannot be filled
39
+ for pos in get_all_pos(self.V, self.H):
40
+ c = get_char(self.board, pos).strip()
41
+ if c:
42
+ d, num = c[0], int(c[1:])
43
+ d = {'U': Direction.UP, 'D': Direction.DOWN, 'L': Direction.LEFT, 'R': Direction.RIGHT}[d]
44
+ ray = get_ray(pos, d, self.V, self.H)
45
+ pos_in_ray.update(ray)
46
+ self.model.Add(self.model_vars[(pos, 'NUMBER')] == 1)
47
+ self.model.Add(lxp.Sum([self.model_vars[(p, 'FILLED')] for p in ray]) == num)
48
+ else:
49
+ self.model.Add(self.model_vars[(pos, 'NUMBER')] == 0)
50
+ lxp_sum = lxp.Sum([self.model_vars[(pos, direction)] for direction in Direction])
51
+ self.model.Add(lxp_sum == 0).OnlyEnforceIf(self.model_vars[(pos, 'NUMBER')])
52
+ self.model.Add(lxp_sum == 0).OnlyEnforceIf(self.model_vars[(pos, 'FILLED')])
53
+ self.model.Add(lxp_sum == 2).OnlyEnforceIf([self.model_vars[(pos, 'NUMBER')].Not(), self.model_vars[(pos, 'FILLED')].Not()])
54
+ for n in get_neighbors4(pos, self.V, self.H): # filled cannot be adjacent to another filled cell
55
+ self.model.Add(self.model_vars[(n, 'FILLED')] == 0).OnlyEnforceIf(self.model_vars[(pos, 'FILLED')])
56
+ self.model.Add(self.model_vars[(pos, 'FILLED')] == 0).OnlyEnforceIf(self.model_vars[(n, 'FILLED')])
57
+
58
+ for pos in get_all_pos(self.V, self.H): # if a position has not been hit by any ray then it cannot be filled
59
+ if pos not in pos_in_ray:
60
+ self.model.Add(self.model_vars[(pos, 'FILLED')] == 0)
61
+
62
+ def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
63
+ p1, d1 = pd1
64
+ p2, d2 = pd2
65
+ return (p1 == p2) or (get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1))
66
+ direction_vars = {k: v for k, v in self.model_vars.items() if isinstance(k[1], Direction)}
67
+ force_connected_component(self.model, direction_vars, is_neighbor=is_neighbor)
68
+
69
+ def solve_and_print(self, verbose: bool = True):
70
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
71
+ assignment: dict[Pos, str] = defaultdict(str)
72
+ for (pos, aux), var in board.model_vars.items():
73
+ if solver.BooleanValue(var):
74
+ assignment[pos] += aux.name[0] if isinstance(aux, Direction) else aux[0]
75
+ return SingleSolution(assignment=assignment)
76
+ def callback(single_res: SingleSolution):
77
+ print("Solution found")
78
+ answer = np.array([[single_res.assignment.get(get_pos(c, r), '') for c in range(self.H)] for r in range(self.V)])
79
+ d = {'U': '↑', 'D': '↓', 'L': '←', 'R': '→'}
80
+ num_arrow = np.array([[self.board[r, c][1:].strip() + d.get(self.board[r, c][0], '') for c in range(self.H)] for r in range(self.V)])
81
+ print(combined_function(self.V, self.H,
82
+ show_border_only=True,
83
+ is_shaded=lambda r, c: answer[r, c] == 'F',
84
+ special_content=lambda r, c: answer[r, c] if answer[r, c] not in ['N', 'F'] else None,
85
+ center_char=lambda r, c: num_arrow[r, c],
86
+ ))
87
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,172 @@
1
+ # THIS PARSER IS STILL VERY BUGGY
2
+
3
+ def extract_lines(bw):
4
+ horizontal = np.copy(bw)
5
+ vertical = np.copy(bw)
6
+
7
+ cols = horizontal.shape[1]
8
+ horizontal_size = max(5, cols // 20)
9
+ h_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (horizontal_size, 1))
10
+ horizontal = cv2.erode(horizontal, h_kernel)
11
+ horizontal = cv2.dilate(horizontal, h_kernel)
12
+ h_means = np.mean(horizontal, axis=1)
13
+ h_idx = np.where(h_means > np.percentile(h_means, 70))[0]
14
+
15
+ rows = vertical.shape[0]
16
+ verticalsize = max(5, rows // 20)
17
+ v_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, verticalsize))
18
+ vertical = cv2.erode(vertical, v_kernel)
19
+ vertical = cv2.dilate(vertical, v_kernel)
20
+ v_means = np.mean(vertical, axis=0)
21
+ v_idx = np.where(v_means > np.percentile(v_means, 70))[0]
22
+ return h_idx, v_idx
23
+
24
+
25
+ def _cluster_line_indices(indices, min_run=3):
26
+ """Group consecutive indices into line positions (take the mean of each run)."""
27
+ if len(indices) == 0:
28
+ return []
29
+ indices = np.sort(indices)
30
+ runs = []
31
+ run = [indices[0]]
32
+ for k in indices[1:]:
33
+ if k == run[-1] + 1:
34
+ run.append(k)
35
+ else:
36
+ if len(run) >= min_run:
37
+ runs.append(int(np.mean(run)))
38
+ run = [k]
39
+ if len(run) >= min_run:
40
+ runs.append(int(np.mean(run)))
41
+ # De-duplicate lines that are too close (rare)
42
+ dedup = []
43
+ for x in runs:
44
+ if not dedup or x - dedup[-1] > 2:
45
+ dedup.append(x)
46
+ return dedup
47
+
48
+
49
+ def extract_yinyang_board(image_path, debug=False):
50
+ # Load and pre-process
51
+ img = cv2.imread(str(image_path))
52
+ assert img is not None, f"Failed to read image: {image_path}"
53
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
54
+
55
+ # Light grid lines → enhance lines using adaptive threshold
56
+ # (binary inverted so lines/dots become white)
57
+ bw = cv2.adaptiveThreshold(
58
+ gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
59
+ cv2.THRESH_BINARY_INV, 35, 5
60
+ )
61
+
62
+ # Detect grid line indices (no guessing)
63
+ h_idx, v_idx = extract_lines(bw)
64
+ print(f"h_idx: {h_idx}")
65
+ print(f"v_idx: {v_idx}")
66
+ h_lines = h_idx
67
+ v_lines = v_idx
68
+ # h_lines = _cluster_line_indices(h_idx)
69
+ # v_lines = _cluster_line_indices(v_idx)
70
+ assert len(h_lines) >= 2 and len(v_lines) >= 2, "Could not detect grid lines"
71
+
72
+ # Cells are spans between successive grid lines
73
+ N_rows = len(h_lines) - 1
74
+ N_cols = len(v_lines) - 1
75
+ board = np.full((N_rows, N_cols), ' ', dtype='<U1')
76
+
77
+ # For robust per-cell analysis, also create a "dots" image with grid erased
78
+ # Remove thickened grid from bw
79
+ # Build masks for horizontal/vertical lines (reusing kernels sized by image dims)
80
+ cols = bw.shape[1]
81
+ rows = bw.shape[0]
82
+ h_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (max(5, cols // 20), 1))
83
+ v_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(5, rows // 20)))
84
+ horiz = cv2.morphologyEx(bw, cv2.MORPH_OPEN, h_kernel)
85
+ vert = cv2.morphologyEx(bw, cv2.MORPH_OPEN, v_kernel)
86
+ grid = cv2.bitwise_or(horiz, vert)
87
+ dots = cv2.bitwise_and(bw, cv2.bitwise_not(grid)) # mostly circles remain
88
+
89
+ # Iterate cells
90
+ print(f"N_rows: {N_rows}, N_cols: {N_cols}")
91
+ print(f"h_lines: {h_lines}")
92
+ print(f"v_lines: {v_lines}")
93
+ for r in range(N_rows):
94
+ y0, y1 = h_lines[r], h_lines[r + 1]
95
+ # shrink ROI to avoid line bleed
96
+ y0i = max(y0 + 2, 0)
97
+ y1i = max(min(y1 - 2, dots.shape[0]), y0i + 1)
98
+ for c in range(N_cols):
99
+ x0, x1 = v_lines[c], v_lines[c + 1]
100
+ x0i = max(x0 + 2, 0)
101
+ x1i = max(min(x1 - 2, dots.shape[1]), x0i + 1)
102
+
103
+ roi_gray = gray[y0i:y1i, x0i:x1i]
104
+ roi_dots = dots[y0i:y1i, x0i:x1i]
105
+ area = roi_dots.shape[0] * roi_dots.shape[1]
106
+ if area == 0:
107
+ continue
108
+
109
+ # If no meaningful foreground, it's empty
110
+ fg_area = int(np.count_nonzero(roi_dots))
111
+ if fg_area < 0.03 * area:
112
+ board[r, c] = ' '
113
+ continue
114
+
115
+ # Segment the largest blob (circle) inside the cell
116
+ contours, _ = cv2.findContours(roi_dots, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
117
+ if not contours:
118
+ board[r, c] = ' '
119
+ continue
120
+
121
+ cnt = max(contours, key=cv2.contourArea)
122
+ if cv2.contourArea(cnt) < 0.02 * area:
123
+ board[r, c] = ' '
124
+ continue
125
+
126
+ mask = np.zeros_like(roi_dots)
127
+ cv2.drawContours(mask, [cnt], -1, 255, thickness=-1)
128
+
129
+ mean_inside = float(cv2.mean(roi_gray, mask=mask)[0])
130
+
131
+ # Heuristic: black stones have dark interior; white stones bright interior
132
+ # (grid background is white; outlines contribute little to mean)
133
+ board[r, c] = 'B' if mean_inside < 150 else 'W'
134
+ non_empty_rows = []
135
+ non_empty_cols = []
136
+ for r in range(N_rows):
137
+ if not all(board[r, :] == ' '):
138
+ non_empty_rows.append(r)
139
+ for c in range(N_cols):
140
+ if not all(board[:, c] == ' '):
141
+ non_empty_cols.append(c)
142
+ board = board[non_empty_rows, :][:, non_empty_cols]
143
+
144
+ if debug:
145
+ for row in board:
146
+ print(row.tolist())
147
+ output_path = Path(__file__).parent / "input_output" / (image_path.stem + ".json")
148
+ with open(output_path, 'w') as f:
149
+ f.write('[\n')
150
+ for i, row in enumerate(board):
151
+ f.write(' ' + str(row.tolist()).replace("'", '"'))
152
+ if i != len(board) - 1:
153
+ f.write(',')
154
+ f.write('\n')
155
+ f.write(']')
156
+ print('output json: ', output_path)
157
+
158
+ return board
159
+
160
+ if __name__ == "__main__":
161
+ # THIS PARSER IS STILL VERY BUGGY
162
+ # python .\src\puzzle_solver\puzzles\yin_yang\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
163
+ import cv2
164
+ import numpy as np
165
+ from pathlib import Path
166
+ image_path = Path(__file__).parent / "input_output" / "OTozLDY2MSw3MjE=.png"
167
+ # image_path = Path(__file__).parent / "input_output" / "MzoyLDcwMSw2NTY=.png"
168
+ # image_path = Path(__file__).parent / "input_output" / "Njo5MDcsNDk4.png"
169
+ # image_path = Path(__file__).parent / "input_output" / "MTE6Niw0NjEsMTIx.png"
170
+ assert image_path.exists(), f"Image file does not exist: {image_path}"
171
+ board = extract_yinyang_board(image_path, debug=True)
172
+ print(board)