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,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)
|