multi-puzzle-solver 0.9.12__py3-none-any.whl → 0.9.13__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.12.dist-info → multi_puzzle_solver-0.9.13.dist-info}/METADATA +9 -9
- {multi_puzzle_solver-0.9.12.dist-info → multi_puzzle_solver-0.9.13.dist-info}/RECORD +7 -6
- puzzle_solver/__init__.py +2 -2
- puzzle_solver/puzzles/lits/lits.py +255 -0
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +212 -212
- {multi_puzzle_solver-0.9.12.dist-info → multi_puzzle_solver-0.9.13.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-0.9.12.dist-info → multi_puzzle_solver-0.9.13.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: multi-puzzle-solver
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.13
|
|
4
4
|
Summary: Efficient solvers for numerous popular and esoteric logic puzzles using CP-SAT
|
|
5
5
|
Author: Ar-Kareem
|
|
6
6
|
Project-URL: Homepage, https://github.com/Ar-Kareem/puzzle_solver
|
|
@@ -260,8 +260,8 @@ These are all the puzzles that are implemented in this repo. <br> Click on any o
|
|
|
260
260
|
</a>
|
|
261
261
|
</td>
|
|
262
262
|
<td align="center">
|
|
263
|
-
<a href="#
|
|
264
|
-
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/
|
|
263
|
+
<a href="#lits-puzzle-type-33"><b>Lits</b><br><br>
|
|
264
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/lits_solved.png" alt="Lits" width="140">
|
|
265
265
|
</a>
|
|
266
266
|
</td>
|
|
267
267
|
</tr>
|
|
@@ -311,7 +311,7 @@ These are all the puzzles that are implemented in this repo. <br> Click on any o
|
|
|
311
311
|
- [Kakurasu (Puzzle Type #30)](#kakurasu-puzzle-type-30)
|
|
312
312
|
- [Star Battle (Puzzle Type #31)](#star-battle-puzzle-type-31)
|
|
313
313
|
- [Star Battle Shapeless (Puzzle Type #32)](#star-battle-shapeless-puzzle-type-32)
|
|
314
|
-
- [
|
|
314
|
+
- [Lits (Puzzle Type #33)](#lits-puzzle-type-33)
|
|
315
315
|
- [Why SAT / CP-SAT?](#why-sat--cp-sat)
|
|
316
316
|
- [Testing](#testing)
|
|
317
317
|
- [Contributing](#contributing)
|
|
@@ -2693,9 +2693,9 @@ Time taken: 0.02 seconds
|
|
|
2693
2693
|
|
|
2694
2694
|
---
|
|
2695
2695
|
|
|
2696
|
-
##
|
|
2696
|
+
## Lits (Puzzle Type #33)
|
|
2697
2697
|
|
|
2698
|
-
* [**Play online**](https://www.puzzle-
|
|
2698
|
+
* [**Play online**](https://www.puzzle-lits.com/)
|
|
2699
2699
|
|
|
2700
2700
|
* [**Solver Code**][33]
|
|
2701
2701
|
|
|
@@ -2715,7 +2715,7 @@ Note: The solver is capable of solving variations where the puzzle pieces the ma
|
|
|
2715
2715
|
|
|
2716
2716
|
**Unsolved puzzle**
|
|
2717
2717
|
|
|
2718
|
-
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/
|
|
2718
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/lits_unsolved.png" alt="Lits unsolved" width="500">
|
|
2719
2719
|
|
|
2720
2720
|
Code to utilize this package and solve the puzzle:
|
|
2721
2721
|
|
|
@@ -2778,7 +2778,7 @@ Time taken: 0.38 seconds
|
|
|
2778
2778
|
|
|
2779
2779
|
**Solved puzzle**
|
|
2780
2780
|
|
|
2781
|
-
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/
|
|
2781
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/lits_solved.png" alt="Lits solved" width="500">
|
|
2782
2782
|
|
|
2783
2783
|
---
|
|
2784
2784
|
|
|
@@ -2865,4 +2865,4 @@ Issues and PRs welcome!
|
|
|
2865
2865
|
[30]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/kakurasu "puzzle_solver/src/puzzle_solver/puzzles/kakurasu at master · Ar-Kareem/puzzle_solver · GitHub"
|
|
2866
2866
|
[31]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/star_battle "puzzle_solver/src/puzzle_solver/puzzles/star_battle at master · Ar-Kareem/puzzle_solver · GitHub"
|
|
2867
2867
|
[32]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/star_battle_shapeless "puzzle_solver/src/puzzle_solver/puzzles/star_battle_shapeless at master · Ar-Kareem/puzzle_solver · GitHub"
|
|
2868
|
-
[33]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/
|
|
2868
|
+
[33]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/lits "puzzle_solver/src/puzzle_solver/puzzles/lits at master · Ar-Kareem/puzzle_solver · GitHub"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
puzzle_solver/__init__.py,sha256=
|
|
1
|
+
puzzle_solver/__init__.py,sha256=3aQDyvMPWTlYKDo3j-v4x_lUcNbp7KyallMChQEeJY0,2429
|
|
2
2
|
puzzle_solver/core/utils.py,sha256=D7enPxJjnsTbGDqqtOtGaRaetwGs0nqrNtTnrqhMB-g,3408
|
|
3
3
|
puzzle_solver/core/utils_ortools.py,sha256=eoT9hSJe-c67A_hsu1jnMpyRgMrTtUs5n2j_m5Hk8Do,7362
|
|
4
4
|
puzzle_solver/puzzles/aquarium/aquarium.py,sha256=BUfkAS2d9eG3TdMoe1cOGGeNYgKUebRvn-z9nsC9gvE,5708
|
|
@@ -17,6 +17,7 @@ puzzle_solver/puzzles/inertia/parse_map/parse_map.py,sha256=A9JQTNqamUdzlwqks0XQ
|
|
|
17
17
|
puzzle_solver/puzzles/kakurasu/kakurasu.py,sha256=VNGMJnBHDi6WkghLObRLhUvkmrPaGphTTUDMC0TkQvQ,2064
|
|
18
18
|
puzzle_solver/puzzles/keen/keen.py,sha256=tDb6C5S3Q0JAKPsdw-84WQ6PxRADELZHr_BK8FDH-NA,5039
|
|
19
19
|
puzzle_solver/puzzles/light_up/light_up.py,sha256=iSA1rjZMFsnI0V0Nxivxox4qZkB7PvUrROSHXcoUXds,4541
|
|
20
|
+
puzzle_solver/puzzles/lits/lits.py,sha256=gYAcsuWucSer2JWs5eKOroiVmjfi-VzccmZvBIUIFks,12014
|
|
20
21
|
puzzle_solver/puzzles/magnets/magnets.py,sha256=-Wl49JD_PKeq735zQVMQ3XSQX6gdHiY-7PKw-Sh16jw,6474
|
|
21
22
|
puzzle_solver/puzzles/map/map.py,sha256=sxc57tapB8Tsgam-yoDitln1o-EB_SbIYvO6WEYy3us,2582
|
|
22
23
|
puzzle_solver/puzzles/minesweeper/minesweeper.py,sha256=LiQVOGkWCsc1WtX8CdPgL_WwAcaeUFuoi5_eqH8U2Og,5876
|
|
@@ -30,7 +31,7 @@ puzzle_solver/puzzles/singles/singles.py,sha256=kwMENfqQ-OP3YIz5baY6LRcvYCsNfhIm
|
|
|
30
31
|
puzzle_solver/puzzles/star_battle/star_battle.py,sha256=IX6w4H3sifN01kPPtrAVRCK0Nl_xlXXSHvJKw8K1EuE,3718
|
|
31
32
|
puzzle_solver/puzzles/star_battle/star_battle_shapeless.py,sha256=lj05V0Y3A3NjMo1boMkPIwBhMtm6SWydjgAMeCf5EIo,225
|
|
32
33
|
puzzle_solver/puzzles/stitches/stitches.py,sha256=iK8t02q43gH3FPbuIDn4dK0sbaOgZOnw8yHNRNvNuIU,6534
|
|
33
|
-
puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=
|
|
34
|
+
puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=VWHT-iYDaFsd37h9DE07EkeZ_dJMEfatXSByqC2vh04,8916
|
|
34
35
|
puzzle_solver/puzzles/sudoku/sudoku.py,sha256=M_pry7XyKKzlfCF5rFi02lyOrj5GWZzXnDAxmD3NXvI,3588
|
|
35
36
|
puzzle_solver/puzzles/tents/tents.py,sha256=iyVK2WXfIT5j_9qqlQg0WmwvixwXlZSsHGK3XA-KpII,6283
|
|
36
37
|
puzzle_solver/puzzles/thermometers/thermometers.py,sha256=nsvJZkm7G8FALT27bpaB0lv5E_AWawqmvapQI8QcYXw,4015
|
|
@@ -39,7 +40,7 @@ puzzle_solver/puzzles/tracks/tracks.py,sha256=0K1YZMHiRIMmFwoD_JxB2c_xB6GYV8spgN
|
|
|
39
40
|
puzzle_solver/puzzles/undead/undead.py,sha256=IrCUfzQFBem658P5KKqldG7vd2TugTHehcwseCarerM,6604
|
|
40
41
|
puzzle_solver/puzzles/unruly/unruly.py,sha256=sDF0oKT50G-NshyW2DYrvAgD9q9Ku9ANUyNhGSAu7cQ,3827
|
|
41
42
|
puzzle_solver/utils/visualizer.py,sha256=tsX1yEKwmwXBYuBJpx_oZGe2UUt1g5yV73G3UbtmvtE,6817
|
|
42
|
-
multi_puzzle_solver-0.9.
|
|
43
|
-
multi_puzzle_solver-0.9.
|
|
44
|
-
multi_puzzle_solver-0.9.
|
|
45
|
-
multi_puzzle_solver-0.9.
|
|
43
|
+
multi_puzzle_solver-0.9.13.dist-info/METADATA,sha256=x82w11xGJo2Rb4cGGQkmlbQNZ1i7MPGuhxCjOyaj61Y,143181
|
|
44
|
+
multi_puzzle_solver-0.9.13.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
45
|
+
multi_puzzle_solver-0.9.13.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
|
|
46
|
+
multi_puzzle_solver-0.9.13.dist-info/RECORD,,
|
puzzle_solver/__init__.py
CHANGED
|
@@ -16,7 +16,7 @@ from puzzle_solver.puzzles.map import map as map_solver
|
|
|
16
16
|
from puzzle_solver.puzzles.minesweeper import minesweeper as minesweeper_solver
|
|
17
17
|
from puzzle_solver.puzzles.mosaic import mosaic as mosaic_solver
|
|
18
18
|
from puzzle_solver.puzzles.nonograms import nonograms as nonograms_solver
|
|
19
|
-
from puzzle_solver.puzzles.
|
|
19
|
+
from puzzle_solver.puzzles.lits import lits as lits_solver
|
|
20
20
|
from puzzle_solver.puzzles.pearl import pearl as pearl_solver
|
|
21
21
|
from puzzle_solver.puzzles.range import range as range_solver
|
|
22
22
|
from puzzle_solver.puzzles.signpost import signpost as signpost_solver
|
|
@@ -34,4 +34,4 @@ from puzzle_solver.puzzles.unruly import unruly as unruly_solver
|
|
|
34
34
|
|
|
35
35
|
from puzzle_solver.puzzles.inertia.parse_map.parse_map import main as inertia_image_parser
|
|
36
36
|
|
|
37
|
-
__version__ = '0.9.
|
|
37
|
+
__version__ = '0.9.13'
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional, Union
|
|
5
|
+
|
|
6
|
+
from ortools.sat.python import cp_model
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
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
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class ShapeOnBoard:
|
|
105
|
+
is_active: cp_model.IntVar
|
|
106
|
+
shape: Shape
|
|
107
|
+
shape_id: int
|
|
108
|
+
body: set[Pos]
|
|
109
|
+
disallow_same_shape: set[Pos]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Board:
|
|
113
|
+
def __init__(self, board: np.array, polyomino_degrees: int = 4):
|
|
114
|
+
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
|
+
self.board = board
|
|
119
|
+
self.polyomino_degrees = polyomino_degrees
|
|
120
|
+
self.polyominoes = polyominoes(self.polyomino_degrees)
|
|
121
|
+
|
|
122
|
+
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)
|
|
126
|
+
|
|
127
|
+
self.model = cp_model.CpModel()
|
|
128
|
+
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
|
|
131
|
+
|
|
132
|
+
self.create_vars()
|
|
133
|
+
self.init_shapes_on_board()
|
|
134
|
+
self.add_all_constraints()
|
|
135
|
+
|
|
136
|
+
def create_vars(self):
|
|
137
|
+
for pos in get_all_pos(self.V, self.H):
|
|
138
|
+
self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
|
|
139
|
+
# print('base vars:', len(self.model_vars))
|
|
140
|
+
|
|
141
|
+
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))
|
|
161
|
+
|
|
162
|
+
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:
|
|
220
|
+
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)
|
|
227
|
+
def callback(single_res: SingleSolution):
|
|
228
|
+
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 ' '
|
|
232
|
+
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
|
|
@@ -1,212 +1,212 @@
|
|
|
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
|
-
from pathlib import Path
|
|
7
|
-
import numpy as np
|
|
8
|
-
cv = None
|
|
9
|
-
Image = None
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def extract_lines(bw):
|
|
13
|
-
# Create the images that will use to extract the horizontal and vertical lines
|
|
14
|
-
horizontal = np.copy(bw)
|
|
15
|
-
vertical = np.copy(bw)
|
|
16
|
-
|
|
17
|
-
cols = horizontal.shape[1]
|
|
18
|
-
horizontal_size = cols // 5
|
|
19
|
-
# Create structure element for extracting horizontal lines through morphology operations
|
|
20
|
-
horizontalStructure = cv.getStructuringElement(cv.MORPH_RECT, (horizontal_size, 1))
|
|
21
|
-
horizontal = cv.erode(horizontal, horizontalStructure)
|
|
22
|
-
horizontal = cv.dilate(horizontal, horizontalStructure)
|
|
23
|
-
horizontal_means = np.mean(horizontal, axis=1)
|
|
24
|
-
horizontal_cutoff = np.percentile(horizontal_means, 50)
|
|
25
|
-
# location where the horizontal lines are
|
|
26
|
-
horizontal_idx = np.where(horizontal_means > horizontal_cutoff)[0]
|
|
27
|
-
# print(f"horizontal_idx: {horizontal_idx}")
|
|
28
|
-
height = len(horizontal_idx)
|
|
29
|
-
# show_wait_destroy("horizontal", horizontal) # this has the horizontal lines
|
|
30
|
-
|
|
31
|
-
rows = vertical.shape[0]
|
|
32
|
-
verticalsize = rows // 5
|
|
33
|
-
# Create structure element for extracting vertical lines through morphology operations
|
|
34
|
-
verticalStructure = cv.getStructuringElement(cv.MORPH_RECT, (1, verticalsize))
|
|
35
|
-
vertical = cv.erode(vertical, verticalStructure)
|
|
36
|
-
vertical = cv.dilate(vertical, verticalStructure)
|
|
37
|
-
vertical_means = np.mean(vertical, axis=0)
|
|
38
|
-
vertical_cutoff = np.percentile(vertical_means, 50)
|
|
39
|
-
vertical_idx = np.where(vertical_means > vertical_cutoff)[0]
|
|
40
|
-
# print(f"vertical_idx: {vertical_idx}")
|
|
41
|
-
width = len(vertical_idx)
|
|
42
|
-
# print(f"height: {height}, width: {width}")
|
|
43
|
-
# print(f"vertical_means: {vertical_means}")
|
|
44
|
-
# show_wait_destroy("vertical", vertical) # this has the vertical lines
|
|
45
|
-
|
|
46
|
-
vertical = cv.bitwise_not(vertical)
|
|
47
|
-
# show_wait_destroy("vertical_bit", vertical)
|
|
48
|
-
|
|
49
|
-
return horizontal_idx, vertical_idx
|
|
50
|
-
|
|
51
|
-
def show_wait_destroy(winname, img):
|
|
52
|
-
cv.imshow(winname, img)
|
|
53
|
-
cv.moveWindow(winname, 500, 0)
|
|
54
|
-
cv.waitKey(0)
|
|
55
|
-
cv.destroyWindow(winname)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def mean_consecutives(arr: np.ndarray) -> np.ndarray:
|
|
59
|
-
"""if a sequence of values is consecutive, then average the values"""
|
|
60
|
-
sums = []
|
|
61
|
-
counts = []
|
|
62
|
-
for i in range(len(arr)):
|
|
63
|
-
if i == 0:
|
|
64
|
-
sums.append(arr[i])
|
|
65
|
-
counts.append(1)
|
|
66
|
-
elif arr[i] == arr[i-1] + 1:
|
|
67
|
-
sums[-1] += arr[i]
|
|
68
|
-
counts[-1] += 1
|
|
69
|
-
else:
|
|
70
|
-
sums.append(arr[i])
|
|
71
|
-
counts.append(1)
|
|
72
|
-
return np.array(sums) // np.array(counts)
|
|
73
|
-
|
|
74
|
-
def dfs(x, y, out, output, current_num):
|
|
75
|
-
if x < 0 or x >= out.shape[1] or y < 0 or y >= out.shape[0]:
|
|
76
|
-
return
|
|
77
|
-
if out[y, x] != ' ':
|
|
78
|
-
return
|
|
79
|
-
out[y, x] = current_num
|
|
80
|
-
if output['top'][y, x] == 0:
|
|
81
|
-
dfs(x, y-1, out, output, current_num)
|
|
82
|
-
if output['left'][y, x] == 0:
|
|
83
|
-
dfs(x-1, y, out, output, current_num)
|
|
84
|
-
if output['right'][y, x] == 0:
|
|
85
|
-
dfs(x+1, y, out, output, current_num)
|
|
86
|
-
if output['bottom'][y, x] == 0:
|
|
87
|
-
dfs(x, y+1, out, output, current_num)
|
|
88
|
-
|
|
89
|
-
def main(image):
|
|
90
|
-
global Image
|
|
91
|
-
global cv
|
|
92
|
-
import matplotlib.pyplot as plt
|
|
93
|
-
from PIL import Image as Image_module
|
|
94
|
-
import cv2 as cv_module
|
|
95
|
-
Image = Image_module
|
|
96
|
-
cv = cv_module
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
image_path = Path(image)
|
|
100
|
-
output_path = image_path.parent / (image_path.stem + '.json')
|
|
101
|
-
src = cv.imread(image, cv.IMREAD_COLOR)
|
|
102
|
-
assert src is not None, f'Error opening image: {image}'
|
|
103
|
-
if len(src.shape) != 2:
|
|
104
|
-
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
|
|
105
|
-
else:
|
|
106
|
-
gray = src
|
|
107
|
-
# now the image is in grayscale
|
|
108
|
-
|
|
109
|
-
# Apply adaptiveThreshold at the bitwise_not of gray, notice the ~ symbol
|
|
110
|
-
gray = cv.bitwise_not(gray)
|
|
111
|
-
bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
|
|
112
|
-
cv.THRESH_BINARY, 15, -2)
|
|
113
|
-
# show_wait_destroy("binary", bw)
|
|
114
|
-
|
|
115
|
-
# show_wait_destroy("src", src)
|
|
116
|
-
horizontal_idx, vertical_idx = extract_lines(bw)
|
|
117
|
-
horizontal_idx = mean_consecutives(horizontal_idx)
|
|
118
|
-
vertical_idx = mean_consecutives(vertical_idx)
|
|
119
|
-
height = len(horizontal_idx)
|
|
120
|
-
width = len(vertical_idx)
|
|
121
|
-
print(f"height: {height}, width: {width}")
|
|
122
|
-
print(f"horizontal_idx: {horizontal_idx}")
|
|
123
|
-
print(f"vertical_idx: {vertical_idx}")
|
|
124
|
-
arr = np.zeros((height - 1, width - 1), dtype=object)
|
|
125
|
-
output = {'top': arr.copy(), 'left': arr.copy(), 'right': arr.copy(), 'bottom': arr.copy()}
|
|
126
|
-
target = 200_000
|
|
127
|
-
hists = {'top': {}, 'left': {}, 'right': {}, 'bottom': {}}
|
|
128
|
-
for j in range(height - 1):
|
|
129
|
-
for i in range(width - 1):
|
|
130
|
-
hidx1, hidx2 = horizontal_idx[j], horizontal_idx[j+1]
|
|
131
|
-
vidx1, vidx2 = vertical_idx[i], vertical_idx[i+1]
|
|
132
|
-
hidx1 = max(0, hidx1 - 2)
|
|
133
|
-
hidx2 = min(src.shape[0], hidx2 + 4)
|
|
134
|
-
vidx1 = max(0, vidx1 - 2)
|
|
135
|
-
vidx2 = min(src.shape[1], vidx2 + 4)
|
|
136
|
-
cell = src[hidx1:hidx2, vidx1:vidx2]
|
|
137
|
-
mid_x = cell.shape[1] // 2
|
|
138
|
-
mid_y = cell.shape[0] // 2
|
|
139
|
-
# show_wait_destroy(f"cell_{i}_{j}", cell)
|
|
140
|
-
cell = cv.bitwise_not(cell) # invert colors
|
|
141
|
-
top = cell[0:10, mid_y-5:mid_y+5]
|
|
142
|
-
hists['top'][j, i] = np.sum(top)
|
|
143
|
-
left = cell[mid_x-5:mid_x+5, 0:10]
|
|
144
|
-
hists['left'][j, i] = np.sum(left)
|
|
145
|
-
right = cell[mid_x-5:mid_x+5, -10:]
|
|
146
|
-
hists['right'][j, i] = np.sum(right)
|
|
147
|
-
bottom = cell[-10:, mid_y-5:mid_y+5]
|
|
148
|
-
hists['bottom'][j, i] = np.sum(bottom)
|
|
149
|
-
|
|
150
|
-
fig, axs = plt.subplots(2, 2)
|
|
151
|
-
axs[0, 0].hist(list(hists['top'].values()), bins=100)
|
|
152
|
-
axs[0, 0].set_title('Top')
|
|
153
|
-
axs[0, 1].hist(list(hists['left'].values()), bins=100)
|
|
154
|
-
axs[0, 1].set_title('Left')
|
|
155
|
-
axs[1, 0].hist(list(hists['right'].values()), bins=100)
|
|
156
|
-
axs[1, 0].set_title('Right')
|
|
157
|
-
axs[1, 1].hist(list(hists['bottom'].values()), bins=100)
|
|
158
|
-
axs[1, 1].set_title('Bottom')
|
|
159
|
-
target_top = np.mean(list(hists['top'].values()))
|
|
160
|
-
target_left = np.mean(list(hists['left'].values()))
|
|
161
|
-
target_right = np.mean(list(hists['right'].values()))
|
|
162
|
-
target_bottom = np.mean(list(hists['bottom'].values()))
|
|
163
|
-
axs[0, 0].axvline(target_top, color='red')
|
|
164
|
-
axs[0, 1].axvline(target_left, color='red')
|
|
165
|
-
axs[1, 0].axvline(target_right, color='red')
|
|
166
|
-
axs[1, 1].axvline(target_bottom, color='red')
|
|
167
|
-
# plt.show()
|
|
168
|
-
# 1/0
|
|
169
|
-
print(f"target_top: {target_top}, target_left: {target_left}, target_right: {target_right}, target_bottom: {target_bottom}")
|
|
170
|
-
for j in range(height - 1):
|
|
171
|
-
for i in range(width - 1):
|
|
172
|
-
if hists['top'][j, i] > target_top:
|
|
173
|
-
output['top'][j, i] = 1
|
|
174
|
-
if hists['left'][j, i] > target_left:
|
|
175
|
-
output['left'][j, i] = 1
|
|
176
|
-
if hists['right'][j, i] > target_right:
|
|
177
|
-
output['right'][j, i] = 1
|
|
178
|
-
if hists['bottom'][j, i] > target_bottom:
|
|
179
|
-
output['bottom'][j, i] = 1
|
|
180
|
-
print(f"cell_{j}_{i}", end=': ')
|
|
181
|
-
print('T' if output['top'][j, i] else '', end='')
|
|
182
|
-
print('L' if output['left'][j, i] else '', end='')
|
|
183
|
-
print('R' if output['right'][j, i] else '', end='')
|
|
184
|
-
print('B' if output['bottom'][j, i] else '', end='')
|
|
185
|
-
print(' Sums: ', hists['top'][j, i], hists['left'][j, i], hists['right'][j, i], hists['bottom'][j, i])
|
|
186
|
-
|
|
187
|
-
current_count = 0
|
|
188
|
-
out = np.full_like(output['top'], ' ', dtype='U2')
|
|
189
|
-
for j in range(out.shape[0]):
|
|
190
|
-
for i in range(out.shape[1]):
|
|
191
|
-
if out[j, i] == ' ':
|
|
192
|
-
dfs(i, j, out, output, str(current_count).zfill(2))
|
|
193
|
-
current_count += 1
|
|
194
|
-
|
|
195
|
-
with open(output_path, 'w') as f:
|
|
196
|
-
f.write('[\n')
|
|
197
|
-
for i, row in enumerate(out):
|
|
198
|
-
f.write(' ' + str(row.tolist()).replace("'", '"'))
|
|
199
|
-
if i != len(out) - 1:
|
|
200
|
-
f.write(',')
|
|
201
|
-
f.write('\n')
|
|
202
|
-
f.write(']')
|
|
203
|
-
print('output json: ', output_path)
|
|
204
|
-
|
|
205
|
-
if __name__ == '__main__':
|
|
206
|
-
# to run this script and visualize the output, in the root run:
|
|
207
|
-
# python .\src\puzzle_solver\puzzles\stitches\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
|
|
208
|
-
# main(Path(__file__).parent / 'input_output' / 'MTM6OSw4MjEsNDAx.png')
|
|
209
|
-
# main(Path(__file__).parent / 'input_output' / 'weekly_oct_3rd_2025.png')
|
|
210
|
-
# main(Path(__file__).parent / 'input_output' / 'star_battle_67f73ff90cd8cdb4b3e30f56f5261f4968f5dac940bc6.png')
|
|
211
|
-
# main(Path(__file__).parent / 'input_output' / 'LITS_MDoxNzksNzY3.png')
|
|
212
|
-
main(Path(__file__).parent / 'input_output' / 'lits_OTo3LDMwNiwwMTU=.png')
|
|
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
|
+
from pathlib import Path
|
|
7
|
+
import numpy as np
|
|
8
|
+
cv = None
|
|
9
|
+
Image = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def extract_lines(bw):
|
|
13
|
+
# Create the images that will use to extract the horizontal and vertical lines
|
|
14
|
+
horizontal = np.copy(bw)
|
|
15
|
+
vertical = np.copy(bw)
|
|
16
|
+
|
|
17
|
+
cols = horizontal.shape[1]
|
|
18
|
+
horizontal_size = cols // 5
|
|
19
|
+
# Create structure element for extracting horizontal lines through morphology operations
|
|
20
|
+
horizontalStructure = cv.getStructuringElement(cv.MORPH_RECT, (horizontal_size, 1))
|
|
21
|
+
horizontal = cv.erode(horizontal, horizontalStructure)
|
|
22
|
+
horizontal = cv.dilate(horizontal, horizontalStructure)
|
|
23
|
+
horizontal_means = np.mean(horizontal, axis=1)
|
|
24
|
+
horizontal_cutoff = np.percentile(horizontal_means, 50)
|
|
25
|
+
# location where the horizontal lines are
|
|
26
|
+
horizontal_idx = np.where(horizontal_means > horizontal_cutoff)[0]
|
|
27
|
+
# print(f"horizontal_idx: {horizontal_idx}")
|
|
28
|
+
height = len(horizontal_idx)
|
|
29
|
+
# show_wait_destroy("horizontal", horizontal) # this has the horizontal lines
|
|
30
|
+
|
|
31
|
+
rows = vertical.shape[0]
|
|
32
|
+
verticalsize = rows // 5
|
|
33
|
+
# Create structure element for extracting vertical lines through morphology operations
|
|
34
|
+
verticalStructure = cv.getStructuringElement(cv.MORPH_RECT, (1, verticalsize))
|
|
35
|
+
vertical = cv.erode(vertical, verticalStructure)
|
|
36
|
+
vertical = cv.dilate(vertical, verticalStructure)
|
|
37
|
+
vertical_means = np.mean(vertical, axis=0)
|
|
38
|
+
vertical_cutoff = np.percentile(vertical_means, 50)
|
|
39
|
+
vertical_idx = np.where(vertical_means > vertical_cutoff)[0]
|
|
40
|
+
# print(f"vertical_idx: {vertical_idx}")
|
|
41
|
+
width = len(vertical_idx)
|
|
42
|
+
# print(f"height: {height}, width: {width}")
|
|
43
|
+
# print(f"vertical_means: {vertical_means}")
|
|
44
|
+
# show_wait_destroy("vertical", vertical) # this has the vertical lines
|
|
45
|
+
|
|
46
|
+
vertical = cv.bitwise_not(vertical)
|
|
47
|
+
# show_wait_destroy("vertical_bit", vertical)
|
|
48
|
+
|
|
49
|
+
return horizontal_idx, vertical_idx
|
|
50
|
+
|
|
51
|
+
def show_wait_destroy(winname, img):
|
|
52
|
+
cv.imshow(winname, img)
|
|
53
|
+
cv.moveWindow(winname, 500, 0)
|
|
54
|
+
cv.waitKey(0)
|
|
55
|
+
cv.destroyWindow(winname)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def mean_consecutives(arr: np.ndarray) -> np.ndarray:
|
|
59
|
+
"""if a sequence of values is consecutive, then average the values"""
|
|
60
|
+
sums = []
|
|
61
|
+
counts = []
|
|
62
|
+
for i in range(len(arr)):
|
|
63
|
+
if i == 0:
|
|
64
|
+
sums.append(arr[i])
|
|
65
|
+
counts.append(1)
|
|
66
|
+
elif arr[i] == arr[i-1] + 1:
|
|
67
|
+
sums[-1] += arr[i]
|
|
68
|
+
counts[-1] += 1
|
|
69
|
+
else:
|
|
70
|
+
sums.append(arr[i])
|
|
71
|
+
counts.append(1)
|
|
72
|
+
return np.array(sums) // np.array(counts)
|
|
73
|
+
|
|
74
|
+
def dfs(x, y, out, output, current_num):
|
|
75
|
+
if x < 0 or x >= out.shape[1] or y < 0 or y >= out.shape[0]:
|
|
76
|
+
return
|
|
77
|
+
if out[y, x] != ' ':
|
|
78
|
+
return
|
|
79
|
+
out[y, x] = current_num
|
|
80
|
+
if output['top'][y, x] == 0:
|
|
81
|
+
dfs(x, y-1, out, output, current_num)
|
|
82
|
+
if output['left'][y, x] == 0:
|
|
83
|
+
dfs(x-1, y, out, output, current_num)
|
|
84
|
+
if output['right'][y, x] == 0:
|
|
85
|
+
dfs(x+1, y, out, output, current_num)
|
|
86
|
+
if output['bottom'][y, x] == 0:
|
|
87
|
+
dfs(x, y+1, out, output, current_num)
|
|
88
|
+
|
|
89
|
+
def main(image):
|
|
90
|
+
global Image
|
|
91
|
+
global cv
|
|
92
|
+
import matplotlib.pyplot as plt
|
|
93
|
+
from PIL import Image as Image_module
|
|
94
|
+
import cv2 as cv_module
|
|
95
|
+
Image = Image_module
|
|
96
|
+
cv = cv_module
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
image_path = Path(image)
|
|
100
|
+
output_path = image_path.parent / (image_path.stem + '.json')
|
|
101
|
+
src = cv.imread(image, cv.IMREAD_COLOR)
|
|
102
|
+
assert src is not None, f'Error opening image: {image}'
|
|
103
|
+
if len(src.shape) != 2:
|
|
104
|
+
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
|
|
105
|
+
else:
|
|
106
|
+
gray = src
|
|
107
|
+
# now the image is in grayscale
|
|
108
|
+
|
|
109
|
+
# Apply adaptiveThreshold at the bitwise_not of gray, notice the ~ symbol
|
|
110
|
+
gray = cv.bitwise_not(gray)
|
|
111
|
+
bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
|
|
112
|
+
cv.THRESH_BINARY, 15, -2)
|
|
113
|
+
# show_wait_destroy("binary", bw)
|
|
114
|
+
|
|
115
|
+
# show_wait_destroy("src", src)
|
|
116
|
+
horizontal_idx, vertical_idx = extract_lines(bw)
|
|
117
|
+
horizontal_idx = mean_consecutives(horizontal_idx)
|
|
118
|
+
vertical_idx = mean_consecutives(vertical_idx)
|
|
119
|
+
height = len(horizontal_idx)
|
|
120
|
+
width = len(vertical_idx)
|
|
121
|
+
print(f"height: {height}, width: {width}")
|
|
122
|
+
print(f"horizontal_idx: {horizontal_idx}")
|
|
123
|
+
print(f"vertical_idx: {vertical_idx}")
|
|
124
|
+
arr = np.zeros((height - 1, width - 1), dtype=object)
|
|
125
|
+
output = {'top': arr.copy(), 'left': arr.copy(), 'right': arr.copy(), 'bottom': arr.copy()}
|
|
126
|
+
target = 200_000
|
|
127
|
+
hists = {'top': {}, 'left': {}, 'right': {}, 'bottom': {}}
|
|
128
|
+
for j in range(height - 1):
|
|
129
|
+
for i in range(width - 1):
|
|
130
|
+
hidx1, hidx2 = horizontal_idx[j], horizontal_idx[j+1]
|
|
131
|
+
vidx1, vidx2 = vertical_idx[i], vertical_idx[i+1]
|
|
132
|
+
hidx1 = max(0, hidx1 - 2)
|
|
133
|
+
hidx2 = min(src.shape[0], hidx2 + 4)
|
|
134
|
+
vidx1 = max(0, vidx1 - 2)
|
|
135
|
+
vidx2 = min(src.shape[1], vidx2 + 4)
|
|
136
|
+
cell = src[hidx1:hidx2, vidx1:vidx2]
|
|
137
|
+
mid_x = cell.shape[1] // 2
|
|
138
|
+
mid_y = cell.shape[0] // 2
|
|
139
|
+
# show_wait_destroy(f"cell_{i}_{j}", cell)
|
|
140
|
+
cell = cv.bitwise_not(cell) # invert colors
|
|
141
|
+
top = cell[0:10, mid_y-5:mid_y+5]
|
|
142
|
+
hists['top'][j, i] = np.sum(top)
|
|
143
|
+
left = cell[mid_x-5:mid_x+5, 0:10]
|
|
144
|
+
hists['left'][j, i] = np.sum(left)
|
|
145
|
+
right = cell[mid_x-5:mid_x+5, -10:]
|
|
146
|
+
hists['right'][j, i] = np.sum(right)
|
|
147
|
+
bottom = cell[-10:, mid_y-5:mid_y+5]
|
|
148
|
+
hists['bottom'][j, i] = np.sum(bottom)
|
|
149
|
+
|
|
150
|
+
fig, axs = plt.subplots(2, 2)
|
|
151
|
+
axs[0, 0].hist(list(hists['top'].values()), bins=100)
|
|
152
|
+
axs[0, 0].set_title('Top')
|
|
153
|
+
axs[0, 1].hist(list(hists['left'].values()), bins=100)
|
|
154
|
+
axs[0, 1].set_title('Left')
|
|
155
|
+
axs[1, 0].hist(list(hists['right'].values()), bins=100)
|
|
156
|
+
axs[1, 0].set_title('Right')
|
|
157
|
+
axs[1, 1].hist(list(hists['bottom'].values()), bins=100)
|
|
158
|
+
axs[1, 1].set_title('Bottom')
|
|
159
|
+
target_top = np.mean(list(hists['top'].values()))
|
|
160
|
+
target_left = np.mean(list(hists['left'].values()))
|
|
161
|
+
target_right = np.mean(list(hists['right'].values()))
|
|
162
|
+
target_bottom = np.mean(list(hists['bottom'].values()))
|
|
163
|
+
axs[0, 0].axvline(target_top, color='red')
|
|
164
|
+
axs[0, 1].axvline(target_left, color='red')
|
|
165
|
+
axs[1, 0].axvline(target_right, color='red')
|
|
166
|
+
axs[1, 1].axvline(target_bottom, color='red')
|
|
167
|
+
# plt.show()
|
|
168
|
+
# 1/0
|
|
169
|
+
print(f"target_top: {target_top}, target_left: {target_left}, target_right: {target_right}, target_bottom: {target_bottom}")
|
|
170
|
+
for j in range(height - 1):
|
|
171
|
+
for i in range(width - 1):
|
|
172
|
+
if hists['top'][j, i] > target_top:
|
|
173
|
+
output['top'][j, i] = 1
|
|
174
|
+
if hists['left'][j, i] > target_left:
|
|
175
|
+
output['left'][j, i] = 1
|
|
176
|
+
if hists['right'][j, i] > target_right:
|
|
177
|
+
output['right'][j, i] = 1
|
|
178
|
+
if hists['bottom'][j, i] > target_bottom:
|
|
179
|
+
output['bottom'][j, i] = 1
|
|
180
|
+
print(f"cell_{j}_{i}", end=': ')
|
|
181
|
+
print('T' if output['top'][j, i] else '', end='')
|
|
182
|
+
print('L' if output['left'][j, i] else '', end='')
|
|
183
|
+
print('R' if output['right'][j, i] else '', end='')
|
|
184
|
+
print('B' if output['bottom'][j, i] else '', end='')
|
|
185
|
+
print(' Sums: ', hists['top'][j, i], hists['left'][j, i], hists['right'][j, i], hists['bottom'][j, i])
|
|
186
|
+
|
|
187
|
+
current_count = 0
|
|
188
|
+
out = np.full_like(output['top'], ' ', dtype='U2')
|
|
189
|
+
for j in range(out.shape[0]):
|
|
190
|
+
for i in range(out.shape[1]):
|
|
191
|
+
if out[j, i] == ' ':
|
|
192
|
+
dfs(i, j, out, output, str(current_count).zfill(2))
|
|
193
|
+
current_count += 1
|
|
194
|
+
|
|
195
|
+
with open(output_path, 'w') as f:
|
|
196
|
+
f.write('[\n')
|
|
197
|
+
for i, row in enumerate(out):
|
|
198
|
+
f.write(' ' + str(row.tolist()).replace("'", '"'))
|
|
199
|
+
if i != len(out) - 1:
|
|
200
|
+
f.write(',')
|
|
201
|
+
f.write('\n')
|
|
202
|
+
f.write(']')
|
|
203
|
+
print('output json: ', output_path)
|
|
204
|
+
|
|
205
|
+
if __name__ == '__main__':
|
|
206
|
+
# to run this script and visualize the output, in the root run:
|
|
207
|
+
# python .\src\puzzle_solver\puzzles\stitches\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
|
|
208
|
+
# main(Path(__file__).parent / 'input_output' / 'MTM6OSw4MjEsNDAx.png')
|
|
209
|
+
# main(Path(__file__).parent / 'input_output' / 'weekly_oct_3rd_2025.png')
|
|
210
|
+
# main(Path(__file__).parent / 'input_output' / 'star_battle_67f73ff90cd8cdb4b3e30f56f5261f4968f5dac940bc6.png')
|
|
211
|
+
# main(Path(__file__).parent / 'input_output' / 'LITS_MDoxNzksNzY3.png')
|
|
212
|
+
main(Path(__file__).parent / 'input_output' / 'lits_OTo3LDMwNiwwMTU=.png')
|
|
File without changes
|
|
File without changes
|