multi-puzzle-solver 0.9.30__py3-none-any.whl → 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of multi-puzzle-solver might be problematic. Click here for more details.
- {multi_puzzle_solver-0.9.30.dist-info → multi_puzzle_solver-1.0.2.dist-info}/METADATA +331 -76
- multi_puzzle_solver-1.0.2.dist-info/RECORD +69 -0
- puzzle_solver/__init__.py +58 -1
- puzzle_solver/core/utils_ortools.py +8 -6
- puzzle_solver/core/utils_visualizer.py +23 -41
- puzzle_solver/puzzles/binairo/binairo.py +4 -4
- puzzle_solver/puzzles/black_box/black_box.py +5 -11
- puzzle_solver/puzzles/bridges/bridges.py +1 -1
- puzzle_solver/puzzles/chess_range/chess_range.py +3 -3
- puzzle_solver/puzzles/chess_range/chess_solo.py +1 -1
- puzzle_solver/puzzles/filling/filling.py +3 -3
- puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
- puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +198 -0
- puzzle_solver/puzzles/galaxies/galaxies.py +1 -1
- puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +3 -3
- puzzle_solver/puzzles/guess/guess.py +1 -1
- puzzle_solver/puzzles/heyawake/heyawake.py +3 -3
- puzzle_solver/puzzles/inertia/inertia.py +1 -1
- puzzle_solver/puzzles/inertia/parse_map/parse_map.py +13 -10
- puzzle_solver/puzzles/inertia/tsp.py +5 -7
- puzzle_solver/puzzles/kakuro/kakuro.py +1 -1
- puzzle_solver/puzzles/keen/keen.py +2 -2
- puzzle_solver/puzzles/minesweeper/minesweeper.py +2 -3
- puzzle_solver/puzzles/nonograms/nonograms.py +3 -3
- puzzle_solver/puzzles/norinori/norinori.py +2 -2
- puzzle_solver/puzzles/nurikabe/nurikabe.py +2 -2
- puzzle_solver/puzzles/range/range.py +1 -1
- puzzle_solver/puzzles/rectangles/rectangles.py +2 -6
- puzzle_solver/puzzles/shingoki/shingoki.py +1 -1
- puzzle_solver/puzzles/signpost/signpost.py +2 -2
- puzzle_solver/puzzles/slant/parse_map/parse_map.py +7 -5
- puzzle_solver/puzzles/slitherlink/slitherlink.py +1 -1
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +6 -5
- puzzle_solver/puzzles/stitches/stitches.py +1 -1
- puzzle_solver/puzzles/sudoku/sudoku.py +91 -20
- puzzle_solver/puzzles/tents/tents.py +2 -2
- puzzle_solver/puzzles/thermometers/thermometers.py +1 -1
- puzzle_solver/puzzles/towers/towers.py +1 -1
- puzzle_solver/puzzles/undead/undead.py +1 -1
- puzzle_solver/puzzles/unruly/unruly.py +1 -1
- puzzle_solver/puzzles/yin_yang/yin_yang.py +1 -1
- puzzle_solver/utils/visualizer.py +1 -1
- multi_puzzle_solver-0.9.30.dist-info/RECORD +0 -67
- {multi_puzzle_solver-0.9.30.dist-info → multi_puzzle_solver-1.0.2.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-0.9.30.dist-info → multi_puzzle_solver-1.0.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This file is a simple helper that parses the images from https://www.chiark.greenend.org.uk 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 // 20
|
|
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 // 20
|
|
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 main(image):
|
|
76
|
+
global Image
|
|
77
|
+
global cv
|
|
78
|
+
import matplotlib.pyplot as plt
|
|
79
|
+
from PIL import Image as Image_module
|
|
80
|
+
import cv2 as cv_module
|
|
81
|
+
Image = Image_module
|
|
82
|
+
cv = cv_module
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
image_path = Path(image)
|
|
86
|
+
output_path = image_path.parent / (image_path.stem + '.json')
|
|
87
|
+
src = cv.imread(image, cv.IMREAD_COLOR)
|
|
88
|
+
assert src is not None, f'Error opening image: {image}'
|
|
89
|
+
if len(src.shape) != 2:
|
|
90
|
+
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
|
|
91
|
+
else:
|
|
92
|
+
gray = src
|
|
93
|
+
# now the image is in grayscale
|
|
94
|
+
|
|
95
|
+
# Apply adaptiveThreshold at the bitwise_not of gray, notice the ~ symbol
|
|
96
|
+
gray = cv.bitwise_not(gray)
|
|
97
|
+
bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
|
|
98
|
+
cv.THRESH_BINARY, 15, -2)
|
|
99
|
+
# show_wait_destroy("binary", bw)
|
|
100
|
+
|
|
101
|
+
# show_wait_destroy("src", src)
|
|
102
|
+
horizontal_idx, vertical_idx = extract_lines(bw)
|
|
103
|
+
horizontal_idx = mean_consecutives(horizontal_idx)
|
|
104
|
+
vertical_idx = mean_consecutives(vertical_idx)
|
|
105
|
+
median_vertical_dist = np.median(np.diff(vertical_idx))
|
|
106
|
+
median_horizontal_dist = np.median(np.diff(horizontal_idx))
|
|
107
|
+
print(f"median_vertical_dist: {median_vertical_dist}, median_horizontal_dist: {median_horizontal_dist}")
|
|
108
|
+
height = len(horizontal_idx)
|
|
109
|
+
width = len(vertical_idx)
|
|
110
|
+
print(f"height: {height}, width: {width}")
|
|
111
|
+
print(f"horizontal_idx: {horizontal_idx}")
|
|
112
|
+
print(f"vertical_idx: {vertical_idx}")
|
|
113
|
+
output_rgb = {}
|
|
114
|
+
j_idx = 0
|
|
115
|
+
for j in range(height - 1):
|
|
116
|
+
i_idx = 0
|
|
117
|
+
for i in range(width - 1):
|
|
118
|
+
hidx1, hidx2 = horizontal_idx[j], horizontal_idx[j+1]
|
|
119
|
+
vidx1, vidx2 = vertical_idx[i], vertical_idx[i+1]
|
|
120
|
+
hidx1 = max(0, hidx1 - 2)
|
|
121
|
+
hidx2 = min(src.shape[0], hidx2 + 4)
|
|
122
|
+
vidx1 = max(0, vidx1 - 2)
|
|
123
|
+
vidx2 = min(src.shape[1], vidx2 + 4)
|
|
124
|
+
if (hidx2 - hidx1) < median_horizontal_dist * 0.5 or (vidx2 - vidx1) < median_vertical_dist * 0.5:
|
|
125
|
+
continue
|
|
126
|
+
cell = src[hidx1:hidx2, vidx1:vidx2]
|
|
127
|
+
mid_x = cell.shape[1] // 2
|
|
128
|
+
mid_y = cell.shape[0] // 2
|
|
129
|
+
print(f"mid_x: {mid_x}, mid_y: {mid_y}")
|
|
130
|
+
cell_50_percent = cell[int(mid_y*0.5):int(mid_y*1.5), int(mid_x*0.5):int(mid_x*1.5)]
|
|
131
|
+
# show_wait_destroy(f"cell_{i_idx}_{j_idx}", cell_50_percent)
|
|
132
|
+
output_rgb[j_idx, i_idx] = cell_50_percent.mean(axis=(0, 1))
|
|
133
|
+
print(f"output_rgb[{j_idx}, {i_idx}]: {output_rgb[j_idx, i_idx]}")
|
|
134
|
+
i_idx += 1
|
|
135
|
+
j_idx += 1
|
|
136
|
+
|
|
137
|
+
colors_to_cluster = cluster_colors(output_rgb)
|
|
138
|
+
width = max(pos[1] for pos in output_rgb.keys()) + 1
|
|
139
|
+
height = max(pos[0] for pos in output_rgb.keys()) + 1
|
|
140
|
+
out = np.zeros((height, width), dtype=object)
|
|
141
|
+
print(colors_to_cluster)
|
|
142
|
+
for pos, cluster_id in colors_to_cluster.items():
|
|
143
|
+
out[pos[0], pos[1]] = cluster_id
|
|
144
|
+
print('Shape of out:', out.shape)
|
|
145
|
+
|
|
146
|
+
with open(output_path, 'w') as f:
|
|
147
|
+
f.write('[\n')
|
|
148
|
+
for i, row in enumerate(out):
|
|
149
|
+
f.write(' ' + str(row.tolist()).replace("'", '"'))
|
|
150
|
+
if i != len(out) - 1:
|
|
151
|
+
f.write(',')
|
|
152
|
+
f.write('\n')
|
|
153
|
+
f.write(']')
|
|
154
|
+
print('output json: ', output_path)
|
|
155
|
+
|
|
156
|
+
def euclidean_distance(a: tuple[int, int, int], b: tuple[int, int, int]) -> int:
|
|
157
|
+
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5
|
|
158
|
+
|
|
159
|
+
KNOWN_COLORS = {
|
|
160
|
+
(0, 0, 255): 'Red',
|
|
161
|
+
(0, 255, 0): 'Green',
|
|
162
|
+
(255, 77, 51): 'Blue',
|
|
163
|
+
(0, 255, 255): 'Yellow',
|
|
164
|
+
(255, 153, 255): 'Pink',
|
|
165
|
+
(0, 128, 255): 'Orange',
|
|
166
|
+
(255, 204, 102): 'Cyan',
|
|
167
|
+
(179, 255, 179): 'Washed Green',
|
|
168
|
+
(77, 77, 128): 'Brown',
|
|
169
|
+
(179, 0, 128): 'Purple',
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
def cluster_colors(rgb: dict[tuple[int, int], tuple[int, int, int]]) -> dict[tuple[int, int, int], int]:
|
|
173
|
+
MIN_DIST = 10 # if distance between two colors is less than this, then they are the same color
|
|
174
|
+
colors_to_cluster = KNOWN_COLORS.copy()
|
|
175
|
+
for pos, color in rgb.items():
|
|
176
|
+
color = tuple(color)
|
|
177
|
+
if color in colors_to_cluster:
|
|
178
|
+
continue
|
|
179
|
+
for existing_color, existing_cluster_id in colors_to_cluster.items():
|
|
180
|
+
if euclidean_distance(color, existing_color) < MIN_DIST:
|
|
181
|
+
colors_to_cluster[color] = existing_cluster_id
|
|
182
|
+
break
|
|
183
|
+
else:
|
|
184
|
+
new_name = str(', '.join(str(int(c)) for c in color))
|
|
185
|
+
print('WARNING: new color found:', new_name, 'at pos:', pos)
|
|
186
|
+
colors_to_cluster[color] = new_name
|
|
187
|
+
pos_to_cluster = {pos: colors_to_cluster[tuple(color)] for pos, color in rgb.items()}
|
|
188
|
+
return pos_to_cluster
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == '__main__':
|
|
192
|
+
# to run this script and visualize the output, in the root run:
|
|
193
|
+
# python .\src\puzzle_solver\puzzles\flood_it\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
|
|
194
|
+
# main(Path(__file__).parent / 'input_output' / 'flood.html#12x12c10m5%23637467359431429.png')
|
|
195
|
+
# main(Path(__file__).parent / 'input_output' / 'flood.html#12x12c6m5%23132018455881870.png')
|
|
196
|
+
# main(Path(__file__).parent / 'input_output' / 'flood.html#12x12c6m0%23668276603006993.png')
|
|
197
|
+
# main(Path(__file__).parent / 'input_output' / 'flood.html#20x20c8m0%23991967486182787.png')flood.html#20x20c4m0%23690338575695152
|
|
198
|
+
main(Path(__file__).parent / 'input_output' / 'flood.html#20x20c4m0%23690338575695152.png')
|
|
@@ -85,7 +85,7 @@ class Board:
|
|
|
85
85
|
self.model.AddExactlyOne(pos_vars)
|
|
86
86
|
for galaxy_idx, v in self.pos_to_galaxy[pos].items():
|
|
87
87
|
galaxy_vars.setdefault(galaxy_idx, {})[pos] = v
|
|
88
|
-
for
|
|
88
|
+
for pos_vars in galaxy_vars.values():
|
|
89
89
|
force_connected_component(self.model, pos_vars)
|
|
90
90
|
|
|
91
91
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
This file is a simple helper that parses the images from https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/galaxies.html and converts them to a json file.
|
|
2
|
+
This file is a simple helper that parses the images from https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/galaxies.html and converts them to a json file.
|
|
3
3
|
Look at the ./input_output/ directory for examples of input images and output json files.
|
|
4
4
|
The output json is used in the test_solve.py file to test the solver.
|
|
5
5
|
"""
|
|
@@ -25,7 +25,7 @@ def extract_lines(bw):
|
|
|
25
25
|
# location where the horizontal lines are
|
|
26
26
|
horizontal_idx = np.where(horizontal_means > horizontal_cutoff)[0]
|
|
27
27
|
# print(f"horizontal_idx: {horizontal_idx}")
|
|
28
|
-
height = len(horizontal_idx)
|
|
28
|
+
# height = len(horizontal_idx)
|
|
29
29
|
# show_wait_destroy("horizontal", horizontal) # this has the horizontal lines
|
|
30
30
|
|
|
31
31
|
rows = vertical.shape[0]
|
|
@@ -38,7 +38,7 @@ def extract_lines(bw):
|
|
|
38
38
|
vertical_cutoff = np.percentile(vertical_means, 50)
|
|
39
39
|
vertical_idx = np.where(vertical_means > vertical_cutoff)[0]
|
|
40
40
|
# print(f"vertical_idx: {vertical_idx}")
|
|
41
|
-
width = len(vertical_idx)
|
|
41
|
+
# width = len(vertical_idx)
|
|
42
42
|
# print(f"height: {height}, width: {width}")
|
|
43
43
|
# print(f"vertical_means: {vertical_means}")
|
|
44
44
|
# show_wait_destroy("vertical", vertical) # this has the vertical lines
|
|
@@ -5,7 +5,7 @@ import numpy as np
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class Board:
|
|
8
|
-
def __init__(self, num_pegs: int = 4, all_colors:
|
|
8
|
+
def __init__(self, num_pegs: int = 4, all_colors: tuple[str] = ('R', 'Y', 'G', 'B', 'O', 'P'), show_warnings: bool = True, show_progress: bool = False):
|
|
9
9
|
assert num_pegs >= 1, 'num_pegs must be at least 1'
|
|
10
10
|
assert len(all_colors) == len(set(all_colors)), 'all_colors must contain only unique colors'
|
|
11
11
|
self.previous_guesses = []
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import numpy as np
|
|
2
2
|
from ortools.sat.python import cp_model
|
|
3
3
|
|
|
4
|
-
from puzzle_solver.core.utils import Pos, get_all_pos, get_neighbors4, get_pos,
|
|
4
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_neighbors4, get_pos, get_char
|
|
5
5
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
6
6
|
from puzzle_solver.core.utils_visualizer import render_shaded_grid
|
|
7
7
|
|
|
8
|
+
|
|
8
9
|
def return_3_consecutives(int_list: list[int]) -> list[tuple[int, int]]:
|
|
9
|
-
"""Given a list of integers (mostly with duplicates), return every consecutive sequence of 3 integer changes.
|
|
10
|
+
"""Given a list of integers (mostly with duplicates), return every consecutive sequence of 3 integer changes.
|
|
10
11
|
i.e. return a list of (begin_idx, end_idx) tuples where for each r=int_list[begin_idx:end_idx] we have r[0]!=r[1] and r[-2]!=r[-1] and len(r)>=3"""
|
|
11
12
|
out = []
|
|
12
13
|
change_indices = [i for i in range(len(int_list) - 1) if int_list[i] != int_list[i+1]]
|
|
@@ -18,7 +19,6 @@ def return_3_consecutives(int_list: list[int]) -> list[tuple[int, int]]:
|
|
|
18
19
|
continue
|
|
19
20
|
out.append((begin_idx, end_idx))
|
|
20
21
|
return out
|
|
21
|
-
|
|
22
22
|
|
|
23
23
|
class Board:
|
|
24
24
|
def __init__(self, board: np.array, region_to_clue: dict[str, int]):
|
|
@@ -118,4 +118,4 @@ def solve_optimal_walk(
|
|
|
118
118
|
seed: int = 0,
|
|
119
119
|
verbose: bool = False
|
|
120
120
|
) -> list[tuple[Pos, Pos]]:
|
|
121
|
-
return tsp.solve_optimal_walk(start_pos, edges, gems_to_edges, restarts=restarts, time_limit_ms=time_limit_ms, seed=seed, verbose=verbose)
|
|
121
|
+
return tsp.solve_optimal_walk(start_pos, edges, gems_to_edges, restarts=restarts, time_limit_ms=time_limit_ms, seed=seed, verbose=verbose)
|
|
@@ -1,25 +1,24 @@
|
|
|
1
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.
|
|
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
3
|
Look at the ./input_output/ directory for examples of input images and output json files.
|
|
4
4
|
The output json is used in the test_solve.py file to test the solver.
|
|
5
5
|
"""
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
import numpy as np
|
|
8
|
-
import numpy as np
|
|
9
8
|
cv = None
|
|
10
9
|
Image = None
|
|
11
10
|
|
|
12
11
|
def load_cell_templates(p: Path) -> dict[str, dict]:
|
|
13
|
-
img = Image.open(p)
|
|
12
|
+
# img = Image.open(p)
|
|
14
13
|
src = cv.imread(p, cv.IMREAD_COLOR)
|
|
15
|
-
rgb = np.asarray(img).astype(np.float32) / 255.0
|
|
14
|
+
# rgb = np.asarray(img).astype(np.float32) / 255.0
|
|
16
15
|
if len(src.shape) != 2:
|
|
17
16
|
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
|
|
18
17
|
else:
|
|
19
18
|
gray = src
|
|
20
19
|
gray = cv.bitwise_not(gray)
|
|
21
|
-
bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
|
|
22
|
-
|
|
20
|
+
# bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
|
|
21
|
+
# cv.THRESH_BINARY, 15, -2)
|
|
23
22
|
return {"gray": gray}
|
|
24
23
|
|
|
25
24
|
|
|
@@ -53,10 +52,14 @@ def get_distance_robust(cell: np.ndarray, template: np.ndarray, max_shift: int =
|
|
|
53
52
|
for dy in range(-max_shift, max_shift + 1):
|
|
54
53
|
for dx in range(-max_shift, max_shift + 1):
|
|
55
54
|
# compute overlapping slices for this shift
|
|
56
|
-
y0a = max(0, dy)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
y0a = max(0, dy)
|
|
56
|
+
y1a = H + min(0, dy)
|
|
57
|
+
x0a = max(0, dx)
|
|
58
|
+
x1a = W + min(0, dx)
|
|
59
|
+
y0b = max(0, -dy)
|
|
60
|
+
y1b = H + min(0, -dy)
|
|
61
|
+
x0b = max(0, -dx)
|
|
62
|
+
x1b = W + min(0, -dx)
|
|
60
63
|
|
|
61
64
|
if y1a <= y0a or x1a <= x0a: # no overlap
|
|
62
65
|
continue
|
|
@@ -197,7 +197,7 @@ def solve_optimal_walk(
|
|
|
197
197
|
changed = False
|
|
198
198
|
i = 0
|
|
199
199
|
while i + 3 < len(ns):
|
|
200
|
-
u, v, w
|
|
200
|
+
u, v, w = ns[i], ns[i+1], ns[i+2]
|
|
201
201
|
if w == u: # u->v, v->u
|
|
202
202
|
before_edges = walk_edges(ns[:i+1])
|
|
203
203
|
removed_edges = [(u, v), (v, u)]
|
|
@@ -235,7 +235,7 @@ def solve_optimal_walk(
|
|
|
235
235
|
for i in range(N_no_depot):
|
|
236
236
|
gi = state_group[i]
|
|
237
237
|
for j in range(N_no_depot):
|
|
238
|
-
if i == j:
|
|
238
|
+
if i == j:
|
|
239
239
|
continue
|
|
240
240
|
gj = state_group[j]
|
|
241
241
|
if gi != gj:
|
|
@@ -244,9 +244,9 @@ def solve_optimal_walk(
|
|
|
244
244
|
# ring + shift
|
|
245
245
|
INF = 10**12
|
|
246
246
|
succ_in_cluster: Dict[int, int] = {}
|
|
247
|
-
for
|
|
247
|
+
for order in cluster_orders.values():
|
|
248
248
|
k = len(order)
|
|
249
|
-
if k == 0:
|
|
249
|
+
if k == 0:
|
|
250
250
|
continue
|
|
251
251
|
pred = {}
|
|
252
252
|
for idx, v in enumerate(order):
|
|
@@ -327,7 +327,6 @@ def solve_optimal_walk(
|
|
|
327
327
|
|
|
328
328
|
best_nodes = None
|
|
329
329
|
best_cost = float('inf')
|
|
330
|
-
best_reps = None
|
|
331
330
|
|
|
332
331
|
# initial deterministic order as a baseline
|
|
333
332
|
def shuffled_cluster_orders():
|
|
@@ -339,7 +338,7 @@ def solve_optimal_walk(
|
|
|
339
338
|
return orders
|
|
340
339
|
|
|
341
340
|
attempts = max(1, restarts)
|
|
342
|
-
for
|
|
341
|
+
for _ in range(attempts):
|
|
343
342
|
cluster_orders = shuffled_cluster_orders()
|
|
344
343
|
for meta in meta_list:
|
|
345
344
|
# print('solve once')
|
|
@@ -382,7 +381,6 @@ def solve_optimal_walk(
|
|
|
382
381
|
if cost < best_cost:
|
|
383
382
|
best_cost = cost
|
|
384
383
|
best_nodes = nodes_seq
|
|
385
|
-
best_reps = reps
|
|
386
384
|
|
|
387
385
|
if best_nodes is None:
|
|
388
386
|
raise RuntimeError("No solution found.")
|
|
@@ -4,7 +4,7 @@ import numpy as np
|
|
|
4
4
|
from ortools.sat.python import cp_model
|
|
5
5
|
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
6
6
|
|
|
7
|
-
from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, get_pos, in_bounds,
|
|
7
|
+
from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, get_pos, in_bounds, get_char
|
|
8
8
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
9
|
from puzzle_solver.core.utils_visualizer import render_shaded_grid
|
|
10
10
|
|
|
@@ -75,9 +75,9 @@ class Board:
|
|
|
75
75
|
for col in range(self.N):
|
|
76
76
|
col_vars = [self.model_vars[pos] for pos in get_col_pos(col, self.N)]
|
|
77
77
|
self.model.AddAllDifferent(col_vars)
|
|
78
|
-
|
|
78
|
+
|
|
79
79
|
def constrain_block_results(self):
|
|
80
|
-
# The digits in each block can be combined to form the number stated in the clue, using the arithmetic operation given in the clue. That is:
|
|
80
|
+
# The digits in each block can be combined to form the number stated in the clue, using the arithmetic operation given in the clue. That is:
|
|
81
81
|
for block, (op, result) in self.block_results.items():
|
|
82
82
|
block_vars = [self.model_vars[p] for p in self.get_block_pos(block)]
|
|
83
83
|
add_opcode_constraint(self.model, block_vars, op, result)
|
|
@@ -103,7 +103,7 @@ def give_next_guess(board: np.array, mine_count: Optional[int] = None, verbose:
|
|
|
103
103
|
print(new_garuneed_mine_positions)
|
|
104
104
|
print('-'*10)
|
|
105
105
|
if len(wrong_flag_positions) > 0:
|
|
106
|
-
print(
|
|
106
|
+
print("WARNING | "*4 + "WARNING")
|
|
107
107
|
print(f"Found {len(wrong_flag_positions)} wrong flag positions")
|
|
108
108
|
print(wrong_flag_positions)
|
|
109
109
|
print('-'*10)
|
|
@@ -120,5 +120,4 @@ def print_board(board: np.array, safe_positions: set[Pos], new_garuneed_mine_pos
|
|
|
120
120
|
set_char(res, pos, 'M')
|
|
121
121
|
elif get_char(board, pos) == 'F' and pos not in wrong_flag_positions:
|
|
122
122
|
set_char(res, pos, 'F')
|
|
123
|
-
|
|
124
|
-
print(res)
|
|
123
|
+
print(res)
|
|
@@ -7,8 +7,8 @@ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
|
7
7
|
|
|
8
8
|
class Board:
|
|
9
9
|
def __init__(self, top: list[list[int]], side: list[list[int]]):
|
|
10
|
-
assert all(isinstance(i, int) for
|
|
11
|
-
assert all(isinstance(i, int) for
|
|
10
|
+
assert all(isinstance(i, int) for line in top for i in line), 'top must be a list of lists of integers'
|
|
11
|
+
assert all(isinstance(i, int) for line in side for i in line), 'side must be a list of lists of integers'
|
|
12
12
|
self.top = top
|
|
13
13
|
self.side = side
|
|
14
14
|
self.V = len(side)
|
|
@@ -63,7 +63,7 @@ class Board:
|
|
|
63
63
|
# Start variables for each run. This is the most critical variable for the problem.
|
|
64
64
|
starts = []
|
|
65
65
|
self.extra_vars[f"{ns}_starts"] = starts
|
|
66
|
-
for i
|
|
66
|
+
for i in range(len(clues)):
|
|
67
67
|
s = self.model.NewIntVar(0, L, f"{ns}_s[{i}]")
|
|
68
68
|
starts.append(s)
|
|
69
69
|
# Enforce order and >=1 blank between consecutive runs.
|
|
@@ -3,8 +3,8 @@ from dataclasses import dataclass
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
from ortools.sat.python import cp_model
|
|
5
5
|
|
|
6
|
-
from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, set_char,
|
|
7
|
-
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
6
|
+
from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, set_char, in_bounds, get_next_pos, Direction
|
|
7
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@dataclass
|
|
@@ -3,7 +3,7 @@ from dataclasses import dataclass
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
from ortools.sat.python import cp_model
|
|
5
5
|
|
|
6
|
-
from puzzle_solver.core.utils import Pos, get_all_pos, get_neighbors4, get_pos, in_bounds,
|
|
6
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_neighbors4, get_pos, in_bounds, get_char, polyominoes, Shape, Direction, get_next_pos
|
|
7
7
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
8
8
|
from puzzle_solver.core.utils_visualizer import render_shaded_grid
|
|
9
9
|
|
|
@@ -62,7 +62,7 @@ class Board:
|
|
|
62
62
|
assert len(hint_shapes) > 0, f'no shapes found for hint {hint_pos} with value {hint_value}'
|
|
63
63
|
self.model.AddExactlyOne([s.is_active for s in hint_shapes])
|
|
64
64
|
self.shapes_on_board.extend(hint_shapes)
|
|
65
|
-
|
|
65
|
+
|
|
66
66
|
# if no shape is active on the spot then it must be black
|
|
67
67
|
for pos in self.get_all_legal_pos():
|
|
68
68
|
shapes_here = [s for s in self.shapes_on_board if pos in s.body]
|
|
@@ -2,7 +2,7 @@ import numpy as np
|
|
|
2
2
|
from ortools.sat.python import cp_model
|
|
3
3
|
|
|
4
4
|
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_neighbors4, in_bounds, Direction, get_next_pos, get_char
|
|
5
|
-
from puzzle_solver.core.utils_ortools import and_constraint,
|
|
5
|
+
from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[Pos]:
|
|
@@ -97,7 +97,7 @@ class Board:
|
|
|
97
97
|
def solve_and_print(self, verbose: bool = True):
|
|
98
98
|
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
99
99
|
assignment: dict[Pos, int] = {}
|
|
100
|
-
for
|
|
100
|
+
for rectangle in self.rectangles:
|
|
101
101
|
if solver.Value(rectangle.active) == 1:
|
|
102
102
|
for pos in rectangle.body:
|
|
103
103
|
assignment[pos] = f'id{rectangle.clue_id}:N={rectangle.N}:{rectangle.height}x{rectangle.width}'
|
|
@@ -121,10 +121,6 @@ class Board:
|
|
|
121
121
|
set_char(res, pos, get_char(res, pos) + 'U')
|
|
122
122
|
if bottom_pos not in single_res.assignment or single_res.assignment[bottom_pos] != cur:
|
|
123
123
|
set_char(res, pos, get_char(res, pos) + 'D')
|
|
124
|
-
|
|
125
|
-
# for row in id_board:
|
|
126
|
-
# print(' ', row.tolist(), end=',\n')
|
|
127
|
-
# print(' ])')
|
|
128
|
-
print(render_grid(res, center_char=self.board))
|
|
124
|
+
print(render_grid(res, center_char=lambda r, c: self.board[r, c] if self.board[r, c] != ' ' else ' '))
|
|
129
125
|
|
|
130
126
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -76,7 +76,7 @@ class Board:
|
|
|
76
76
|
continue
|
|
77
77
|
self.enforce_corner_color(pos, self.board_colors[pos])
|
|
78
78
|
self.enforce_corner_number(pos, self.board_numbers[pos])
|
|
79
|
-
|
|
79
|
+
|
|
80
80
|
# enforce single connected component
|
|
81
81
|
def is_neighbor(edge1: tuple[Pos, Pos], edge2: tuple[Pos, Pos]) -> bool:
|
|
82
82
|
return any(c1 == c2 for c1 in edge1 for c2 in edge2)
|
|
@@ -65,7 +65,7 @@ class Board:
|
|
|
65
65
|
continue
|
|
66
66
|
direction = CHAR_TO_DIRECTION8[c]
|
|
67
67
|
self.constrain_plus_one(pos, direction)
|
|
68
|
-
|
|
68
|
+
|
|
69
69
|
def constrain_plus_one(self, pos: Pos, direction: Direction8):
|
|
70
70
|
beam_res = beam(pos, self.V, self.H, direction)
|
|
71
71
|
is_eq_list = []
|
|
@@ -75,7 +75,7 @@ class Board:
|
|
|
75
75
|
self.model.Add(self.model_vars[p] != self.model_vars[pos] + 1).OnlyEnforceIf(aux.Not())
|
|
76
76
|
is_eq_list.append(aux)
|
|
77
77
|
self.model.Add(lxp.Sum(is_eq_list) == 1)
|
|
78
|
-
|
|
78
|
+
|
|
79
79
|
def solve_and_print(self, verbose: bool = True):
|
|
80
80
|
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
81
81
|
assignment: dict[Pos, str] = {}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
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.
|
|
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
3
|
Look at the ./input_output/ directory for examples of input images and output json files.
|
|
4
4
|
The output json is used in the test_solve.py file to test the solver.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
import
|
|
7
|
+
import itertools
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
import numpy as np
|
|
10
10
|
cv = None
|
|
@@ -37,9 +37,11 @@ def mean_consecutives(arr):
|
|
|
37
37
|
sums, counts = [arr[0]], [1]
|
|
38
38
|
for k in arr[1:]:
|
|
39
39
|
if k == sums[-1] + counts[-1]:
|
|
40
|
-
sums[-1] += k
|
|
40
|
+
sums[-1] += k
|
|
41
|
+
counts[-1] += 1
|
|
41
42
|
else:
|
|
42
|
-
sums.append(k)
|
|
43
|
+
sums.append(k)
|
|
44
|
+
counts.append(1)
|
|
43
45
|
return np.array(sums)//np.array(counts)
|
|
44
46
|
|
|
45
47
|
def main(img_path):
|
|
@@ -90,7 +92,7 @@ def main(img_path):
|
|
|
90
92
|
|
|
91
93
|
# Build KD-like search by grid proximity
|
|
92
94
|
tol = int(cell*0.5) # max distance from an intersection to accept a circle
|
|
93
|
-
for (cx, cy,
|
|
95
|
+
for (cx, cy, _) in detected:
|
|
94
96
|
# find nearest indices
|
|
95
97
|
j = int(np.argmin(np.abs(h_idx - cy)))
|
|
96
98
|
i = int(np.argmin(np.abs(v_idx - cx)))
|
|
@@ -56,7 +56,7 @@ class Board:
|
|
|
56
56
|
next_pos = get_next_pos(pos, direction)
|
|
57
57
|
if in_bounds(next_pos, self.V, self.H):
|
|
58
58
|
self.cell_borders[(next_pos, get_opposite_direction(direction))] = var
|
|
59
|
-
|
|
59
|
+
|
|
60
60
|
def add_corner_vars(self, cell_border: CellBorder, var: cp_model.IntVar):
|
|
61
61
|
"""
|
|
62
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 wont make sense)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
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.
|
|
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
3
|
Look at the ./input_output/ directory for examples of input images and output json files.
|
|
4
4
|
The output json is used in the test_solve.py file to test the solver.
|
|
5
5
|
"""
|
|
@@ -26,7 +26,7 @@ def extract_lines(bw):
|
|
|
26
26
|
# location where the horizontal lines are
|
|
27
27
|
horizontal_idx = np.where(horizontal_means > horizontal_cutoff)[0]
|
|
28
28
|
# print(f"horizontal_idx: {horizontal_idx}")
|
|
29
|
-
height = len(horizontal_idx)
|
|
29
|
+
# height = len(horizontal_idx)
|
|
30
30
|
# show_wait_destroy("horizontal", horizontal) # this has the horizontal lines
|
|
31
31
|
|
|
32
32
|
rows = vertical.shape[0]
|
|
@@ -39,7 +39,7 @@ def extract_lines(bw):
|
|
|
39
39
|
vertical_cutoff = np.percentile(vertical_means, 50)
|
|
40
40
|
vertical_idx = np.where(vertical_means > vertical_cutoff)[0]
|
|
41
41
|
# print(f"vertical_idx: {vertical_idx}")
|
|
42
|
-
width = len(vertical_idx)
|
|
42
|
+
# width = len(vertical_idx)
|
|
43
43
|
# print(f"height: {height}, width: {width}")
|
|
44
44
|
# print(f"vertical_means: {vertical_means}")
|
|
45
45
|
# show_wait_destroy("vertical", vertical) # this has the vertical lines
|
|
@@ -126,7 +126,6 @@ def main(image):
|
|
|
126
126
|
print(f"vertical_idx: {vertical_idx}")
|
|
127
127
|
arr = np.zeros((height - 1, width - 1), dtype=object)
|
|
128
128
|
output = {'top': arr.copy(), 'left': arr.copy(), 'right': arr.copy(), 'bottom': arr.copy()}
|
|
129
|
-
target = 200_000
|
|
130
129
|
hists = {'top': {}, 'left': {}, 'right': {}, 'bottom': {}}
|
|
131
130
|
for j in range(height - 1):
|
|
132
131
|
for i in range(width - 1):
|
|
@@ -244,4 +243,6 @@ if __name__ == '__main__':
|
|
|
244
243
|
# main(Path(__file__).parent / 'input_output' / 'norinori_501d93110d6b4b818c268378973afbf268f96cfa8d7b4.png')
|
|
245
244
|
# main(Path(__file__).parent / 'input_output' / 'norinori_OTo0LDc0Miw5MTU.png')
|
|
246
245
|
# main(Path(__file__).parent / 'input_output' / 'heyawake_MDoxNiwxNDQ=.png')
|
|
247
|
-
main(Path(__file__).parent / 'input_output' / 'heyawake_MTQ6ODQ4LDEzOQ==.png')
|
|
246
|
+
# main(Path(__file__).parent / 'input_output' / 'heyawake_MTQ6ODQ4LDEzOQ==.png')
|
|
247
|
+
main(Path(__file__).parent / 'input_output' / 'sudoku_jigsaw.png')
|
|
248
|
+
|
|
@@ -77,7 +77,7 @@ class Board:
|
|
|
77
77
|
# print(f'{pos}:{direction} must == {neighbor}:{opposite_direction}')
|
|
78
78
|
|
|
79
79
|
# all blocks connected exactly N times (N usually 1 but can be 2 or 3)
|
|
80
|
-
for
|
|
80
|
+
for connections in self.block_neighbors.values():
|
|
81
81
|
is_connected_list = []
|
|
82
82
|
for pos_a, direction_a, pos_b, direction_b in connections:
|
|
83
83
|
v = self.model.NewBoolVar(f'{pos_a}:{direction_a}->{pos_b}:{direction_b}')
|