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,97 @@
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, get_pos, get_row_pos, get_col_pos
5
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
6
+ from puzzle_solver.core.utils_visualizer import combined_function
7
+
8
+
9
+ def spiral_from_topleft(matrix, N: int):
10
+ res = []
11
+ top, bottom = 0, N - 1
12
+ left, right = 0, N - 1
13
+ while top <= bottom and left <= right:
14
+ for c in range(left, right + 1): # go right
15
+ res.append(matrix[get_pos(x=c, y=top)])
16
+ top += 1
17
+ if top > bottom:
18
+ break
19
+ for r in range(top, bottom + 1): # go down
20
+ res.append(matrix[get_pos(x=right, y=r)])
21
+ right -= 1
22
+ if left > right:
23
+ break
24
+ for c in range(right, left - 1, -1): # go left
25
+ res.append(matrix[get_pos(x=c, y=bottom)])
26
+ bottom -= 1
27
+ if top > bottom:
28
+ break
29
+ for r in range(bottom, top - 1, -1): # go up
30
+ res.append(matrix[get_pos(x=left, y=r)])
31
+ left += 1
32
+ return res
33
+
34
+
35
+ def get_eq_val(model: cp_model.CpModel, int_var: cp_model.IntVar, val: int) -> cp_model.IntVar:
36
+ eq_var = model.NewBoolVar(f'{int_var}:{val}:eq')
37
+ model.Add(int_var == val).OnlyEnforceIf(eq_var)
38
+ model.Add(int_var != val).OnlyEnforceIf(eq_var.Not())
39
+ return eq_var
40
+
41
+
42
+ class Board:
43
+ def __init__(self, board: np.array):
44
+ self.V, self.H = board.shape
45
+ assert self.V == self.H and self.V >= 3, f'board must be square, got {self.V}x{self.H}'
46
+ self.board = board
47
+
48
+ self.model = cp_model.CpModel()
49
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
50
+ self.create_vars()
51
+ self.add_all_constraints()
52
+
53
+ def create_vars(self):
54
+ for pos in get_all_pos(self.V, self.H):
55
+ self.model_vars[pos] = self.model.NewIntVar(0, 4, f'{pos}')
56
+
57
+ def add_all_constraints(self):
58
+ for pos in get_all_pos(self.V, self.H): # force clues
59
+ c = get_char(self.board, pos).strip()
60
+ if c:
61
+ self.model.Add(self.model_vars[pos] == int(c))
62
+ for v in [1, 2, 3]:
63
+ for row in range(self.V):
64
+ self.model.AddExactlyOne([get_eq_val(self.model, self.model_vars[pos], v) for pos in get_row_pos(row, self.H)])
65
+ for col in range(self.H):
66
+ self.model.AddExactlyOne([get_eq_val(self.model, self.model_vars[pos], v) for pos in get_col_pos(col, self.V)])
67
+ vars_ = spiral_from_topleft(self.model_vars, self.V)
68
+ self.add_snail_pattern(vars_)
69
+
70
+ def add_snail_pattern(self, vars_):
71
+ """Enforce on vars_ (each in {0,1,2,3}) that, ignoring 0s, the nonzero values must follow the repeating pattern 1 -> 2 -> 3 -> 1 -> 2 -> 3 -> ..."""
72
+ # States: 0 = expect 1, 1 = expect 2, 2 = expect 3
73
+ start = 0
74
+ accept = [3] # we can stop after expecting 1 or 2 or 3
75
+ transitions = [ # (*tail*, *transition*, *head*)
76
+ # zeros don't change the state
77
+ (0, 0, 0),
78
+ (1, 0, 1),
79
+ (2, 0, 2),
80
+ (3, 0, 3),
81
+ # pattern 1 -> 2 -> 3 -> 1 ...
82
+ (0, 1, 1), # State 0: saw "1" -> Go to state 1 (which will expect 2)
83
+ (1, 2, 2), # State 1: saw "2" -> Go to state 2 (which will expect 3)
84
+ (2, 3, 3), # State 2: saw "3" -> Go to state 3 (which will expect 1)
85
+ (3, 1, 1), # State 3: saw "1" -> Go to state 1 (which will expect 2)
86
+ ]
87
+ self.model.AddAutomaton(vars_, start, accept, transitions)
88
+
89
+ def solve_and_print(self, verbose: bool = True):
90
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
91
+ return SingleSolution(assignment={pos: solver.Value(v) for pos, v in board.model_vars.items()})
92
+ def callback(single_res: SingleSolution):
93
+ print("Solution found")
94
+ print(combined_function(self.V, self.H,
95
+ center_char=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)]).replace('0', ' '),
96
+ ))
97
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,93 @@
1
+ import numpy as np
2
+ from ortools.sat.python import cp_model
3
+
4
+ from puzzle_solver.core.utils import Direction8, Pos, get_all_pos, get_char, get_pos, get_row_pos, get_col_pos, Direction, get_next_pos, in_bounds
5
+ from puzzle_solver.core.utils_ortools import 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() in ['L', 'R', 'U', 'D', 'O', ' '] for c in np.nditer(board)), 'board must contain only L, R, U, D, O, or space'
13
+ self.STATES = list(Direction) + ['O']
14
+ self.board = board
15
+ self.V, self.H = board.shape
16
+ assert self.V == 6 and self.H == 6, f'board must be 6x6, got {self.V}x{self.H}'
17
+ self.model = cp_model.CpModel()
18
+ self.model_vars: dict[tuple[Pos, str], cp_model.IntVar] = {}
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
+ for direction in self.STATES:
25
+ self.model_vars[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
26
+ self.model.AddExactlyOne([self.model_vars[(pos, direction)] for direction in self.STATES])
27
+
28
+ def add_all_constraints(self):
29
+ for pos in get_all_pos(self.V, self.H): # force clues
30
+ c = get_char(self.board, pos)
31
+ c = {'L': Direction.LEFT, 'R': Direction.RIGHT, 'U': Direction.UP, 'D': Direction.DOWN, 'O': 'O'}.get(c, None)
32
+ if c is not None:
33
+ self.model.Add(self.model_vars[(pos, c)] == 1)
34
+ for row in range(self.V): # each row, 1 of each direction and 2 O's
35
+ for direction in Direction:
36
+ self.model.AddExactlyOne([self.model_vars[(pos, direction)] for pos in get_row_pos(row, self.H)])
37
+ for col in range(self.H): # each column, 1 of each direction and 2 O's
38
+ for direction in Direction:
39
+ self.model.AddExactlyOne([self.model_vars[(pos, direction)] for pos in get_col_pos(col, self.V)])
40
+ for pos in get_all_pos(self.V, self.H):
41
+ for direction in Direction:
42
+ self.apply_orientation_rule(pos, direction)
43
+
44
+ def apply_orientation_rule(self, pos: Pos, direction: Direction):
45
+ # if cell is direction (for example L), then the cell to its left must not be R, and the cell to its up-right and down-right must also not be R
46
+ # and the cell to its up-right can't be U and the cell to its down-right can't be D. You have to see the triangles visually for it to make sense.
47
+ assert direction in Direction, f'direction must be in Direction, got {direction}'
48
+ if direction == Direction.LEFT:
49
+ disallow_pairs = [
50
+ (get_next_pos(pos, Direction8.LEFT), Direction.RIGHT),
51
+ (get_next_pos(pos, Direction8.UP_RIGHT), Direction.RIGHT),
52
+ (get_next_pos(pos, Direction8.DOWN_RIGHT), Direction.RIGHT),
53
+ (get_next_pos(pos, Direction8.UP_RIGHT), Direction.UP),
54
+ (get_next_pos(pos, Direction8.DOWN_RIGHT), Direction.DOWN),
55
+ ]
56
+ elif direction == Direction.RIGHT:
57
+ disallow_pairs = [
58
+ (get_next_pos(pos, Direction8.RIGHT), Direction.LEFT),
59
+ (get_next_pos(pos, Direction8.UP_LEFT), Direction.LEFT),
60
+ (get_next_pos(pos, Direction8.DOWN_LEFT), Direction.LEFT),
61
+ (get_next_pos(pos, Direction8.UP_LEFT), Direction.UP),
62
+ (get_next_pos(pos, Direction8.DOWN_LEFT), Direction.DOWN),
63
+ ]
64
+ elif direction == Direction.UP:
65
+ disallow_pairs = [
66
+ (get_next_pos(pos, Direction8.UP), Direction.DOWN),
67
+ (get_next_pos(pos, Direction8.DOWN_LEFT), Direction.DOWN),
68
+ (get_next_pos(pos, Direction8.DOWN_RIGHT), Direction.DOWN),
69
+ (get_next_pos(pos, Direction8.DOWN_LEFT), Direction.LEFT),
70
+ (get_next_pos(pos, Direction8.DOWN_RIGHT), Direction.RIGHT),
71
+ ]
72
+ elif direction == Direction.DOWN:
73
+ disallow_pairs = [
74
+ (get_next_pos(pos, Direction8.DOWN), Direction.UP),
75
+ (get_next_pos(pos, Direction8.UP_LEFT), Direction.UP),
76
+ (get_next_pos(pos, Direction8.UP_RIGHT), Direction.UP),
77
+ (get_next_pos(pos, Direction8.UP_LEFT), Direction.LEFT),
78
+ (get_next_pos(pos, Direction8.UP_RIGHT), Direction.RIGHT),
79
+ ]
80
+ else:
81
+ raise ValueError(f'invalid direction: {direction}')
82
+ disallow_pairs = [d_pair for d_pair in disallow_pairs if in_bounds(d_pair[0], self.V, self.H)]
83
+ for d_pos, d_direction in disallow_pairs:
84
+ self.model.Add(self.model_vars[(d_pos, d_direction)] == 0).OnlyEnforceIf(self.model_vars[(pos, direction)])
85
+
86
+
87
+ def solve_and_print(self, verbose: bool = True):
88
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
89
+ return SingleSolution(assignment={pos: 'O' if direction == 'O' else direction.name[0] for (pos, direction), var in board.model_vars.items() if solver.Value(var) == 1})
90
+ def callback(single_res: SingleSolution):
91
+ print("Solution found")
92
+ print(combined_function(self.V, self.H, center_char=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), ' ')))
93
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,75 @@
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, set_char, get_neighbors8, get_row_pos, get_col_pos
5
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
6
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
7
+
8
+
9
+ class Board:
10
+ def __init__(self, board: np.array, star_count: int = 1, shapeless: bool = False):
11
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
12
+ assert star_count >= 1 and isinstance(star_count, int), 'star_count must be an integer greater than or equal to 1'
13
+ self.board = board
14
+ self.V, self.H = board.shape
15
+ self.N = self.V * self.H
16
+ self.star_count = star_count
17
+ self.shapeless = shapeless
18
+ if not shapeless:
19
+ assert all((str(c.item()).isdecimal() for c in np.nditer(board))), 'board must contain only digits'
20
+ self.block_numbers = set([int(c.item()) for c in np.nditer(board)])
21
+ 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}
22
+ else:
23
+ assert all((str(c.item()) in [' ', 'B'] for c in np.nditer(board))), 'board must contain only digits'
24
+ self.model = cp_model.CpModel()
25
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
26
+
27
+ self.create_vars()
28
+ self.add_all_constraints()
29
+
30
+ def create_vars(self):
31
+ for pos in get_all_pos(self.V, self.H):
32
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
33
+
34
+ def add_all_constraints(self):
35
+ # N stars per row / column
36
+ for row in range(self.V):
37
+ self.model.Add(sum(self.model_vars[pos] for pos in get_row_pos(row, H=self.H)) == self.star_count)
38
+ for col in range(self.H):
39
+ self.model.Add(sum(self.model_vars[pos] for pos in get_col_pos(col, V=self.V)) == self.star_count)
40
+ if self.shapeless: # shapeless version = no blocks but disallow black cells
41
+ for pos in get_all_pos(self.V, self.H):
42
+ if get_char(self.board, pos) == 'B':
43
+ self.model.Add(self.model_vars[pos] == 0)
44
+ else: # shaped version = blocks
45
+ for block_i in self.block_numbers:
46
+ self.model.Add(sum(self.model_vars[pos] for pos in self.blocks[block_i]) == self.star_count)
47
+ # stars can't be adjacent
48
+ for pos in get_all_pos(self.V, self.H):
49
+ for neighbor in get_neighbors8(pos, V=self.V, H=self.H):
50
+ self.model.Add(self.model_vars[neighbor] == 0).OnlyEnforceIf(self.model_vars[pos])
51
+
52
+
53
+ def solve_and_print(self, verbose: bool = True):
54
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
55
+ assignment: dict[Pos, int] = {}
56
+ for pos, var in board.model_vars.items():
57
+ assignment[pos] = solver.value(var)
58
+ return SingleSolution(assignment=assignment)
59
+ def callback(single_res: SingleSolution):
60
+ print("Solution found")
61
+ res = np.full((self.V, self.H), ' ', dtype=object)
62
+ for pos in get_all_pos(self.V, self.H):
63
+ if single_res.assignment[pos] == 1:
64
+ set_char(res, pos, 'X')
65
+ else:
66
+ b = get_char(self.board, pos)
67
+ if b == 'B':
68
+ set_char(res, pos, ' ')
69
+ else:
70
+ set_char(res, pos, '.')
71
+ print(combined_function(self.V, self.H,
72
+ cell_flags=id_board_to_wall_fn(self.board),
73
+ center_char=lambda r, c: res[r][c]
74
+ ))
75
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,7 @@
1
+ import numpy as np
2
+
3
+ from . import star_battle
4
+
5
+ class Board(star_battle.Board):
6
+ def __init__(self, board: np.array, star_count: int = 1):
7
+ super().__init__(board=board, star_count=star_count, shapeless=True)
@@ -0,0 +1,267 @@
1
+ """
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
+ Look at the ./input_output/ directory for examples of input images and output json files.
4
+ The output json is used in the test_solve.py file to test the solver.
5
+ """
6
+ # import json
7
+ from pathlib import Path
8
+ import numpy as np
9
+ cv = None
10
+ Image = None
11
+
12
+
13
+ def extract_lines(bw):
14
+ # Create the images that will use to extract the horizontal and vertical lines
15
+ horizontal = np.copy(bw)
16
+ vertical = np.copy(bw)
17
+
18
+ cols = horizontal.shape[1]
19
+ horizontal_size = cols // 9
20
+ # Create structure element for extracting horizontal lines through morphology operations
21
+ horizontalStructure = cv.getStructuringElement(cv.MORPH_RECT, (horizontal_size, 1))
22
+ horizontal = cv.erode(horizontal, horizontalStructure)
23
+ horizontal = cv.dilate(horizontal, horizontalStructure)
24
+ horizontal_means = np.mean(horizontal, axis=1)
25
+ horizontal_cutoff = np.percentile(horizontal_means, 50)
26
+ # location where the horizontal lines are
27
+ horizontal_idx = np.where(horizontal_means > horizontal_cutoff)[0]
28
+ # print(f"horizontal_idx: {horizontal_idx}")
29
+ # height = len(horizontal_idx)
30
+ show_wait_destroy("horizontal", horizontal) # this has the horizontal lines
31
+
32
+ rows = vertical.shape[0]
33
+ verticalsize = rows // 9
34
+ # Create structure element for extracting vertical lines through morphology operations
35
+ verticalStructure = cv.getStructuringElement(cv.MORPH_RECT, (1, verticalsize))
36
+ vertical = cv.erode(vertical, verticalStructure)
37
+ vertical = cv.dilate(vertical, verticalStructure)
38
+ vertical_means = np.mean(vertical, axis=0)
39
+ vertical_cutoff = np.percentile(vertical_means, 50)
40
+ vertical_idx = np.where(vertical_means > vertical_cutoff)[0]
41
+ # print(f"vertical_idx: {vertical_idx}")
42
+ # width = len(vertical_idx)
43
+ # print(f"height: {height}, width: {width}")
44
+ # print(f"vertical_means: {vertical_means}")
45
+ show_wait_destroy("vertical", vertical) # this has the vertical lines
46
+
47
+ vertical = cv.bitwise_not(vertical)
48
+ # show_wait_destroy("vertical_bit", vertical)
49
+
50
+ return horizontal_idx, vertical_idx
51
+
52
+ def show_wait_destroy(winname, img):
53
+ cv.imshow(winname, img)
54
+ cv.moveWindow(winname, 500, 0)
55
+ cv.waitKey(0)
56
+ cv.destroyWindow(winname)
57
+
58
+
59
+ def mean_consecutives(arr: np.ndarray) -> np.ndarray:
60
+ """if a sequence of values is consecutive, then average the values"""
61
+ sums = []
62
+ counts = []
63
+ for i in range(len(arr)):
64
+ if i == 0:
65
+ sums.append(arr[i])
66
+ counts.append(1)
67
+ elif arr[i] == arr[i-1] + 1:
68
+ sums[-1] += arr[i]
69
+ counts[-1] += 1
70
+ else:
71
+ sums.append(arr[i])
72
+ counts.append(1)
73
+ return np.array(sums) // np.array(counts)
74
+
75
+ def dfs(x, y, out, output, current_num):
76
+ # if current_num == '48':
77
+ # print('dfs', x, y, current_num)
78
+ if x < 0 or x >= out.shape[1] or y < 0 or y >= out.shape[0]:
79
+ return
80
+ if out[y, x] != ' ':
81
+ return
82
+ out[y, x] = current_num
83
+ if output['top'][y, x] == 0:
84
+ dfs(x, y-1, out, output, current_num)
85
+ if output['left'][y, x] == 0:
86
+ dfs(x-1, y, out, output, current_num)
87
+ if output['right'][y, x] == 0:
88
+ dfs(x+1, y, out, output, current_num)
89
+ if output['bottom'][y, x] == 0:
90
+ dfs(x, y+1, out, output, current_num)
91
+
92
+ def main(image):
93
+ global Image
94
+ global cv
95
+ import matplotlib.pyplot as plt
96
+ from PIL import Image as Image_module
97
+ import cv2 as cv_module
98
+ Image = Image_module
99
+ cv = cv_module
100
+
101
+
102
+ image_path = Path(image)
103
+ output_path = image_path.parent / (image_path.stem + '.json')
104
+ src = cv.imread(image, cv.IMREAD_COLOR)
105
+ assert src is not None, f'Error opening image: {image}. Parent exists: {image_path.parent.exists()}'
106
+ if len(src.shape) != 2:
107
+ gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
108
+ else:
109
+ gray = src
110
+ # now the image is in grayscale
111
+
112
+ # Apply adaptiveThreshold at the bitwise_not of gray, notice the ~ symbol
113
+ gray = cv.bitwise_not(gray)
114
+ bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
115
+ cv.THRESH_BINARY, 15, -2)
116
+ # show_wait_destroy("binary", bw)
117
+
118
+ # show_wait_destroy("src", src)
119
+ horizontal_idx, vertical_idx = extract_lines(bw)
120
+ horizontal_idx = mean_consecutives(horizontal_idx)
121
+ vertical_idx = mean_consecutives(vertical_idx)
122
+ mean_vertical_dist = np.mean(np.diff(vertical_idx))
123
+ mean_horizontal_dist = np.mean(np.diff(horizontal_idx))
124
+ height = len(horizontal_idx)
125
+ width = len(vertical_idx)
126
+ print(f"height: {height}, width: {width}")
127
+ print(f"horizontal_idx: {horizontal_idx}")
128
+ print(f"vertical_idx: {vertical_idx}")
129
+ hists = {'top': {}, 'left': {}, 'right': {}, 'bottom': {}}
130
+ j_idx = 0
131
+ i_len = 0
132
+ j_len = 0
133
+ for j in range(height - 1):
134
+ i_idx = 0
135
+ for i in range(width - 1):
136
+ hidx1, hidx2 = horizontal_idx[j], horizontal_idx[j+1]
137
+ vidx1, vidx2 = vertical_idx[i], vertical_idx[i+1]
138
+ hidx1 = max(0, hidx1 - 2)
139
+ hidx2 = min(src.shape[0], hidx2 + 4)
140
+ vidx1 = max(0, vidx1 - 2)
141
+ vidx2 = min(src.shape[1], vidx2 + 4)
142
+ if (hidx2 - hidx1) < mean_horizontal_dist * 0.5 or (vidx2 - vidx1) < mean_vertical_dist * 0.5:
143
+ continue
144
+ print(f"j_idx: {j_idx}, i_idx: {i_idx}")
145
+ cell = src[hidx1:hidx2, vidx1:vidx2]
146
+ # print(f"cell_shape: {cell.shape}, mean_horizontal_dist: {mean_horizontal_dist}, mean_vertical_dist: {mean_vertical_dist}")
147
+ mid_x = cell.shape[1] // 2
148
+ mid_y = cell.shape[0] // 2
149
+ # if j > height - 4 and i > width - 6:
150
+ # show_wait_destroy(f"cell_{i}_{j}", cell)
151
+ # show_wait_destroy(f"cell_{i}_{j}", cell)
152
+ cell = cv.bitwise_not(cell) # invert colors
153
+ top = cell[0:10, mid_y-5:mid_y+5]
154
+ hists['top'][j_idx, i_idx] = np.sum(top)
155
+ left = cell[mid_x-5:mid_x+5, 0:10]
156
+ hists['left'][j_idx, i_idx] = np.sum(left)
157
+ right = cell[mid_x-5:mid_x+5, -10:]
158
+ hists['right'][j_idx, i_idx] = np.sum(right)
159
+ bottom = cell[-10:, mid_y-5:mid_y+5]
160
+ hists['bottom'][j_idx, i_idx] = np.sum(bottom)
161
+ i_idx += 1
162
+ i_len = max(i_len, i_idx)
163
+ if i_idx > 0:
164
+ j_idx += 1
165
+ j_len = max(j_len, j_idx)
166
+
167
+ fig, axs = plt.subplots(2, 2)
168
+ axs[0, 0].hist(list(hists['top'].values()), bins=100)
169
+ axs[0, 0].set_title('Top')
170
+ axs[0, 1].hist(list(hists['left'].values()), bins=100)
171
+ axs[0, 1].set_title('Left')
172
+ axs[1, 0].hist(list(hists['right'].values()), bins=100)
173
+ axs[1, 0].set_title('Right')
174
+ axs[1, 1].hist(list(hists['bottom'].values()), bins=100)
175
+ axs[1, 1].set_title('Bottom')
176
+ global_target = None
177
+ # global_target = 28_000
178
+ target_top = np.mean(list(hists['top'].values()))
179
+ target_left = np.mean(list(hists['left'].values()))
180
+ target_right = np.mean(list(hists['right'].values()))
181
+ target_bottom = np.mean(list(hists['bottom'].values()))
182
+ if global_target is not None:
183
+ target_top = global_target
184
+ target_left = global_target
185
+ target_right = global_target
186
+ target_bottom = global_target
187
+
188
+ axs[0, 0].axvline(target_top, color='red')
189
+ axs[0, 1].axvline(target_left, color='red')
190
+ axs[1, 0].axvline(target_right, color='red')
191
+ axs[1, 1].axvline(target_bottom, color='red')
192
+ # plt.show()
193
+ # 1/0
194
+ arr = np.zeros((j_len, i_len), dtype=object)
195
+ output = {'top': arr.copy(), 'left': arr.copy(), 'right': arr.copy(), 'bottom': arr.copy()}
196
+ print(f"target_top: {target_top}, target_left: {target_left}, target_right: {target_right}, target_bottom: {target_bottom}, j_len: {j_len}, i_len: {i_len}")
197
+ for j in range(j_len):
198
+ for i in range(i_len):
199
+ if hists['top'][j, i] > target_top:
200
+ output['top'][j, i] = 1
201
+ if hists['left'][j, i] > target_left:
202
+ output['left'][j, i] = 1
203
+ if hists['right'][j, i] > target_right:
204
+ output['right'][j, i] = 1
205
+ if hists['bottom'][j, i] > target_bottom:
206
+ output['bottom'][j, i] = 1
207
+ print(f"cell_{j}_{i}", end=': ')
208
+ print('T' if output['top'][j, i] else '', end='')
209
+ print('L' if output['left'][j, i] else '', end='')
210
+ print('R' if output['right'][j, i] else '', end='')
211
+ print('B' if output['bottom'][j, i] else '', end='')
212
+ print(' Sums: ', hists['top'][j, i], hists['left'][j, i], hists['right'][j, i], hists['bottom'][j, i])
213
+
214
+ current_count = 0
215
+ z_fill = 2
216
+ out = np.full_like(output['top'], ' ', dtype='U32')
217
+ for j in range(out.shape[0]):
218
+ if current_count > 99:
219
+ z_fill = 3
220
+ for i in range(out.shape[1]):
221
+ if out[j, i] == ' ':
222
+ if current_count == 48:
223
+ print(f"current_count: {current_count}, x: {i}, y: {j}")
224
+ dfs(i, j, out, output, str(current_count).zfill(z_fill))
225
+ current_count += 1
226
+ print(out)
227
+
228
+ with open(output_path, 'w') as f:
229
+ f.write('[\n')
230
+ for i, row in enumerate(out):
231
+ f.write(' ' + str(row.tolist()).replace("'", '"'))
232
+ if i != len(out) - 1:
233
+ f.write(',')
234
+ f.write('\n')
235
+ f.write(']')
236
+ print('output json: ', output_path)
237
+
238
+ # with open(output_path.parent / 'debug.json', 'w') as f:
239
+ # debug_pos = {}
240
+ # for j in range(out.shape[0]):
241
+ # for i in range(out.shape[1]):
242
+ # out_str = ''
243
+ # out_str += 'T' if output['top'][j, i] else ''
244
+ # out_str += 'L' if output['left'][j, i] else ''
245
+ # out_str += 'R' if output['right'][j, i] else ''
246
+ # out_str += 'B' if output['bottom'][j, i] else ''
247
+ # debug_pos[f'{j}_{i}'] = out_str
248
+ # json.dump(debug_pos, f, indent=2)
249
+
250
+ if __name__ == '__main__':
251
+ # to run this script and visualize the output, in the root run:
252
+ # python .\src\puzzle_solver\puzzles\stitches\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
253
+ # main(Path(__file__).parent / 'input_output' / 'MTM6OSw4MjEsNDAx.png')
254
+ # main(Path(__file__).parent / 'input_output' / 'weekly_oct_3rd_2025.png')
255
+ # main(Path(__file__).parent / 'input_output' / 'star_battle_67f73ff90cd8cdb4b3e30f56f5261f4968f5dac940bc6.png')
256
+ # main(Path(__file__).parent / 'input_output' / 'LITS_MDoxNzksNzY3.png')
257
+ # main(Path(__file__).parent / 'input_output' / 'lits_OTo3LDMwNiwwMTU=.png')
258
+ # main(Path(__file__).parent / 'input_output' / 'norinori_501d93110d6b4b818c268378973afbf268f96cfa8d7b4.png')
259
+ # main(Path(__file__).parent / 'input_output' / 'norinori_OTo0LDc0Miw5MTU.png')
260
+ # main(Path(__file__).parent / 'input_output' / 'heyawake_MDoxNiwxNDQ=.png')
261
+ # main(Path(__file__).parent / 'input_output' / 'heyawake_MTQ6ODQ4LDEzOQ==.png')
262
+ # main(Path(__file__).parent / 'input_output' / 'sudoku_jigsaw.png')
263
+ # main(Path(__file__).parent / 'input_output' / 'Screenshot 2025-11-01 025846.png')
264
+ # main(Path(__file__).parent / 'input_output' / 'Screenshot 2025-11-01 035658.png')
265
+ # main(Path(__file__).parent / 'input_output' / 'Screenshot 2025-11-01 044110.png')
266
+ # main(Path(__file__).parent / 'input_output' / 'Screenshot 2025-11-03 020828.png')
267
+ main(Path(__file__).parent / 'input_output' / 'ripple_effect_unsolved.png')
@@ -0,0 +1,96 @@
1
+ from typing import Union
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+
6
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_next_pos, Direction, get_row_pos, get_col_pos, in_bounds, get_opposite_direction, get_pos
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint
8
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
9
+
10
+
11
+ class Board:
12
+ def __init__(self, board: np.array, top: np.array, side: np.array, connection_count=1):
13
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
14
+ self.V = board.shape[0]
15
+ self.H = board.shape[1]
16
+ assert top.ndim == 1 and top.shape[0] == self.H, 'top must be a 1d array of length board width'
17
+ assert side.ndim == 1 and side.shape[0] == self.V, 'side must be a 1d array of length board height'
18
+ assert all((str(c.item()).isdecimal() for c in np.nditer(board))), 'board must contain only digits'
19
+ assert isinstance(connection_count, int) and connection_count >= 1, f'connection count must be int and >= 1, got {connection_count}'
20
+ self.board = board
21
+ self.top = top
22
+ self.side = side
23
+ self.connection_count = connection_count
24
+ self.top_empties = [self.H - i for i in self.top]
25
+ self.side_empties = [self.V - i for i in self.side]
26
+ self.block_numbers = set([int(c.item()) for c in np.nditer(board)])
27
+ 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}
28
+ # keys are (block_i, block_j) where block_i < block_j to avoid double counting
29
+ # values are sets of (pos_a, direction_a, pos_b, direction_b) where the two blocks meet
30
+ self.block_neighbors: dict[tuple[int, int], set[tuple[Pos, Direction, Pos, Direction]]] = {}
31
+ self.valid_stitches: set[tuple[Pos, Pos]] = set() # records all pairs of positions that can have a stitch
32
+ for pos in get_all_pos(self.V, self.H):
33
+ block_i = int(get_char(self.board, pos))
34
+ for direction in Direction:
35
+ neighbor = get_next_pos(pos, direction)
36
+ if not in_bounds(neighbor, self.V, self.H):
37
+ continue
38
+ block_j = int(get_char(self.board, neighbor))
39
+ if block_i < block_j: # avoid double counting
40
+ opposite_direction = get_opposite_direction(direction)
41
+ self.block_neighbors.setdefault((block_i, block_j), set()).add((pos, direction, neighbor, opposite_direction))
42
+ self.valid_stitches.add((pos, neighbor))
43
+ self.valid_stitches.add((neighbor, pos))
44
+
45
+ self.model = cp_model.CpModel()
46
+ self.model_vars: dict[tuple[Pos, Union[Direction, None]], cp_model.IntVar] = {}
47
+ self.create_vars()
48
+ self.add_all_constraints()
49
+
50
+ def create_vars(self):
51
+ for pos in get_all_pos(self.V, self.H):
52
+ for direction in Direction:
53
+ self.model_vars[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
54
+ self.model_vars[(pos, None)] = self.model.NewBoolVar(f'{pos}:empty')
55
+
56
+ def add_all_constraints(self):
57
+ # every position has exactly 1 state
58
+ for pos in get_all_pos(self.V, self.H):
59
+ state = [self.model_vars[(pos, direction)] for direction in Direction]
60
+ state.append(self.model_vars[(pos, None)])
61
+ self.model.AddExactlyOne(state)
62
+ # If a position points at X (and this is a valid pair) then X has to point at me
63
+ for pos in get_all_pos(self.V, self.H):
64
+ for direction in Direction:
65
+ neighbor = get_next_pos(pos, direction)
66
+ if not in_bounds(neighbor, self.V, self.H) or (pos, neighbor) not in self.valid_stitches: # this is not a valid stitch
67
+ self.model.Add(self.model_vars[(pos, direction)] == 0)
68
+ continue
69
+ opposite_direction = get_opposite_direction(direction)
70
+ self.model.Add(self.model_vars[(pos, direction)] == self.model_vars[(neighbor, opposite_direction)])
71
+
72
+ # all blocks connected exactly N times (N usually 1 but can be 2 or 3)
73
+ for connections in self.block_neighbors.values():
74
+ is_connected_list = []
75
+ for pos_a, direction_a, pos_b, direction_b in connections:
76
+ v = self.model.NewBoolVar(f'{pos_a}:{direction_a}->{pos_b}:{direction_b}')
77
+ and_constraint(self.model, v, [self.model_vars[pos_a, direction_a], self.model_vars[pos_b, direction_b]])
78
+ is_connected_list.append(v)
79
+ self.model.Add(sum(is_connected_list) == self.connection_count)
80
+
81
+ # sums of top and side must match
82
+ for col in range(self.H):
83
+ self.model.Add(sum([self.model_vars[pos, None] for pos in get_col_pos(col, self.V)]) == self.top_empties[col])
84
+ for row in range(self.V):
85
+ self.model.Add(sum([self.model_vars[pos, None] for pos in get_row_pos(row, self.H)]) == self.side_empties[row])
86
+
87
+ def solve_and_print(self, verbose: bool = True):
88
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
89
+ return SingleSolution(assignment={pos: direction.name[0] if direction is not None else ' ' for (pos, direction), var in board.model_vars.items() if solver.Value(var) == 1 and direction is not None})
90
+ def callback(single_res: SingleSolution):
91
+ print("Solution found")
92
+ print(combined_function(self.V, self.H,
93
+ cell_flags=id_board_to_wall_fn(self.board),
94
+ special_content=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), ''),
95
+ center_char=lambda r, c: 'O' if get_pos(x=c, y=r) in single_res.assignment else '.'))
96
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=9)