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.

@@ -1,6 +1,6 @@
1
- puzzle_solver/__init__.py,sha256=hsdC4t9rGAyQMPNuCjepmf4hZhoUgGbAW83ROdkDAoc,2574
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=SZQyLE6eld7B_Zq5AjyVjeGimsxoRk3D7M40wWWExIE,10463
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=AJwH-HMpSvOAu2DTV-VbP52jUK0hmlkP31g4xB0F3K4,5607
15
- puzzle_solver/puzzles/galaxies/parse_map/parse_map.py,sha256=DzVEihO6gokBDo-joSKCSyfdSsRdpZy3Ft50--nJyak,9354
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=Z2c0iEn7a6S6gaaJlvNMNNbAQwpztNLB0LTH_XVgu74,12269
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=VWHT-iYDaFsd37h9DE07EkeZ_dJMEfatXSByqC2vh04,8916
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.15.dist-info/METADATA,sha256=LIonrn93fVCBRPPgFoBKnSezECoANY9lY5JXIKss_g8,148507
47
- multi_puzzle_solver-0.9.15.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
48
- multi_puzzle_solver-0.9.15.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
49
- multi_puzzle_solver-0.9.15.dist-info/RECORD,,
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.15'
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
- def parent_of(p: Pos) -> list[Pos]:
179
- return [q for q in keys_in_order if (q, p) in tree_edge]
180
- def children_of(p: Pos) -> list[Pos]:
181
- return [q for q in keys_in_order if (p, q) in tree_edge]
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 keys_in_order:
201
- if p == q:
202
- continue
203
- if is_neighbor(p, q):
204
- tree_edge[(p, q)] = model.NewBoolVar(f"{prefix_name}tree_edge[{p} is parent of {q}]")
205
- model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([vs[p].Not()])
206
- model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([vs[q].Not()])
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(p):
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(p):
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(p)] + [vs[p].Not(), is_root[p]])
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, get_char, 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, or_constraint, force_connected_component
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/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/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, get_pos, in_bounds, Direction, get_next_pos
10
- from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
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
- shape: Shape
107
- shape_id: int
13
+ orientation: str
108
14
  body: set[Pos]
109
- disallow_same_shape: set[Pos]
15
+ disallow: set[Pos]
110
16
 
111
17
 
112
18
  class Board:
113
- def __init__(self, board: np.array, polyomino_degrees: int = 4):
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.polyomino_degrees = polyomino_degrees
120
- self.polyominoes = polyominoes(self.polyomino_degrees)
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: set() for i in self.block_numbers}
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.connected_components: dict[Pos, cp_model.IntVar] = {}
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 idx, (shape, shape_id) in enumerate(self.polyominoes):
143
- for translate in get_all_pos(self.V, self.H): # body of shape is translated to be at pos
144
- body = {get_pos(x=p.x + translate.x, y=p.y + translate.y) for p in shape}
145
- if any(not in_bounds(p, self.V, self.H) for p in body):
146
- continue
147
- # shape must be fully contained in one block
148
- if len(set(get_char(self.board, p) for p in body)) > 1:
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
- # RULES:
164
- # 1- You have to place one tetromino in each region in such a way that:
165
- # 2- 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
166
- # 3- The shaded cells should form a single connected area.
167
- # 4- 2x2 shaded areas are not allowed
168
-
169
- # each cell must be part of a shape, every shape must be fully on the board. Core constraint, otherwise shapes on the board make no sense.
170
- self.only_allow_shapes_on_board()
171
-
172
- self.force_one_shape_per_block() # Rule #1
173
- self.disallow_same_shape_touching() # Rule #2
174
- self.fc = force_connected_component(self.model, self.model_vars) # Rule #3
175
- # print('force connected vars:', len(fc))
176
- shape_2_by_2 = frozenset({Pos(0, 0), Pos(0, 1), Pos(1, 0), Pos(1, 1)})
177
- self.disallow_shape(shape_2_by_2) # Rule #4
178
-
179
-
180
- def only_allow_shapes_on_board(self):
181
- for shape_on_board in self.shapes_on_board:
182
- # if shape is active then all its body cells must be active
183
- self.model.Add(sum(self.model_vars[p] for p in shape_on_board.body) == len(shape_on_board.body)).OnlyEnforceIf(shape_on_board.is_active)
184
- # each cell must be part of a shape
185
- for p in get_all_pos(self.V, self.H):
186
- shapes_on_p = [s for s in self.shapes_on_board if p in s.body]
187
- self.model.Add(sum(s.is_active for s in shapes_on_p) == 1).OnlyEnforceIf(self.model_vars[p])
188
-
189
- def force_one_shape_per_block(self):
190
- # You have to place exactly one tetromino in each region
191
- for block_i in self.block_numbers:
192
- shapes_on_block = [s for s in self.shapes_on_board if s.body & self.blocks[block_i]]
193
- assert all(s.body.issubset(self.blocks[block_i]) for s in shapes_on_block), 'expected all shapes on block to be fully contained in the block'
194
- # print(f'shapes on block {block_i} has {len(shapes_on_block)} shapes')
195
- self.model.Add(sum(s.is_active for s in shapes_on_block) == 1)
196
-
197
- def disallow_same_shape_touching(self):
198
- # if shape is active then it must not touch any other shape of the same type
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, var in board.model_vars.items():
222
- assignment[pos] = solver.Value(var)
223
- all_other_variables = {
224
- 'fc': {k: solver.Value(v) for k, v in board.fc.items()}
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=str)
230
- for pos, val in single_res.assignment.items():
231
- c = 'X' if val == 1 else ' '
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('[\n' + '\n'.join([' ' + str(res[row].tolist()) + ',' for row in range(self.V)]) + '\n]')
234
- pass
235
- return generic_solve_all(self, board_to_solution, callback=callback if verbose_callback else None, verbose=verbose, max_solutions=max_solutions)
236
-
237
- def solve_then_constrain(self, verbose: bool = True):
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')