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.
- multi_puzzle_solver-1.1.8.dist-info/METADATA +4326 -0
- multi_puzzle_solver-1.1.8.dist-info/RECORD +106 -0
- multi_puzzle_solver-1.1.8.dist-info/WHEEL +5 -0
- multi_puzzle_solver-1.1.8.dist-info/top_level.txt +1 -0
- puzzle_solver/__init__.py +184 -0
- puzzle_solver/core/utils.py +298 -0
- puzzle_solver/core/utils_ortools.py +333 -0
- puzzle_solver/core/utils_visualizer.py +575 -0
- puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
- puzzle_solver/puzzles/aquarium/aquarium.py +97 -0
- puzzle_solver/puzzles/area_51/area_51.py +159 -0
- puzzle_solver/puzzles/battleships/battleships.py +139 -0
- puzzle_solver/puzzles/binairo/binairo.py +98 -0
- puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
- puzzle_solver/puzzles/black_box/black_box.py +243 -0
- puzzle_solver/puzzles/branches/branches.py +64 -0
- puzzle_solver/puzzles/bridges/bridges.py +104 -0
- puzzle_solver/puzzles/chess_range/chess_melee.py +6 -0
- puzzle_solver/puzzles/chess_range/chess_range.py +406 -0
- puzzle_solver/puzzles/chess_range/chess_solo.py +9 -0
- puzzle_solver/puzzles/chess_sequence/chess_sequence.py +262 -0
- puzzle_solver/puzzles/circle_9/circle_9.py +44 -0
- puzzle_solver/puzzles/clouds/clouds.py +81 -0
- puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
- puzzle_solver/puzzles/cow_and_cactus/cow_and_cactus.py +66 -0
- puzzle_solver/puzzles/dominosa/dominosa.py +67 -0
- puzzle_solver/puzzles/filling/filling.py +94 -0
- puzzle_solver/puzzles/flip/flip.py +64 -0
- puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
- puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +197 -0
- puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
- puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
- puzzle_solver/puzzles/guess/guess.py +232 -0
- puzzle_solver/puzzles/heyawake/heyawake.py +152 -0
- puzzle_solver/puzzles/hidden_stars/hidden_stars.py +52 -0
- puzzle_solver/puzzles/hidoku/hidoku.py +59 -0
- puzzle_solver/puzzles/inertia/inertia.py +121 -0
- puzzle_solver/puzzles/inertia/parse_map/parse_map.py +207 -0
- puzzle_solver/puzzles/inertia/tsp.py +400 -0
- puzzle_solver/puzzles/kakurasu/kakurasu.py +38 -0
- puzzle_solver/puzzles/kakuro/kakuro.py +81 -0
- puzzle_solver/puzzles/kakuro/krypto_kakuro.py +95 -0
- puzzle_solver/puzzles/keen/keen.py +76 -0
- puzzle_solver/puzzles/kropki/kropki.py +94 -0
- puzzle_solver/puzzles/light_up/light_up.py +58 -0
- puzzle_solver/puzzles/linesweeper/linesweeper.py +71 -0
- puzzle_solver/puzzles/link_a_pix/link_a_pix.py +91 -0
- puzzle_solver/puzzles/lits/lits.py +138 -0
- puzzle_solver/puzzles/magnets/magnets.py +96 -0
- puzzle_solver/puzzles/map/map.py +56 -0
- puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
- puzzle_solver/puzzles/mathrax/mathrax.py +93 -0
- puzzle_solver/puzzles/minesweeper/minesweeper.py +123 -0
- puzzle_solver/puzzles/mosaic/mosaic.py +38 -0
- puzzle_solver/puzzles/n_queens/n_queens.py +71 -0
- puzzle_solver/puzzles/nonograms/nonograms.py +121 -0
- puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -0
- puzzle_solver/puzzles/norinori/norinori.py +96 -0
- puzzle_solver/puzzles/number_path/number_path.py +76 -0
- puzzle_solver/puzzles/numbermaze/numbermaze.py +97 -0
- puzzle_solver/puzzles/nurikabe/nurikabe.py +130 -0
- puzzle_solver/puzzles/palisade/palisade.py +91 -0
- puzzle_solver/puzzles/pearl/pearl.py +107 -0
- puzzle_solver/puzzles/pipes/pipes.py +82 -0
- puzzle_solver/puzzles/range/range.py +59 -0
- puzzle_solver/puzzles/rectangles/rectangles.py +128 -0
- puzzle_solver/puzzles/ripple_effect/ripple_effect.py +83 -0
- puzzle_solver/puzzles/rooms/rooms.py +75 -0
- puzzle_solver/puzzles/schurs_numbers/schurs_numbers.py +73 -0
- puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
- puzzle_solver/puzzles/shingoki/shingoki.py +116 -0
- puzzle_solver/puzzles/signpost/signpost.py +93 -0
- puzzle_solver/puzzles/singles/singles.py +53 -0
- puzzle_solver/puzzles/slant/parse_map/parse_map.py +135 -0
- puzzle_solver/puzzles/slant/slant.py +111 -0
- puzzle_solver/puzzles/slitherlink/slitherlink.py +130 -0
- puzzle_solver/puzzles/snail/snail.py +97 -0
- puzzle_solver/puzzles/split_ends/split_ends.py +93 -0
- puzzle_solver/puzzles/star_battle/star_battle.py +75 -0
- puzzle_solver/puzzles/star_battle/star_battle_shapeless.py +7 -0
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +267 -0
- puzzle_solver/puzzles/stitches/stitches.py +96 -0
- puzzle_solver/puzzles/sudoku/sudoku.py +267 -0
- puzzle_solver/puzzles/suguru/suguru.py +55 -0
- puzzle_solver/puzzles/suko/suko.py +54 -0
- puzzle_solver/puzzles/tapa/tapa.py +97 -0
- puzzle_solver/puzzles/tatami/tatami.py +64 -0
- puzzle_solver/puzzles/tents/tents.py +80 -0
- puzzle_solver/puzzles/thermometers/thermometers.py +82 -0
- puzzle_solver/puzzles/towers/towers.py +89 -0
- puzzle_solver/puzzles/tracks/tracks.py +88 -0
- puzzle_solver/puzzles/trees_logic/trees_logic.py +48 -0
- puzzle_solver/puzzles/troix/dumplings.py +7 -0
- puzzle_solver/puzzles/troix/troix.py +75 -0
- puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
- puzzle_solver/puzzles/undead/undead.py +130 -0
- puzzle_solver/puzzles/unequal/unequal.py +128 -0
- puzzle_solver/puzzles/unruly/unruly.py +54 -0
- puzzle_solver/puzzles/vectors/vectors.py +94 -0
- puzzle_solver/puzzles/vermicelli/vermicelli.py +74 -0
- puzzle_solver/puzzles/walls/walls.py +52 -0
- puzzle_solver/puzzles/yajilin/yajilin.py +87 -0
- puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +172 -0
- puzzle_solver/puzzles/yin_yang/yin_yang.py +103 -0
- puzzle_solver/utils/etc/parser/board_color_digit.py +497 -0
- puzzle_solver/utils/visualizer.py +155 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
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_next_pos, get_char, in_bounds, set_char, get_pos, get_opposite_direction
|
|
7
|
+
from puzzle_solver.core.utils_ortools import generic_unique_projections, force_connected_component_using_demand, and_constraint, SingleSolution
|
|
8
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[Pos]:
|
|
12
|
+
out = []
|
|
13
|
+
while True:
|
|
14
|
+
out.append(pos)
|
|
15
|
+
pos = get_next_pos(pos, direction)
|
|
16
|
+
if not in_bounds(pos, V, H):
|
|
17
|
+
break
|
|
18
|
+
return out
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Board:
|
|
22
|
+
def __init__(self, board: np.array):
|
|
23
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
24
|
+
assert all((c.item().strip() == '') or (str(c.item())[:-1].isdecimal() and c.item()[-1].upper() in ['B', 'W']) for c in np.nditer(board)), 'board must contain only space or digits and B/W'
|
|
25
|
+
self.V, self.H = board.shape
|
|
26
|
+
self.board = board
|
|
27
|
+
self.board_numbers: dict[Pos, int] = {}
|
|
28
|
+
self.board_colors: dict[Pos, str] = {}
|
|
29
|
+
for pos in get_all_pos(self.V, self.H):
|
|
30
|
+
c = get_char(board, pos)
|
|
31
|
+
if c.strip() == '':
|
|
32
|
+
continue
|
|
33
|
+
self.board_numbers[pos] = int(c[:-1])
|
|
34
|
+
self.board_colors[pos] = c[-1].upper()
|
|
35
|
+
|
|
36
|
+
self.model = cp_model.CpModel()
|
|
37
|
+
self.cell_active: dict[Pos, cp_model.IntVar] = {}
|
|
38
|
+
self.cell_direction: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
|
|
39
|
+
|
|
40
|
+
self.create_vars()
|
|
41
|
+
self.add_all_constraints()
|
|
42
|
+
|
|
43
|
+
def create_vars(self):
|
|
44
|
+
for pos in get_all_pos(self.V, self.H):
|
|
45
|
+
self.cell_active[pos] = self.model.NewBoolVar(f'{pos}')
|
|
46
|
+
for direction in Direction:
|
|
47
|
+
neighbor = get_next_pos(pos, direction)
|
|
48
|
+
opposite_direction = get_opposite_direction(direction)
|
|
49
|
+
if not in_bounds(neighbor, self.V, self.H):
|
|
50
|
+
self.cell_direction[(pos, direction)] = self.model.NewConstant(0)
|
|
51
|
+
continue
|
|
52
|
+
if (neighbor, opposite_direction) in self.cell_direction:
|
|
53
|
+
self.cell_direction[(pos, direction)] = self.cell_direction[(neighbor, opposite_direction)]
|
|
54
|
+
else:
|
|
55
|
+
self.cell_direction[(pos, direction)] = self.model.NewBoolVar(f'{pos}-{neighbor}')
|
|
56
|
+
|
|
57
|
+
def add_all_constraints(self):
|
|
58
|
+
for pos in get_all_pos(self.V, self.H):
|
|
59
|
+
s = sum([self.cell_direction[(pos, direction)] for direction in Direction])
|
|
60
|
+
self.model.Add(s == 2).OnlyEnforceIf(self.cell_active[pos])
|
|
61
|
+
self.model.Add(s == 0).OnlyEnforceIf(self.cell_active[pos].Not())
|
|
62
|
+
if pos not in self.board_numbers:
|
|
63
|
+
continue
|
|
64
|
+
self.enforce_corner_color_and_number(pos, self.board_colors[pos], self.board_numbers[pos]) # enforce colors and number
|
|
65
|
+
self.force_connected_component() # enforce single connected component
|
|
66
|
+
|
|
67
|
+
def enforce_corner_color_and_number(self, pos: Pos, pos_color: str, pos_number: int):
|
|
68
|
+
assert pos_color in ['W', 'B'] and pos_number > 0, f'Invalid color or number: {pos_color}, {pos_number}'
|
|
69
|
+
self.model.Add(self.cell_active[pos] == 1)
|
|
70
|
+
if pos_color == 'W': # White circles must be passed through in a straight line
|
|
71
|
+
self.model.Add(self.cell_direction[(pos, Direction.RIGHT)] == self.cell_direction[(pos, Direction.LEFT)])
|
|
72
|
+
self.model.Add(self.cell_direction[(pos, Direction.DOWN)] == self.cell_direction[(pos, Direction.UP)])
|
|
73
|
+
elif pos_color == 'B': # Black circles must be turned upon
|
|
74
|
+
self.model.Add(self.cell_direction[(pos, Direction.RIGHT)] == 0).OnlyEnforceIf([self.cell_direction[(pos, Direction.LEFT)]])
|
|
75
|
+
self.model.Add(self.cell_direction[(pos, Direction.LEFT)] == 0).OnlyEnforceIf([self.cell_direction[(pos, Direction.RIGHT)]])
|
|
76
|
+
self.model.Add(self.cell_direction[(pos, Direction.DOWN)] == 0).OnlyEnforceIf([self.cell_direction[(pos, Direction.UP)]])
|
|
77
|
+
self.model.Add(self.cell_direction[(pos, Direction.UP)] == 0).OnlyEnforceIf([self.cell_direction[(pos, Direction.DOWN)]])
|
|
78
|
+
else:
|
|
79
|
+
raise ValueError(f'Invalid color: {pos_color}')
|
|
80
|
+
vis_vars: list[cp_model.IntVar] = [] # The numbers in the circles show the sum of the lengths of the 2 straight lines going out of that circle.
|
|
81
|
+
for direction in Direction: # Build visibility chains in four direction
|
|
82
|
+
ray = get_ray(pos, self.V, self.H, direction) # cells outward
|
|
83
|
+
for idx in range(len(ray)):
|
|
84
|
+
v = self.model.NewBoolVar(f"vis[{pos}]->({direction.name})[{idx}]")
|
|
85
|
+
and_constraint(self.model, target=v, cs=[self.cell_direction[(p, direction)] for p in ray[:idx+1]])
|
|
86
|
+
vis_vars.append(v)
|
|
87
|
+
self.model.Add(sum(vis_vars) == pos_number)
|
|
88
|
+
|
|
89
|
+
def force_connected_component(self):
|
|
90
|
+
def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
|
|
91
|
+
p1, d1 = pd1
|
|
92
|
+
p2, d2 = pd2
|
|
93
|
+
if p1 == p2 and d1 != d2: # same position, different direction, is neighbor
|
|
94
|
+
return True
|
|
95
|
+
if get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1):
|
|
96
|
+
return True
|
|
97
|
+
return False
|
|
98
|
+
force_connected_component_using_demand(self.model, self.cell_direction, is_neighbor=is_neighbor)
|
|
99
|
+
|
|
100
|
+
def solve_and_print(self, verbose: bool = True):
|
|
101
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
102
|
+
assignment: dict[Pos, str] = defaultdict(str)
|
|
103
|
+
for (pos, direction), var in board.cell_direction.items():
|
|
104
|
+
assignment[pos] += direction.name[0] if solver.BooleanValue(var) else ''
|
|
105
|
+
return SingleSolution(assignment=assignment)
|
|
106
|
+
def callback(single_res: SingleSolution):
|
|
107
|
+
print("Solution found")
|
|
108
|
+
output_board = np.full((self.V, self.H), '', dtype=object)
|
|
109
|
+
for pos in get_all_pos(self.V, self.H):
|
|
110
|
+
if get_char(self.board, pos)[-1] in ['B', 'W']: # if the main board has a white or black pearl, put it in the output
|
|
111
|
+
set_char(output_board, pos, get_char(self.board, pos))
|
|
112
|
+
if not single_res.assignment[pos].strip(): # if the cell does not the line through it, put a dot
|
|
113
|
+
set_char(output_board, pos, '.')
|
|
114
|
+
print(combined_function(self.V, self.H, show_grid=False, special_content=lambda r, c: single_res.assignment[get_pos(x=c, y=r)], center_char=lambda r, c: output_board[r, c]))
|
|
115
|
+
project_vars = list(self.cell_direction.values())
|
|
116
|
+
return generic_unique_projections(self, project_vars, 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
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
4
|
+
|
|
5
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, in_bounds, get_next_pos, Direction8
|
|
6
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
CHAR_TO_DIRECTION8 = {
|
|
10
|
+
'Q': Direction8.UP_LEFT,
|
|
11
|
+
'W': Direction8.UP,
|
|
12
|
+
'E': Direction8.UP_RIGHT,
|
|
13
|
+
'A': Direction8.LEFT,
|
|
14
|
+
'D': Direction8.RIGHT,
|
|
15
|
+
'Z': Direction8.DOWN_LEFT,
|
|
16
|
+
'X': Direction8.DOWN,
|
|
17
|
+
'C': Direction8.DOWN_RIGHT,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def beam(pos: Pos, V: int, H: int, direction: Direction8) -> list[Pos]:
|
|
22
|
+
out = []
|
|
23
|
+
while True:
|
|
24
|
+
pos = get_next_pos(pos, direction)
|
|
25
|
+
if not in_bounds(pos, V, H):
|
|
26
|
+
break
|
|
27
|
+
out.append(pos)
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
class Board:
|
|
31
|
+
def __init__(self, board: np.array, values: np.array):
|
|
32
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
33
|
+
assert values.ndim == 2, f'values must be 2d, got {values.ndim}'
|
|
34
|
+
assert board.shape == values.shape, f'board and values must have the same shape, got {board.shape} and {values.shape}'
|
|
35
|
+
self.board = board
|
|
36
|
+
self.values = values
|
|
37
|
+
self.V = board.shape[0]
|
|
38
|
+
self.H = board.shape[1]
|
|
39
|
+
self.N = self.V * self.H
|
|
40
|
+
assert all(int(c.item()) >= 0 and int(c.item()) <= self.N for c in np.nditer(values)), 'values must contain only integers between 0 and N'
|
|
41
|
+
|
|
42
|
+
self.model = cp_model.CpModel()
|
|
43
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
44
|
+
|
|
45
|
+
self.create_vars()
|
|
46
|
+
self.add_all_constraints()
|
|
47
|
+
|
|
48
|
+
def create_vars(self):
|
|
49
|
+
for pos in get_all_pos(V=self.V, H=self.H):
|
|
50
|
+
self.model_vars[pos] = self.model.NewIntVar(1, self.N, f'{pos}')
|
|
51
|
+
|
|
52
|
+
def add_all_constraints(self):
|
|
53
|
+
# constrain clues
|
|
54
|
+
for pos in get_all_pos(V=self.V, H=self.H):
|
|
55
|
+
c = int(get_char(self.values, pos))
|
|
56
|
+
if c == 0:
|
|
57
|
+
continue
|
|
58
|
+
self.model.Add(self.model_vars[pos] == c)
|
|
59
|
+
# all values are unique
|
|
60
|
+
self.model.AddAllDifferent(list(self.model_vars.values()))
|
|
61
|
+
# arrow for x points to x+1
|
|
62
|
+
for pos in get_all_pos(V=self.V, H=self.H):
|
|
63
|
+
c = get_char(self.board, pos)
|
|
64
|
+
if c == ' ':
|
|
65
|
+
continue
|
|
66
|
+
direction = CHAR_TO_DIRECTION8[c]
|
|
67
|
+
self.constrain_plus_one(pos, direction)
|
|
68
|
+
|
|
69
|
+
def constrain_plus_one(self, pos: Pos, direction: Direction8):
|
|
70
|
+
beam_res = beam(pos, self.V, self.H, direction)
|
|
71
|
+
is_eq_list = []
|
|
72
|
+
for p in beam_res:
|
|
73
|
+
aux = self.model.NewBoolVar(f'{pos}:{p}')
|
|
74
|
+
self.model.Add(self.model_vars[p] == self.model_vars[pos] + 1).OnlyEnforceIf(aux)
|
|
75
|
+
self.model.Add(self.model_vars[p] != self.model_vars[pos] + 1).OnlyEnforceIf(aux.Not())
|
|
76
|
+
is_eq_list.append(aux)
|
|
77
|
+
self.model.Add(lxp.Sum(is_eq_list) == 1)
|
|
78
|
+
|
|
79
|
+
def solve_and_print(self, verbose: bool = True):
|
|
80
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
81
|
+
assignment: dict[Pos, str] = {}
|
|
82
|
+
for pos, var in board.model_vars.items():
|
|
83
|
+
assignment[pos] = solver.Value(var)
|
|
84
|
+
return SingleSolution(assignment=assignment)
|
|
85
|
+
def callback(single_res: SingleSolution):
|
|
86
|
+
print("Solution found")
|
|
87
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
88
|
+
for pos in get_all_pos(V=self.V, H=self.H):
|
|
89
|
+
c = get_char(self.board, pos)
|
|
90
|
+
c = single_res.assignment[pos]
|
|
91
|
+
set_char(res, pos, c)
|
|
92
|
+
print(res)
|
|
93
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=20)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from ortools.sat.python import cp_model
|
|
4
|
+
|
|
5
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_neighbors4, get_all_pos_to_idx_dict, get_row_pos, get_col_pos, get_pos
|
|
6
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
7
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
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
|
+
self.board = board
|
|
14
|
+
self.V, self.H = board.shape
|
|
15
|
+
self.N = self.V * self.H
|
|
16
|
+
self.idx_of: dict[Pos, int] = get_all_pos_to_idx_dict(self.V, self.H)
|
|
17
|
+
|
|
18
|
+
self.model = cp_model.CpModel()
|
|
19
|
+
self.B: dict[Pos, cp_model.IntVar] = {}
|
|
20
|
+
self.W: dict[Pos, cp_model.IntVar] = {}
|
|
21
|
+
self.Num: dict[Pos, cp_model.IntVar] = {} # value of squares (Num = N + idx if black, else board[pos])
|
|
22
|
+
self.create_vars()
|
|
23
|
+
self.add_all_constraints()
|
|
24
|
+
|
|
25
|
+
def create_vars(self):
|
|
26
|
+
for pos in get_all_pos(self.V, self.H):
|
|
27
|
+
self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
|
|
28
|
+
self.W[pos] = self.B[pos].Not()
|
|
29
|
+
self.Num[pos] = self.model.NewIntVar(0, 2*self.N, f'{pos}')
|
|
30
|
+
self.model.Add(self.Num[pos] == self.N + self.idx_of[pos]).OnlyEnforceIf(self.B[pos])
|
|
31
|
+
self.model.Add(self.Num[pos] == int(get_char(self.board, pos))).OnlyEnforceIf(self.W[pos])
|
|
32
|
+
|
|
33
|
+
def add_all_constraints(self):
|
|
34
|
+
for pos in get_all_pos(self.V, self.H): # no two black squares are adjacent
|
|
35
|
+
for neighbor in get_neighbors4(pos, self.V, self.H):
|
|
36
|
+
self.model.Add(self.B[pos] + self.B[neighbor] <= 1)
|
|
37
|
+
for row in range(self.V): # no number appears twice in any row (numbers are ignored if black)
|
|
38
|
+
self.model.AddAllDifferent([self.Num[pos] for pos in get_row_pos(row, self.H)])
|
|
39
|
+
for col in range(self.H): # no number appears twice in any column (numbers are ignored if black)
|
|
40
|
+
self.model.AddAllDifferent([self.Num[pos] for pos in get_col_pos(col, self.V)])
|
|
41
|
+
force_connected_component(self.model, self.W) # all white squares must be a single connected component
|
|
42
|
+
|
|
43
|
+
def solve_and_print(self, verbose: bool = True):
|
|
44
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
45
|
+
return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.B.items()})
|
|
46
|
+
def callback(single_res: SingleSolution):
|
|
47
|
+
print("Solution found")
|
|
48
|
+
print(combined_function(self.V, self.H,
|
|
49
|
+
is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)],
|
|
50
|
+
center_char=lambda r, c: self.board[r, c],
|
|
51
|
+
text_on_shaded_cells=False
|
|
52
|
+
))
|
|
53
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This file is a simple helper that parses the images from https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/inertia.html and converts them to a json file.
|
|
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
|
+
|
|
7
|
+
import itertools
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import numpy as np
|
|
10
|
+
cv = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def extract_lines(bw):
|
|
14
|
+
horizontal = np.copy(bw)
|
|
15
|
+
vertical = np.copy(bw)
|
|
16
|
+
|
|
17
|
+
cols = horizontal.shape[1]
|
|
18
|
+
horizontal_size = max(5, cols // 20)
|
|
19
|
+
h_kernel = cv.getStructuringElement(cv.MORPH_RECT, (horizontal_size, 1))
|
|
20
|
+
horizontal = cv.erode(horizontal, h_kernel)
|
|
21
|
+
horizontal = cv.dilate(horizontal, h_kernel)
|
|
22
|
+
h_means = np.mean(horizontal, axis=1)
|
|
23
|
+
h_idx = np.where(h_means > np.percentile(h_means, 70))[0]
|
|
24
|
+
|
|
25
|
+
rows = vertical.shape[0]
|
|
26
|
+
verticalsize = max(5, rows // 20)
|
|
27
|
+
v_kernel = cv.getStructuringElement(cv.MORPH_RECT, (1, verticalsize))
|
|
28
|
+
vertical = cv.erode(vertical, v_kernel)
|
|
29
|
+
vertical = cv.dilate(vertical, v_kernel)
|
|
30
|
+
v_means = np.mean(vertical, axis=0)
|
|
31
|
+
v_idx = np.where(v_means > np.percentile(v_means, 70))[0]
|
|
32
|
+
return h_idx, v_idx
|
|
33
|
+
|
|
34
|
+
def mean_consecutives(arr):
|
|
35
|
+
if len(arr) == 0:
|
|
36
|
+
return arr
|
|
37
|
+
sums, counts = [arr[0]], [1]
|
|
38
|
+
for k in arr[1:]:
|
|
39
|
+
if k == sums[-1] + counts[-1]:
|
|
40
|
+
sums[-1] += k
|
|
41
|
+
counts[-1] += 1
|
|
42
|
+
else:
|
|
43
|
+
sums.append(k)
|
|
44
|
+
counts.append(1)
|
|
45
|
+
return np.array(sums)//np.array(counts)
|
|
46
|
+
|
|
47
|
+
def main(img_path):
|
|
48
|
+
global cv
|
|
49
|
+
import cv2 as cv_module
|
|
50
|
+
cv = cv_module
|
|
51
|
+
image_path = Path(img_path)
|
|
52
|
+
output_path = image_path.parent / (image_path.stem + '.json')
|
|
53
|
+
src = cv.imread(img_path, cv.IMREAD_COLOR)
|
|
54
|
+
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
|
|
55
|
+
inv = cv.bitwise_not(gray)
|
|
56
|
+
bw = cv.adaptiveThreshold(inv, 255, cv.ADAPTIVE_THRESH_MEAN_C, cv.THRESH_BINARY, 15, -2)
|
|
57
|
+
h_idx, v_idx = extract_lines(bw)
|
|
58
|
+
h_idx = mean_consecutives(h_idx)
|
|
59
|
+
v_idx = mean_consecutives(v_idx)
|
|
60
|
+
|
|
61
|
+
# Estimate grid cell and circle radii
|
|
62
|
+
cell = int(np.median(np.diff(h_idx))) if len(h_idx) > 3 else 40
|
|
63
|
+
r_min = max(6, int(cell*0.18))
|
|
64
|
+
r_max = int(cell*0.52)
|
|
65
|
+
|
|
66
|
+
# Global Hough detection with parameter sweep
|
|
67
|
+
blur = cv.medianBlur(gray, 5)
|
|
68
|
+
detected = [] # x, y, r
|
|
69
|
+
|
|
70
|
+
for dp, p2 in itertools.product([1.2, 1.0], [20, 18, 16, 14, 12]):
|
|
71
|
+
circles = cv.HoughCircles(
|
|
72
|
+
blur, cv.HOUGH_GRADIENT, dp=dp, minDist=max(12, int(cell*0.75)),
|
|
73
|
+
param1=120, param2=p2, minRadius=r_min, maxRadius=r_max
|
|
74
|
+
)
|
|
75
|
+
if circles is not None:
|
|
76
|
+
for (x, y, r) in np.round(circles[0, :]).astype(int):
|
|
77
|
+
detected.append((x, y, r))
|
|
78
|
+
|
|
79
|
+
# Non-maximum suppression to remove duplicates
|
|
80
|
+
def nms(circles, dist_thr=10):
|
|
81
|
+
kept = []
|
|
82
|
+
for x,y,r in sorted(circles, key=lambda c: -c[2]):
|
|
83
|
+
if all((x-kx)**2+(y-ky)**2 > dist_thr**2 for kx,ky,kr in kept):
|
|
84
|
+
kept.append((x,y,r))
|
|
85
|
+
return kept
|
|
86
|
+
|
|
87
|
+
detected = nms(detected, dist_thr=max(10,int(cell*0.4)))
|
|
88
|
+
|
|
89
|
+
# Map circle centers to nearest intersection
|
|
90
|
+
H, W = len(h_idx), len(v_idx)
|
|
91
|
+
presence = np.zeros((H, W), dtype=int)
|
|
92
|
+
|
|
93
|
+
# Build KD-like search by grid proximity
|
|
94
|
+
tol = int(cell*0.5) # max distance from an intersection to accept a circle
|
|
95
|
+
for (cx, cy, _) in detected:
|
|
96
|
+
# find nearest indices
|
|
97
|
+
j = int(np.argmin(np.abs(h_idx - cy)))
|
|
98
|
+
i = int(np.argmin(np.abs(v_idx - cx)))
|
|
99
|
+
if abs(h_idx[j]-cy) <= tol and abs(v_idx[i]-cx) <= tol:
|
|
100
|
+
presence[j, i] = 1
|
|
101
|
+
|
|
102
|
+
with open(output_path, 'w') as f:
|
|
103
|
+
f.write('[\n')
|
|
104
|
+
for i, row in enumerate(presence):
|
|
105
|
+
f.write(' ' + str(row.tolist()).replace("'", '"'))
|
|
106
|
+
if i != len(presence) - 1:
|
|
107
|
+
f.write(',')
|
|
108
|
+
f.write('\n')
|
|
109
|
+
f.write(']')
|
|
110
|
+
print('output json: ', output_path)
|
|
111
|
+
print('output json: ', output_path)
|
|
112
|
+
print('output json: ', output_path)
|
|
113
|
+
|
|
114
|
+
overlay = src.copy()
|
|
115
|
+
for (cx, cy, r) in detected:
|
|
116
|
+
cv.circle(overlay, (cx, cy), r, (255,0,0), 2)
|
|
117
|
+
for j, y in enumerate(h_idx):
|
|
118
|
+
for i, x in enumerate(v_idx):
|
|
119
|
+
color = (0,0,255) if presence[j,i]==1 else (0,255,0)
|
|
120
|
+
cv.circle(overlay, (int(x), int(y)), 4, color, 2)
|
|
121
|
+
show_wait_destroy("overlay", overlay)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def show_wait_destroy(winname, img):
|
|
126
|
+
cv.imshow(winname, img)
|
|
127
|
+
cv.moveWindow(winname, 500, 0)
|
|
128
|
+
cv.waitKey(0)
|
|
129
|
+
cv.destroyWindow(winname)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == '__main__':
|
|
133
|
+
# to run this script and visualize the output, in the root run:
|
|
134
|
+
# python .\src\puzzle_solver\puzzles\slant\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
|
|
135
|
+
main(Path(__file__).parent / 'input_output' / '23131379850022376.png')
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from typing import Literal, Optional, Union
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from ortools.sat.python import cp_model
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, get_pos
|
|
8
|
+
from puzzle_solver.core.utils_ortools import force_no_loops, generic_solve_all, SingleSolution
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Node:
|
|
14
|
+
"""The grid is represented as a graph of cells connected to corners."""
|
|
15
|
+
node_type: Union[Literal["Cell"], Literal["Corner"]]
|
|
16
|
+
pos: Pos
|
|
17
|
+
slant: Union[Literal["/"], Literal["\\"], None]
|
|
18
|
+
|
|
19
|
+
def get_neighbors(self, board_nodes: dict[tuple[str, Pos, Optional[str]], "Node"]) -> list["Node"]:
|
|
20
|
+
if self.node_type == "Cell" and self.slant == "/":
|
|
21
|
+
n1 = board_nodes[("Corner", get_pos(self.pos.x+1, self.pos.y), None)]
|
|
22
|
+
n2 = board_nodes[("Corner", get_pos(self.pos.x, self.pos.y+1), None)]
|
|
23
|
+
return [n1, n2]
|
|
24
|
+
elif self.node_type == "Cell" and self.slant == "\\":
|
|
25
|
+
n1 = board_nodes[("Corner", get_pos(self.pos.x, self.pos.y), None)]
|
|
26
|
+
n2 = board_nodes[("Corner", get_pos(self.pos.x+1, self.pos.y+1), None)]
|
|
27
|
+
return [n1, n2]
|
|
28
|
+
elif self.node_type == "Corner":
|
|
29
|
+
# 4 cells, 2 cells per slant
|
|
30
|
+
n1 = ("Cell", get_pos(self.pos.x-1, self.pos.y-1), "\\")
|
|
31
|
+
n2 = ("Cell", get_pos(self.pos.x, self.pos.y-1), "/")
|
|
32
|
+
n3 = ("Cell", get_pos(self.pos.x-1, self.pos.y), "/")
|
|
33
|
+
n4 = ("Cell", get_pos(self.pos.x, self.pos.y), "\\")
|
|
34
|
+
return {board_nodes[n] for n in [n1, n2, n3, n4] if n in board_nodes}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Board:
|
|
38
|
+
def __init__(self, numbers: Union[list[tuple[Pos, int]], np.array], V: int = None, H: int = None):
|
|
39
|
+
if isinstance(numbers, np.ndarray):
|
|
40
|
+
V, H = numbers.shape
|
|
41
|
+
V = V - 1
|
|
42
|
+
H = H - 1
|
|
43
|
+
numbers = [(get_pos(x=pos[1], y=pos[0]), int(d)) for pos, d in np.ndenumerate(numbers) if str(d).isdecimal()]
|
|
44
|
+
numbers = [(p, n) for p, n in numbers if n >= 0]
|
|
45
|
+
else:
|
|
46
|
+
assert V is not None and H is not None, 'V and H must be provided if numbers is not a numpy array'
|
|
47
|
+
assert V >= 1 and H >= 1, 'V and H must be at least 1'
|
|
48
|
+
assert all(isinstance(number, int) and number >= 0 for (pos, number) in numbers), 'numbers must be a list of integers'
|
|
49
|
+
self.V = V
|
|
50
|
+
self.H = H
|
|
51
|
+
self.numbers = numbers
|
|
52
|
+
self.pos_to_number: dict[Pos, int] = {pos: number for pos, number in numbers}
|
|
53
|
+
|
|
54
|
+
self.model = cp_model.CpModel()
|
|
55
|
+
self.model_vars: dict[tuple[Pos, str], cp_model.IntVar] = {}
|
|
56
|
+
self.nodes: dict[Node, cp_model.IntVar] = {}
|
|
57
|
+
self.neighbor_dict: dict[Node, set[Node]] = {}
|
|
58
|
+
|
|
59
|
+
self.create_vars()
|
|
60
|
+
self.add_all_constraints()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_vars(self):
|
|
64
|
+
for pos in get_all_pos(self.V, self.H):
|
|
65
|
+
self.model_vars[(pos, '/')] = self.model.NewBoolVar(f'{pos}:/')
|
|
66
|
+
self.model_vars[(pos, '\\')] = self.model.NewBoolVar(f'{pos}:\\')
|
|
67
|
+
self.model.AddExactlyOne([self.model_vars[(pos, '/')], self.model_vars[(pos, '\\')]])
|
|
68
|
+
for (pos, slant), v in self.model_vars.items():
|
|
69
|
+
self.nodes[Node(node_type="Cell", pos=pos, slant=slant)] = v
|
|
70
|
+
for pos in get_all_pos(self.V + 1, self.H + 1):
|
|
71
|
+
self.nodes[Node(node_type="Corner", pos=pos, slant=None)] = self.model.NewConstant(1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def add_all_constraints(self):
|
|
75
|
+
for pos, number in self.pos_to_number.items():
|
|
76
|
+
# pos is a position on the intersection of 4 cells
|
|
77
|
+
# when pos is (xi, yi) then it gets a +1 contribution for each:
|
|
78
|
+
# - cell (xi-1, yi-1) is a "\\"
|
|
79
|
+
# - cell (xi, yi) is a "\\"
|
|
80
|
+
# - cell (xi, yi-1) is a "/"
|
|
81
|
+
# - cell (xi-1, yi) is a "/"
|
|
82
|
+
xi, yi = pos.x, pos.y
|
|
83
|
+
tl_pos = get_pos(xi-1, yi-1)
|
|
84
|
+
br_pos = get_pos(xi, yi)
|
|
85
|
+
tr_pos = get_pos(xi, yi-1)
|
|
86
|
+
bl_pos = get_pos(xi-1, yi)
|
|
87
|
+
tl_var = self.model_vars[(tl_pos, '\\')] if in_bounds(tl_pos, self.V, self.H) else 0
|
|
88
|
+
br_var = self.model_vars[(br_pos, '\\')] if in_bounds(br_pos, self.V, self.H) else 0
|
|
89
|
+
tr_var = self.model_vars[(tr_pos, '/')] if in_bounds(tr_pos, self.V, self.H) else 0
|
|
90
|
+
bl_var = self.model_vars[(bl_pos, '/')] if in_bounds(bl_pos, self.V, self.H) else 0
|
|
91
|
+
self.model.Add(sum([tl_var, tr_var, bl_var, br_var]) == number)
|
|
92
|
+
board_nodes = {(node.node_type, node.pos, node.slant): node for node in self.nodes.keys()}
|
|
93
|
+
self.neighbor_dict = {node: node.get_neighbors(board_nodes) for node in self.nodes.keys()}
|
|
94
|
+
no_loops_vars = force_no_loops(self.model, self.nodes, is_neighbor=lambda n1, n2: n1 in self.neighbor_dict[n2])
|
|
95
|
+
self.no_loops_vars = no_loops_vars
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def solve_and_print(self, verbose: bool = True):
|
|
99
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
100
|
+
assignment: dict[Pos, int] = {}
|
|
101
|
+
# graph = {node: solver.Value(var) for node, var in board.nodes.items()}
|
|
102
|
+
for (pos, s), var in board.model_vars.items():
|
|
103
|
+
if solver.Value(var) == 1:
|
|
104
|
+
assignment[pos] = s
|
|
105
|
+
for p in get_all_pos(self.V, self.H):
|
|
106
|
+
assert p in assignment, f'position {p} is not assigned a number'
|
|
107
|
+
return SingleSolution(assignment=assignment)
|
|
108
|
+
def callback(single_res: SingleSolution):
|
|
109
|
+
print("Solution found")
|
|
110
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: single_res.assignment[get_pos(x=c, y=r)]))
|
|
111
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,130 @@
|
|
|
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, 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
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CellBorder = tuple[Pos, Direction]
|
|
11
|
+
Corner = Pos
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Board:
|
|
15
|
+
def __init__(self, board: np.array):
|
|
16
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
17
|
+
assert all(c.item() == ' ' or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only spaces or digits'
|
|
18
|
+
self.V = board.shape[0]
|
|
19
|
+
self.H = board.shape[1]
|
|
20
|
+
self.board = board
|
|
21
|
+
self.cell_borders_to_corners: dict[CellBorder, set[Corner]] = defaultdict(set) # for every cell border, a set of all corners it is connected to
|
|
22
|
+
self.corners_to_cell_borders: dict[Corner, set[CellBorder]] = defaultdict(set) # opposite direction
|
|
23
|
+
|
|
24
|
+
# 2N^2 + 2N edges
|
|
25
|
+
# 4*edges (fully connected component)
|
|
26
|
+
# model variables = edges (on/off) + 4*edges (fully connected component)
|
|
27
|
+
# = 9N^2 + 9N
|
|
28
|
+
self.model = cp_model.CpModel()
|
|
29
|
+
self.model_vars: dict[CellBorder, cp_model.IntVar] = {} # one entry for every unique variable in the model
|
|
30
|
+
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)
|
|
31
|
+
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)
|
|
32
|
+
|
|
33
|
+
self.create_vars()
|
|
34
|
+
self.add_all_constraints()
|
|
35
|
+
|
|
36
|
+
def create_vars(self):
|
|
37
|
+
for pos in get_all_pos(self.V, self.H):
|
|
38
|
+
for direction in [Direction.RIGHT, Direction.DOWN]:
|
|
39
|
+
self.add_var(pos, direction)
|
|
40
|
+
for pos in get_row_pos(0, self.H):
|
|
41
|
+
self.add_var(pos, Direction.UP)
|
|
42
|
+
for pos in get_col_pos(0, self.V):
|
|
43
|
+
self.add_var(pos, Direction.LEFT)
|
|
44
|
+
|
|
45
|
+
def add_var(self, pos: Pos, direction: Direction):
|
|
46
|
+
cell_border = (pos, direction)
|
|
47
|
+
v = self.model.NewBoolVar(f'main:{cell_border}')
|
|
48
|
+
self.model_vars[cell_border] = v
|
|
49
|
+
self.add_cell_border_var(cell_border, v)
|
|
50
|
+
self.add_corner_vars(cell_border, v)
|
|
51
|
+
|
|
52
|
+
def add_cell_border_var(self, cell_border: CellBorder, var: cp_model.IntVar):
|
|
53
|
+
"""An edge belongs to two cells unless its on the border in which case it only belongs to one."""
|
|
54
|
+
pos, direction = cell_border
|
|
55
|
+
self.cell_borders[cell_border] = var
|
|
56
|
+
next_pos = get_next_pos(pos, direction)
|
|
57
|
+
if in_bounds(next_pos, self.V, self.H):
|
|
58
|
+
self.cell_borders[(next_pos, get_opposite_direction(direction))] = var
|
|
59
|
+
|
|
60
|
+
def add_corner_vars(self, cell_border: CellBorder, var: cp_model.IntVar):
|
|
61
|
+
"""
|
|
62
|
+
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 won't make sense)
|
|
63
|
+
Thus corner index is +1 of board coordinates.
|
|
64
|
+
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.
|
|
65
|
+
"""
|
|
66
|
+
pos, direction = cell_border
|
|
67
|
+
if direction == Direction.LEFT: # it touches me and (xi,yi+1)
|
|
68
|
+
corner1 = pos
|
|
69
|
+
corner2 = get_next_pos(pos, Direction.DOWN)
|
|
70
|
+
elif direction == Direction.UP: # it touches me and (xi+1,yi)
|
|
71
|
+
corner1 = pos
|
|
72
|
+
corner2 = get_next_pos(pos, Direction.RIGHT)
|
|
73
|
+
elif direction == Direction.RIGHT: # it touches (xi+1,yi) and (xi+1,yi+1)
|
|
74
|
+
corner1 = get_next_pos(pos, Direction.RIGHT)
|
|
75
|
+
corner2 = get_next_pos(corner1, Direction.DOWN)
|
|
76
|
+
elif direction == Direction.DOWN: # it touches (xi,yi+1) and (xi+1,yi+1)
|
|
77
|
+
corner1 = get_next_pos(pos, Direction.DOWN)
|
|
78
|
+
corner2 = get_next_pos(corner1, Direction.RIGHT)
|
|
79
|
+
else:
|
|
80
|
+
raise ValueError(f'Invalid direction: {direction}')
|
|
81
|
+
self.corner_vars[corner1].add(var)
|
|
82
|
+
self.corner_vars[corner2].add(var)
|
|
83
|
+
self.cell_borders_to_corners[cell_border].add(corner1)
|
|
84
|
+
self.cell_borders_to_corners[cell_border].add(corner2)
|
|
85
|
+
self.corners_to_cell_borders[corner1].add(cell_border)
|
|
86
|
+
self.corners_to_cell_borders[corner2].add(cell_border)
|
|
87
|
+
|
|
88
|
+
def add_all_constraints(self):
|
|
89
|
+
for pos in get_all_pos(self.V, self.H): # enforce cells with numbers
|
|
90
|
+
variables = [self.cell_borders[(pos, direction)] for direction in Direction if (pos, direction) in self.cell_borders]
|
|
91
|
+
val = get_char(self.board, pos)
|
|
92
|
+
if not val.isdecimal():
|
|
93
|
+
continue
|
|
94
|
+
self.model.Add(sum(variables) == int(val))
|
|
95
|
+
|
|
96
|
+
corner_sum_domain = cp_model.Domain.FromValues([0, 2]) # sum of edges touching a corner is 0 or 2
|
|
97
|
+
for corner in self.corner_vars: # a corder always has 0 or 2 active edges
|
|
98
|
+
self.model.AddLinearExpressionInDomain(sum(self.corner_vars[corner]), corner_sum_domain)
|
|
99
|
+
|
|
100
|
+
# single connected component
|
|
101
|
+
def is_neighbor(cb1: CellBorder, cb2: CellBorder) -> bool:
|
|
102
|
+
cb1_corners = self.cell_borders_to_corners[cb1]
|
|
103
|
+
cb2_corners = self.cell_borders_to_corners[cb2]
|
|
104
|
+
return len(cb1_corners & cb2_corners) > 0
|
|
105
|
+
force_connected_component(self.model, self.model_vars, is_neighbor=is_neighbor)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def solve_and_print(self, verbose: bool = True):
|
|
111
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
112
|
+
assignment: dict[Pos, str] = {}
|
|
113
|
+
for (pos, direction), var in board.model_vars.items():
|
|
114
|
+
if solver.value(var) == 1:
|
|
115
|
+
if pos not in assignment:
|
|
116
|
+
assignment[pos] = ''
|
|
117
|
+
assignment[pos] += direction.name[0]
|
|
118
|
+
return SingleSolution(assignment=assignment)
|
|
119
|
+
def callback(single_res: SingleSolution):
|
|
120
|
+
print("Solution found")
|
|
121
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
122
|
+
for pos in get_all_pos(self.V, self.H):
|
|
123
|
+
if pos not in single_res.assignment:
|
|
124
|
+
continue
|
|
125
|
+
c = ''.join(sorted(single_res.assignment[pos]))
|
|
126
|
+
set_char(res, pos, c)
|
|
127
|
+
# replace " " with "·"
|
|
128
|
+
board = np.where(self.board == ' ', '·', self.board)
|
|
129
|
+
print(combined_function(self.V, self.H, cell_flags=lambda r, c: res[r, c], center_char=lambda r, c: board[r, c]))
|
|
130
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=999)
|