multi-puzzle-solver 0.9.15__py3-none-any.whl → 0.9.20__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.15.dist-info → multi_puzzle_solver-0.9.20.dist-info}/METADATA +509 -11
- {multi_puzzle_solver-0.9.15.dist-info → multi_puzzle_solver-0.9.20.dist-info}/RECORD +14 -10
- puzzle_solver/__init__.py +5 -1
- puzzle_solver/core/utils_ortools.py +21 -17
- puzzle_solver/puzzles/galaxies/galaxies.py +2 -2
- puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +1 -1
- puzzle_solver/puzzles/norinori/norinori.py +66 -220
- puzzle_solver/puzzles/slant/parse_map/parse_map.py +133 -0
- puzzle_solver/puzzles/slant/slant.py +117 -0
- puzzle_solver/puzzles/slitherlink/slitherlink.py +248 -0
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +37 -4
- puzzle_solver/puzzles/unequal/unequal.py +128 -0
- {multi_puzzle_solver-0.9.15.dist-info → multi_puzzle_solver-0.9.20.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-0.9.15.dist-info → multi_puzzle_solver-0.9.20.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
puzzle_solver/__init__.py,sha256=
|
|
1
|
+
puzzle_solver/__init__.py,sha256=EuBuJfRIA3IGQExkQ5h4BNYXWHLzdZh3j65ocCMy_64,2855
|
|
2
2
|
puzzle_solver/core/utils.py,sha256=_LA81kHrsgvqPvq7RISBeaurXmYMKAU9N6qmV8n0G7s,8063
|
|
3
|
-
puzzle_solver/core/utils_ortools.py,sha256=
|
|
3
|
+
puzzle_solver/core/utils_ortools.py,sha256=2xEL9cMEKmNhRD9lhr2nGdZ3Lbmc9cnHY8xv6iLhUr0,10542
|
|
4
4
|
puzzle_solver/puzzles/aquarium/aquarium.py,sha256=BUfkAS2d9eG3TdMoe1cOGGeNYgKUebRvn-z9nsC9gvE,5708
|
|
5
5
|
puzzle_solver/puzzles/battleships/battleships.py,sha256=RuYCrs4j0vUjlU139NRYYP-uNPAgO0V7hAzbsHrRwD8,7446
|
|
6
6
|
puzzle_solver/puzzles/black_box/black_box.py,sha256=ZnHDVt6PFS_r1kMNSsbz9hav1hxIrNDUvPyERGPjLjM,15635
|
|
@@ -11,8 +11,8 @@ puzzle_solver/puzzles/chess_range/chess_solo.py,sha256=U3v766UsZHx_dC3gxqU90VbjA
|
|
|
11
11
|
puzzle_solver/puzzles/chess_sequence/chess_sequence.py,sha256=6ap3Wouf2PxHV4P56B9ol1QT98Ym6VHaxorQZWl6LnY,13692
|
|
12
12
|
puzzle_solver/puzzles/dominosa/dominosa.py,sha256=Nmb7pn8U27QJwGy9F3wo8ylqo2_U51OAo3GN2soaNpc,7195
|
|
13
13
|
puzzle_solver/puzzles/filling/filling.py,sha256=vrOIil285_r3IQ0F4c9mUBWMRVlPH4vowog_z1tCGdI,5567
|
|
14
|
-
puzzle_solver/puzzles/galaxies/galaxies.py,sha256=
|
|
15
|
-
puzzle_solver/puzzles/galaxies/parse_map/parse_map.py,sha256=
|
|
14
|
+
puzzle_solver/puzzles/galaxies/galaxies.py,sha256=p10lpmW0FjtneFCMEjG1FSiEpQuvD8zZG9FG8zYGoes,5582
|
|
15
|
+
puzzle_solver/puzzles/galaxies/parse_map/parse_map.py,sha256=v5TCrdREeOB69s9_QFgPHKA7flG69Im1HVzIdxH0qQc,9355
|
|
16
16
|
puzzle_solver/puzzles/guess/guess.py,sha256=sH-NlYhxM3DNbhk4eGde09kgM0KaDvSbLrpHQiwcFGo,10791
|
|
17
17
|
puzzle_solver/puzzles/inertia/inertia.py,sha256=gJBahkh69CrSWNscalKEoP1j4X-Q3XpbIBMiG9PUpU0,5657
|
|
18
18
|
puzzle_solver/puzzles/inertia/tsp.py,sha256=gobiISHtARA4Elq0jr90p6Yhq11ULjGoqsS-rLFhYcc,15389
|
|
@@ -26,24 +26,28 @@ puzzle_solver/puzzles/map/map.py,sha256=sxc57tapB8Tsgam-yoDitln1o-EB_SbIYvO6WEYy
|
|
|
26
26
|
puzzle_solver/puzzles/minesweeper/minesweeper.py,sha256=LiQVOGkWCsc1WtX8CdPgL_WwAcaeUFuoi5_eqH8U2Og,5876
|
|
27
27
|
puzzle_solver/puzzles/mosaic/mosaic.py,sha256=QX_nVpVKQg8OfaUcqFk9tKqsDyVqvZc6-XWvfI3YcSw,2175
|
|
28
28
|
puzzle_solver/puzzles/nonograms/nonograms.py,sha256=1jmDTOCnmivmBlwtMDyyk3TVqH5IjapzLn7zLQ4qubk,6056
|
|
29
|
-
puzzle_solver/puzzles/norinori/norinori.py,sha256=
|
|
29
|
+
puzzle_solver/puzzles/norinori/norinori.py,sha256=uC8vXAw35xsTmpmTeKqYW7tbcssms9LCcXFBONtV2Ng,4743
|
|
30
30
|
puzzle_solver/puzzles/pearl/pearl.py,sha256=OhzpMYpxqvR3GCd5NH4ETT0NO4X753kRi6p5omYLChM,6798
|
|
31
31
|
puzzle_solver/puzzles/range/range.py,sha256=rruvD5ZSaOgvQuX6uGV_Dkr82nSiWZ5kDz03_j7Tt24,4425
|
|
32
32
|
puzzle_solver/puzzles/signpost/signpost.py,sha256=-0_S6ycwzwlUf9-ZhP127Rgo5gMBOHiTM6t08dLLDac,3869
|
|
33
33
|
puzzle_solver/puzzles/singles/singles.py,sha256=3wACiUa1Vmh2ce6szQ2hPjyBuH7aHiQ888p4R2jFkW4,3342
|
|
34
|
+
puzzle_solver/puzzles/slant/slant.py,sha256=xF-N4PuXYfx638NP1f1mi6YncIZB4mLtXtdS79XyPbg,6122
|
|
35
|
+
puzzle_solver/puzzles/slant/parse_map/parse_map.py,sha256=dxnALSDXe9wU0uSD0QEXnzoh1q801mj1ePTNLtG0n60,4796
|
|
36
|
+
puzzle_solver/puzzles/slitherlink/slitherlink.py,sha256=jw-Buwzo_eZADL45zD5-Hs8HaT3AU4dZn6eifCUPnhA,11701
|
|
34
37
|
puzzle_solver/puzzles/star_battle/star_battle.py,sha256=IX6w4H3sifN01kPPtrAVRCK0Nl_xlXXSHvJKw8K1EuE,3718
|
|
35
38
|
puzzle_solver/puzzles/star_battle/star_battle_shapeless.py,sha256=lj05V0Y3A3NjMo1boMkPIwBhMtm6SWydjgAMeCf5EIo,225
|
|
36
39
|
puzzle_solver/puzzles/stitches/stitches.py,sha256=iK8t02q43gH3FPbuIDn4dK0sbaOgZOnw8yHNRNvNuIU,6534
|
|
37
|
-
puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=
|
|
40
|
+
puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=1LNJkIqpcz1LvY0H0uRedABQWm44dgNf9XeQuKm36WM,10275
|
|
38
41
|
puzzle_solver/puzzles/sudoku/sudoku.py,sha256=M_pry7XyKKzlfCF5rFi02lyOrj5GWZzXnDAxmD3NXvI,3588
|
|
39
42
|
puzzle_solver/puzzles/tents/tents.py,sha256=iyVK2WXfIT5j_9qqlQg0WmwvixwXlZSsHGK3XA-KpII,6283
|
|
40
43
|
puzzle_solver/puzzles/thermometers/thermometers.py,sha256=nsvJZkm7G8FALT27bpaB0lv5E_AWawqmvapQI8QcYXw,4015
|
|
41
44
|
puzzle_solver/puzzles/towers/towers.py,sha256=QvL0Pp-Z2ewCeq9ZkNrh8MShKOh-Y52sFBSudve68wk,6496
|
|
42
45
|
puzzle_solver/puzzles/tracks/tracks.py,sha256=98xds9SKNqtOLFTRUX_KSMC7XYmZo567LOFeqotVQaM,7237
|
|
43
46
|
puzzle_solver/puzzles/undead/undead.py,sha256=IrCUfzQFBem658P5KKqldG7vd2TugTHehcwseCarerM,6604
|
|
47
|
+
puzzle_solver/puzzles/unequal/unequal.py,sha256=ExY2XDCrqROCDpRLfHo8uVr1zuli1QvbCdNCiDhlCac,6978
|
|
44
48
|
puzzle_solver/puzzles/unruly/unruly.py,sha256=sDF0oKT50G-NshyW2DYrvAgD9q9Ku9ANUyNhGSAu7cQ,3827
|
|
45
49
|
puzzle_solver/utils/visualizer.py,sha256=tsX1yEKwmwXBYuBJpx_oZGe2UUt1g5yV73G3UbtmvtE,6817
|
|
46
|
-
multi_puzzle_solver-0.9.
|
|
47
|
-
multi_puzzle_solver-0.9.
|
|
48
|
-
multi_puzzle_solver-0.9.
|
|
49
|
-
multi_puzzle_solver-0.9.
|
|
50
|
+
multi_puzzle_solver-0.9.20.dist-info/METADATA,sha256=R36gUfx8jHLCnOMt4QEYf-wtBYskwk46IxxPJGMapjI,180363
|
|
51
|
+
multi_puzzle_solver-0.9.20.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
52
|
+
multi_puzzle_solver-0.9.20.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
|
|
53
|
+
multi_puzzle_solver-0.9.20.dist-info/RECORD,,
|
puzzle_solver/__init__.py
CHANGED
|
@@ -18,11 +18,14 @@ from puzzle_solver.puzzles.map import map as map_solver
|
|
|
18
18
|
from puzzle_solver.puzzles.minesweeper import minesweeper as minesweeper_solver
|
|
19
19
|
from puzzle_solver.puzzles.mosaic import mosaic as mosaic_solver
|
|
20
20
|
from puzzle_solver.puzzles.nonograms import nonograms as nonograms_solver
|
|
21
|
+
from puzzle_solver.puzzles.norinori import norinori as norinori_solver
|
|
21
22
|
from puzzle_solver.puzzles.lits import lits as lits_solver
|
|
22
23
|
from puzzle_solver.puzzles.pearl import pearl as pearl_solver
|
|
23
24
|
from puzzle_solver.puzzles.range import range as range_solver
|
|
24
25
|
from puzzle_solver.puzzles.signpost import signpost as signpost_solver
|
|
25
26
|
from puzzle_solver.puzzles.singles import singles as singles_solver
|
|
27
|
+
from puzzle_solver.puzzles.slant import slant as slant_solver
|
|
28
|
+
from puzzle_solver.puzzles.slitherlink import slitherlink as slitherlink_solver
|
|
26
29
|
from puzzle_solver.puzzles.star_battle import star_battle as star_battle_solver
|
|
27
30
|
from puzzle_solver.puzzles.star_battle import star_battle_shapeless as star_battle_shapeless_solver
|
|
28
31
|
from puzzle_solver.puzzles.stitches import stitches as stitches_solver
|
|
@@ -32,8 +35,9 @@ from puzzle_solver.puzzles.thermometers import thermometers as thermometers_solv
|
|
|
32
35
|
from puzzle_solver.puzzles.towers import towers as towers_solver
|
|
33
36
|
from puzzle_solver.puzzles.tracks import tracks as tracks_solver
|
|
34
37
|
from puzzle_solver.puzzles.undead import undead as undead_solver
|
|
38
|
+
from puzzle_solver.puzzles.unequal import unequal as unequal_solver
|
|
35
39
|
from puzzle_solver.puzzles.unruly import unruly as unruly_solver
|
|
36
40
|
|
|
37
41
|
from puzzle_solver.puzzles.inertia.parse_map.parse_map import main as inertia_image_parser
|
|
38
42
|
|
|
39
|
-
__version__ = '0.9.
|
|
43
|
+
__version__ = '0.9.20'
|
|
@@ -175,10 +175,15 @@ def force_no_loops(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.In
|
|
|
175
175
|
tree_edge: dict[tuple[Pos, Pos], cp_model.IntVar] = {} # tree_edge[p, q] means p is parent of q
|
|
176
176
|
prefix_name = "no_loops_"
|
|
177
177
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
178
|
+
parent_of = {p: [] for p in vs.keys()}
|
|
179
|
+
children_of = {p: [] for p in vs.keys()}
|
|
180
|
+
for p in vs.keys():
|
|
181
|
+
for q in vs.keys():
|
|
182
|
+
if p == q:
|
|
183
|
+
continue
|
|
184
|
+
if is_neighbor(p, q):
|
|
185
|
+
parent_of[q].append(p)
|
|
186
|
+
children_of[p].append(q)
|
|
182
187
|
|
|
183
188
|
keys_in_order = list(vs.keys()) # must enforce some ordering
|
|
184
189
|
node_to_idx: dict[Pos, int] = {p: i+1 for i, p in enumerate(keys_in_order)}
|
|
@@ -197,16 +202,13 @@ def force_no_loops(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.In
|
|
|
197
202
|
model.Add(block_root[p] == node_to_idx[p]).OnlyEnforceIf([is_root[p]])
|
|
198
203
|
|
|
199
204
|
for p in keys_in_order:
|
|
200
|
-
for q in
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
# a tree_edge[p, q] means p is parent of q thus h[q] = h[p] + 1
|
|
208
|
-
model.Add(node_height[q] == node_height[p] + 1).OnlyEnforceIf([tree_edge[(p, q)]])
|
|
209
|
-
model.Add(block_root[q] == block_root[p]).OnlyEnforceIf([tree_edge[(p, q)]])
|
|
205
|
+
for q in children_of[p]:
|
|
206
|
+
tree_edge[(p, q)] = model.NewBoolVar(f"{prefix_name}tree_edge[{p} is parent of {q}]")
|
|
207
|
+
model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([vs[p].Not()])
|
|
208
|
+
model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([vs[q].Not()])
|
|
209
|
+
# a tree_edge[p, q] means p is parent of q thus h[q] = h[p] + 1
|
|
210
|
+
model.Add(node_height[q] == node_height[p] + 1).OnlyEnforceIf([tree_edge[(p, q)]])
|
|
211
|
+
model.Add(block_root[q] == block_root[p]).OnlyEnforceIf([tree_edge[(p, q)]])
|
|
210
212
|
|
|
211
213
|
for (p, q) in tree_edge:
|
|
212
214
|
if (q, p) in tree_edge:
|
|
@@ -214,14 +216,14 @@ def force_no_loops(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.In
|
|
|
214
216
|
model.Add(tree_edge[(p, q)] == 1).OnlyEnforceIf([tree_edge[(q, p)].Not(), vs[p], vs[q]])
|
|
215
217
|
|
|
216
218
|
for p in keys_in_order:
|
|
217
|
-
for p_child in children_of
|
|
219
|
+
for p_child in children_of[p]:
|
|
218
220
|
# i am root thus I point to all my children
|
|
219
221
|
model.Add(tree_edge[(p, p_child)] == 1).OnlyEnforceIf([is_root[p], vs[p_child]])
|
|
220
|
-
for p_parent in parent_of
|
|
222
|
+
for p_parent in parent_of[p]:
|
|
221
223
|
# i am root thus I have no parent
|
|
222
224
|
model.Add(tree_edge[(p_parent, p)] == 0).OnlyEnforceIf([is_root[p]])
|
|
223
225
|
# every active node has exactly 1 parent except root has none
|
|
224
|
-
model.AddExactlyOne([tree_edge[(p_parent, p)] for p_parent in parent_of
|
|
226
|
+
model.AddExactlyOne([tree_edge[(p_parent, p)] for p_parent in parent_of[p]] + [vs[p].Not(), is_root[p]])
|
|
225
227
|
|
|
226
228
|
# now each subgraph has directions where each non-root points to a single parent (and its value is parent+1).
|
|
227
229
|
# to break cycles, every non-root active node must be > all neighbors that arent children
|
|
@@ -233,5 +235,7 @@ def force_no_loops(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.In
|
|
|
233
235
|
all_new_vars[f"{prefix_name}tree_edge[{k[0]} is parent of {k[1]}]"] = v
|
|
234
236
|
for k, v in node_height.items():
|
|
235
237
|
all_new_vars[f"{prefix_name}node_height[{k}]"] = v
|
|
238
|
+
for k, v in block_root.items():
|
|
239
|
+
all_new_vars[f"{prefix_name}block_root[{k}]"] = v
|
|
236
240
|
|
|
237
241
|
return all_new_vars
|
|
@@ -4,8 +4,8 @@ from typing import Iterable, Union
|
|
|
4
4
|
import numpy as np
|
|
5
5
|
from ortools.sat.python import cp_model
|
|
6
6
|
|
|
7
|
-
from puzzle_solver.core.utils import Pos, get_all_pos,
|
|
8
|
-
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution,
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, Direction, get_next_pos, in_bounds, get_opposite_direction, get_pos
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def parse_numpy(galaxies: np.ndarray) -> list[tuple[Pos, ...]]:
|
|
@@ -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/
|
|
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
|
"""
|
|
@@ -1,133 +1,32 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import time
|
|
3
1
|
from dataclasses import dataclass
|
|
4
|
-
from typing import Optional, Union
|
|
5
2
|
|
|
6
|
-
from ortools.sat.python import cp_model
|
|
7
3
|
import numpy as np
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
8
5
|
|
|
9
|
-
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char,
|
|
10
|
-
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
# a shape on the 2d board is just a set of positions
|
|
14
|
-
Shape = frozenset[Pos]
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def polyominoes(N):
|
|
18
|
-
"""Generate all polyominoes of size N. Every rotation and reflection is considered different and included in the result.
|
|
19
|
-
Translation is not considered different and is removed from the result (otherwise the result would be infinite).
|
|
20
|
-
|
|
21
|
-
Below is the number of unique polyominoes of size N (not including rotations and reflections) and the lenth of the returned result (which includes all rotations and reflections)
|
|
22
|
-
N name #shapes #results
|
|
23
|
-
1 monomino 1 1
|
|
24
|
-
2 domino 1 2
|
|
25
|
-
3 tromino 2 6
|
|
26
|
-
4 tetromino 5 19
|
|
27
|
-
5 pentomino 12 63
|
|
28
|
-
6 hexomino 35 216
|
|
29
|
-
7 heptomino 108 760
|
|
30
|
-
8 octomino 369 2,725
|
|
31
|
-
9 nonomino 1,285 9,910
|
|
32
|
-
10 decomino 4,655 36,446
|
|
33
|
-
11 undecomino 17,073 135,268
|
|
34
|
-
12 dodecomino 63,600 505,861
|
|
35
|
-
Source: https://en.wikipedia.org/wiki/Polyomino
|
|
36
|
-
|
|
37
|
-
Args:
|
|
38
|
-
N (int): The size of the polyominoes to generate.
|
|
39
|
-
|
|
40
|
-
Returns:
|
|
41
|
-
set[(frozenset[Pos], int)]: A set of all polyominoes of size N (rotated and reflected up to D4 symmetry) along with a unique ID for each polyomino.
|
|
42
|
-
"""
|
|
43
|
-
assert N >= 1, 'N cannot be less than 1'
|
|
44
|
-
# need a frozenset because regular sets are not hashable
|
|
45
|
-
shapes: set[Shape] = {frozenset({Pos(0, 0)})}
|
|
46
|
-
for i in range(1, N):
|
|
47
|
-
next_shapes: set[Shape] = set()
|
|
48
|
-
for s in shapes:
|
|
49
|
-
# frontier: all 4-neighbors of existing cells not already in the shape
|
|
50
|
-
frontier = {get_next_pos(pos, direction)
|
|
51
|
-
for pos in s
|
|
52
|
-
for direction in Direction
|
|
53
|
-
if get_next_pos(pos, direction) not in s}
|
|
54
|
-
for cell in frontier:
|
|
55
|
-
t = s | {cell}
|
|
56
|
-
# normalize by translation only: shift so min x,y is (0,0). This removes translational symmetries.
|
|
57
|
-
minx = min(pos.x for pos in t)
|
|
58
|
-
miny = min(pos.y for pos in t)
|
|
59
|
-
t0 = frozenset(Pos(x=pos.x - minx, y=pos.y - miny) for pos in t)
|
|
60
|
-
next_shapes.add(t0)
|
|
61
|
-
shapes = next_shapes
|
|
62
|
-
# shapes is now complete, now classify up to D4 symmetry (rotations/reflections), translations ignored
|
|
63
|
-
mats = (
|
|
64
|
-
( 1, 0, 0, 1), # regular
|
|
65
|
-
(-1, 0, 0, 1), # reflect about x
|
|
66
|
-
( 1, 0, 0,-1), # reflect about y
|
|
67
|
-
(-1, 0, 0,-1), # reflect about x and y
|
|
68
|
-
# trnaspose then all 4 above
|
|
69
|
-
( 0, 1, 1, 0), ( 0, 1, -1, 0), ( 0,-1, 1, 0), ( 0,-1, -1, 0),
|
|
70
|
-
)
|
|
71
|
-
# compute canonical representative for each shape (lexicographically smallest normalized transform)
|
|
72
|
-
shape_to_canon: dict[Shape, tuple[Pos, ...]] = {}
|
|
73
|
-
for s in shapes:
|
|
74
|
-
reps: list[tuple[Pos, ...]] = []
|
|
75
|
-
for a, b, c, d in mats:
|
|
76
|
-
pts = {Pos(x=a*p.x + b*p.y, y=c*p.x + d*p.y) for p in s}
|
|
77
|
-
minx = min(p.x for p in pts)
|
|
78
|
-
miny = min(p.y for p in pts)
|
|
79
|
-
rep = tuple(sorted(Pos(x=p.x - minx, y=p.y - miny) for p in pts))
|
|
80
|
-
reps.append(rep)
|
|
81
|
-
canon = min(reps)
|
|
82
|
-
shape_to_canon[s] = canon
|
|
83
|
-
|
|
84
|
-
canon_set = set(shape_to_canon.values())
|
|
85
|
-
canon_to_id = {canon: i for i, canon in enumerate(sorted(canon_set))}
|
|
86
|
-
result = {(s, canon_to_id[shape_to_canon[s]]) for s in shapes}
|
|
87
|
-
return result
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
@dataclass(frozen=True)
|
|
91
|
-
class SingleSolution:
|
|
92
|
-
assignment: dict[Pos, Union[str, int]]
|
|
93
|
-
all_other_variables: dict
|
|
94
|
-
|
|
95
|
-
def get_hashable_solution(self) -> str:
|
|
96
|
-
result = []
|
|
97
|
-
for pos, v in self.assignment.items():
|
|
98
|
-
result.append((pos.x, pos.y, v))
|
|
99
|
-
return json.dumps(result, sort_keys=True)
|
|
100
|
-
|
|
6
|
+
from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, set_char, polyominoes, in_bounds, get_next_pos, Direction
|
|
7
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint
|
|
101
8
|
|
|
102
9
|
|
|
103
10
|
@dataclass
|
|
104
11
|
class ShapeOnBoard:
|
|
105
12
|
is_active: cp_model.IntVar
|
|
106
|
-
|
|
107
|
-
shape_id: int
|
|
13
|
+
orientation: str
|
|
108
14
|
body: set[Pos]
|
|
109
|
-
|
|
15
|
+
disallow: set[Pos]
|
|
110
16
|
|
|
111
17
|
|
|
112
18
|
class Board:
|
|
113
|
-
def __init__(self, board: np.
|
|
19
|
+
def __init__(self, board: np.ndarray):
|
|
114
20
|
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
115
|
-
self.V = board.shape[0]
|
|
116
|
-
self.H = board.shape[1]
|
|
117
|
-
assert all((str(c.item()).isdecimal() for c in np.nditer(board))), 'board must contain only digits'
|
|
118
21
|
self.board = board
|
|
119
|
-
self.
|
|
120
|
-
|
|
121
|
-
|
|
22
|
+
self.V, self.H = board.shape
|
|
23
|
+
assert all((c == ' ') or str(c).isdecimal() for c in np.nditer(board)), "board must contain space or digits"
|
|
122
24
|
self.block_numbers = set([int(c.item()) for c in np.nditer(board)])
|
|
123
|
-
self.blocks = {i:
|
|
124
|
-
for cell in get_all_pos(self.V, self.H):
|
|
125
|
-
self.blocks[int(get_char(self.board, cell))].add(cell)
|
|
25
|
+
self.blocks = {i: [pos for pos in get_all_pos(self.V, self.H) if int(get_char(self.board, pos)) == i] for i in self.block_numbers}
|
|
126
26
|
|
|
127
27
|
self.model = cp_model.CpModel()
|
|
128
28
|
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
129
|
-
self.
|
|
130
|
-
self.shapes_on_board: list[ShapeOnBoard] = [] # will contain every possible shape on the board based on polyomino degrees
|
|
29
|
+
self.shapes_on_board: list[ShapeOnBoard] = []
|
|
131
30
|
|
|
132
31
|
self.create_vars()
|
|
133
32
|
self.init_shapes_on_board()
|
|
@@ -136,120 +35,67 @@ class Board:
|
|
|
136
35
|
def create_vars(self):
|
|
137
36
|
for pos in get_all_pos(self.V, self.H):
|
|
138
37
|
self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
|
|
139
|
-
# print('base vars:', len(self.model_vars))
|
|
140
38
|
|
|
141
39
|
def init_shapes_on_board(self):
|
|
142
|
-
for
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
continue
|
|
150
|
-
# 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
|
|
151
|
-
disallow_same_shape = set(get_next_pos(p, direction) for p in body for direction in Direction)
|
|
152
|
-
disallow_same_shape -= body
|
|
153
|
-
self.shapes_on_board.append(ShapeOnBoard(
|
|
154
|
-
is_active=self.model.NewBoolVar(f'{idx}:{translate}:is_active'),
|
|
155
|
-
shape=shape,
|
|
156
|
-
shape_id=shape_id,
|
|
157
|
-
body=body,
|
|
158
|
-
disallow_same_shape=disallow_same_shape,
|
|
159
|
-
))
|
|
160
|
-
# print('shapes on board:', len(self.shapes_on_board))
|
|
40
|
+
for pos in get_all_pos(self.V, self.H):
|
|
41
|
+
shape = self.get_shape(pos, 'horizontal')
|
|
42
|
+
if shape is not None:
|
|
43
|
+
self.shapes_on_board.append(shape)
|
|
44
|
+
shape = self.get_shape(pos, 'vertical')
|
|
45
|
+
if shape is not None:
|
|
46
|
+
self.shapes_on_board.append(shape)
|
|
161
47
|
|
|
162
48
|
def add_all_constraints(self):
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
#
|
|
170
|
-
self.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def
|
|
198
|
-
|
|
199
|
-
for shape_on_board in self.shapes_on_board:
|
|
200
|
-
similar_shapes = [s for s in self.shapes_on_board if s.shape_id == shape_on_board.shape_id]
|
|
201
|
-
for s in similar_shapes:
|
|
202
|
-
if shape_on_board.disallow_same_shape & s.body: # this shape disallows having s be on the board
|
|
203
|
-
self.model.Add(s.is_active == 0).OnlyEnforceIf(shape_on_board.is_active)
|
|
204
|
-
|
|
205
|
-
def disallow_shape(self, shape_to_disallow: Shape):
|
|
206
|
-
# for every position in the board, force sum of body < len(body)
|
|
207
|
-
for translate in get_all_pos(self.V, self.H):
|
|
208
|
-
cur_body = {get_pos(x=p.x + translate.x, y=p.y + translate.y) for p in shape_to_disallow}
|
|
209
|
-
if any(not in_bounds(p, self.V, self.H) for p in cur_body):
|
|
210
|
-
continue
|
|
211
|
-
self.model.Add(sum(self.model_vars[p] for p in cur_body) < len(cur_body))
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def solve_and_print(self, verbose: bool = True, max_solutions: Optional[int] = None, verbose_callback: Optional[bool] = None):
|
|
217
|
-
if verbose_callback is None:
|
|
218
|
-
verbose_callback = verbose
|
|
219
|
-
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
49
|
+
# if a piece is active then all its body is active and the disallow is inactive
|
|
50
|
+
for shape in self.shapes_on_board:
|
|
51
|
+
for pos in shape.body:
|
|
52
|
+
self.model.Add(self.model_vars[pos] == 1).OnlyEnforceIf(shape.is_active)
|
|
53
|
+
for pos in shape.disallow:
|
|
54
|
+
self.model.Add(self.model_vars[pos] == 0).OnlyEnforceIf(shape.is_active)
|
|
55
|
+
# if a spot is active then exactly one piece (with a body there) is active
|
|
56
|
+
for pos in get_all_pos(self.V, self.H):
|
|
57
|
+
pieces_on_pos = [shape for shape in self.shapes_on_board if pos in shape.body]
|
|
58
|
+
# if pos is on then exactly one shape is active. if pos is off then 0 shapes are active.
|
|
59
|
+
self.model.Add(sum(shape.is_active for shape in pieces_on_pos) == self.model_vars[pos])
|
|
60
|
+
# every region must have exactly 2 spots active.
|
|
61
|
+
for block in self.blocks.values():
|
|
62
|
+
self.model.Add(sum(self.model_vars[pos] for pos in block) == 2)
|
|
63
|
+
|
|
64
|
+
def get_shape(self, pos: Pos, orientation: str) -> Shape:
|
|
65
|
+
assert orientation in ['horizontal', 'vertical'], 'orientation must be horizontal or vertical'
|
|
66
|
+
if orientation == 'horizontal':
|
|
67
|
+
body = {pos, get_next_pos(pos, Direction.RIGHT)}
|
|
68
|
+
else:
|
|
69
|
+
body = {pos, get_next_pos(pos, Direction.DOWN)}
|
|
70
|
+
if any(not in_bounds(p, self.V, self.H) for p in body):
|
|
71
|
+
return None
|
|
72
|
+
disallow = set(get_next_pos(p, direction) for p in body for direction in Direction)
|
|
73
|
+
disallow = {p for p in disallow if p not in body and in_bounds(p, self.V, self.H)}
|
|
74
|
+
shape_on_board = ShapeOnBoard(
|
|
75
|
+
is_active=self.model.NewBoolVar(f'horizontal:{pos}'),
|
|
76
|
+
orientation='horizontal',
|
|
77
|
+
body=body,
|
|
78
|
+
disallow=disallow,
|
|
79
|
+
)
|
|
80
|
+
return shape_on_board
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def solve_and_print(self, verbose: bool = True):
|
|
84
|
+
def board_to_solution(board: "Board", solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
220
85
|
assignment: dict[Pos, int] = {}
|
|
221
|
-
for pos
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
return SingleSolution(assignment=assignment, all_other_variables=all_other_variables)
|
|
86
|
+
for pos in get_all_pos(self.V, self.H):
|
|
87
|
+
if solver.Value(self.model_vars[pos]) == 1:
|
|
88
|
+
assignment[pos] = get_char(self.board, pos)
|
|
89
|
+
return SingleSolution(assignment=assignment)
|
|
227
90
|
def callback(single_res: SingleSolution):
|
|
228
91
|
print("Solution found")
|
|
229
|
-
res = np.full((self.V, self.H), ' ', dtype=
|
|
230
|
-
for pos
|
|
231
|
-
c =
|
|
92
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
93
|
+
for pos in get_all_pos(self.V, self.H):
|
|
94
|
+
c = get_char(self.board, pos)
|
|
95
|
+
c = 'X' if pos in single_res.assignment else ' '
|
|
232
96
|
set_char(res, pos, c)
|
|
233
|
-
print('[
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
tic = time.time()
|
|
239
|
-
all_solutions = []
|
|
240
|
-
while True:
|
|
241
|
-
solutions = self.solve_and_print(verbose=False, verbose_callback=verbose, max_solutions=1)
|
|
242
|
-
if len(solutions) == 0:
|
|
243
|
-
break
|
|
244
|
-
all_solutions.extend(solutions)
|
|
245
|
-
assignment = solutions[0].assignment
|
|
246
|
-
# constrain the board to not return the same solution again
|
|
247
|
-
lits = [self.model_vars[p].Not() if assignment[p] == 1 else self.model_vars[p] for p in assignment.keys()]
|
|
248
|
-
self.model.AddBoolOr(lits)
|
|
249
|
-
self.model.ClearHints()
|
|
250
|
-
for k, v in solutions[0].all_other_variables['fc'].items():
|
|
251
|
-
self.model.AddHint(self.fc[k], v)
|
|
252
|
-
print(f'Solutions found: {len(all_solutions)}')
|
|
253
|
-
toc = time.time()
|
|
254
|
-
print(f'Time taken: {toc - tic:.2f} seconds')
|
|
255
|
-
return all_solutions
|
|
97
|
+
print('[')
|
|
98
|
+
for row in res:
|
|
99
|
+
print(" [ '" + "', '".join(row.tolist()) + "' ],")
|
|
100
|
+
print(']')
|
|
101
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,133 @@
|
|
|
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 json, 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; counts[-1] += 1
|
|
41
|
+
else:
|
|
42
|
+
sums.append(k); counts.append(1)
|
|
43
|
+
return np.array(sums)//np.array(counts)
|
|
44
|
+
|
|
45
|
+
def main(img_path):
|
|
46
|
+
global cv
|
|
47
|
+
import cv2 as cv_module
|
|
48
|
+
cv = cv_module
|
|
49
|
+
image_path = Path(img_path)
|
|
50
|
+
output_path = image_path.parent / (image_path.stem + '.json')
|
|
51
|
+
src = cv.imread(img_path, cv.IMREAD_COLOR)
|
|
52
|
+
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
|
|
53
|
+
inv = cv.bitwise_not(gray)
|
|
54
|
+
bw = cv.adaptiveThreshold(inv, 255, cv.ADAPTIVE_THRESH_MEAN_C, cv.THRESH_BINARY, 15, -2)
|
|
55
|
+
h_idx, v_idx = extract_lines(bw)
|
|
56
|
+
h_idx = mean_consecutives(h_idx)
|
|
57
|
+
v_idx = mean_consecutives(v_idx)
|
|
58
|
+
|
|
59
|
+
# Estimate grid cell and circle radii
|
|
60
|
+
cell = int(np.median(np.diff(h_idx))) if len(h_idx) > 3 else 40
|
|
61
|
+
r_min = max(6, int(cell*0.18))
|
|
62
|
+
r_max = int(cell*0.52)
|
|
63
|
+
|
|
64
|
+
# Global Hough detection with parameter sweep
|
|
65
|
+
blur = cv.medianBlur(gray, 5)
|
|
66
|
+
detected = [] # x, y, r
|
|
67
|
+
|
|
68
|
+
for dp, p2 in itertools.product([1.2, 1.0], [20, 18, 16, 14, 12]):
|
|
69
|
+
circles = cv.HoughCircles(
|
|
70
|
+
blur, cv.HOUGH_GRADIENT, dp=dp, minDist=max(12, int(cell*0.75)),
|
|
71
|
+
param1=120, param2=p2, minRadius=r_min, maxRadius=r_max
|
|
72
|
+
)
|
|
73
|
+
if circles is not None:
|
|
74
|
+
for (x, y, r) in np.round(circles[0, :]).astype(int):
|
|
75
|
+
detected.append((x, y, r))
|
|
76
|
+
|
|
77
|
+
# Non-maximum suppression to remove duplicates
|
|
78
|
+
def nms(circles, dist_thr=10):
|
|
79
|
+
kept = []
|
|
80
|
+
for x,y,r in sorted(circles, key=lambda c: -c[2]):
|
|
81
|
+
if all((x-kx)**2+(y-ky)**2 > dist_thr**2 for kx,ky,kr in kept):
|
|
82
|
+
kept.append((x,y,r))
|
|
83
|
+
return kept
|
|
84
|
+
|
|
85
|
+
detected = nms(detected, dist_thr=max(10,int(cell*0.4)))
|
|
86
|
+
|
|
87
|
+
# Map circle centers to nearest intersection
|
|
88
|
+
H, W = len(h_idx), len(v_idx)
|
|
89
|
+
presence = np.zeros((H, W), dtype=int)
|
|
90
|
+
|
|
91
|
+
# Build KD-like search by grid proximity
|
|
92
|
+
tol = int(cell*0.5) # max distance from an intersection to accept a circle
|
|
93
|
+
for (cx, cy, r) in detected:
|
|
94
|
+
# find nearest indices
|
|
95
|
+
j = int(np.argmin(np.abs(h_idx - cy)))
|
|
96
|
+
i = int(np.argmin(np.abs(v_idx - cx)))
|
|
97
|
+
if abs(h_idx[j]-cy) <= tol and abs(v_idx[i]-cx) <= tol:
|
|
98
|
+
presence[j, i] = 1
|
|
99
|
+
|
|
100
|
+
with open(output_path, 'w') as f:
|
|
101
|
+
f.write('[\n')
|
|
102
|
+
for i, row in enumerate(presence):
|
|
103
|
+
f.write(' ' + str(row.tolist()).replace("'", '"'))
|
|
104
|
+
if i != len(presence) - 1:
|
|
105
|
+
f.write(',')
|
|
106
|
+
f.write('\n')
|
|
107
|
+
f.write(']')
|
|
108
|
+
print('output json: ', output_path)
|
|
109
|
+
print('output json: ', output_path)
|
|
110
|
+
print('output json: ', output_path)
|
|
111
|
+
|
|
112
|
+
overlay = src.copy()
|
|
113
|
+
for (cx, cy, r) in detected:
|
|
114
|
+
cv.circle(overlay, (cx, cy), r, (255,0,0), 2)
|
|
115
|
+
for j, y in enumerate(h_idx):
|
|
116
|
+
for i, x in enumerate(v_idx):
|
|
117
|
+
color = (0,0,255) if presence[j,i]==1 else (0,255,0)
|
|
118
|
+
cv.circle(overlay, (int(x), int(y)), 4, color, 2)
|
|
119
|
+
show_wait_destroy("overlay", overlay)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def show_wait_destroy(winname, img):
|
|
124
|
+
cv.imshow(winname, img)
|
|
125
|
+
cv.moveWindow(winname, 500, 0)
|
|
126
|
+
cv.waitKey(0)
|
|
127
|
+
cv.destroyWindow(winname)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == '__main__':
|
|
131
|
+
# to run this script and visualize the output, in the root run:
|
|
132
|
+
# python .\src\puzzle_solver\puzzles\slant\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
|
|
133
|
+
main(Path(__file__).parent / 'input_output' / '23131379850022376.png')
|