multi-puzzle-solver 0.9.20__py3-none-any.whl → 0.9.22__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.20.dist-info → multi_puzzle_solver-0.9.22.dist-info}/METADATA +122 -3
- {multi_puzzle_solver-0.9.20.dist-info → multi_puzzle_solver-0.9.22.dist-info}/RECORD +8 -6
- puzzle_solver/__init__.py +2 -1
- puzzle_solver/puzzles/sudoku/sudoku.py +136 -23
- puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +169 -0
- puzzle_solver/puzzles/yin_yang/yin_yang.py +110 -0
- {multi_puzzle_solver-0.9.20.dist-info → multi_puzzle_solver-0.9.22.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-0.9.20.dist-info → multi_puzzle_solver-0.9.22.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.22
|
|
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
|
|
@@ -309,6 +309,11 @@ These are all the puzzles that are implemented in this repo. <br> Click on any o
|
|
|
309
309
|
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/slitherlink_solved.png" alt="Slitherlink" width="140">
|
|
310
310
|
</a>
|
|
311
311
|
</td>
|
|
312
|
+
<td align="center">
|
|
313
|
+
<a href="#yin-yang-puzzle-type-40"><b>Yin-Yang</b><br><br>
|
|
314
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/yin_yang_solved.png" alt="Yin-Yang" width="140">
|
|
315
|
+
</a>
|
|
316
|
+
</td>
|
|
312
317
|
</tr>
|
|
313
318
|
</table>
|
|
314
319
|
|
|
@@ -363,6 +368,7 @@ These are all the puzzles that are implemented in this repo. <br> Click on any o
|
|
|
363
368
|
- [Unequal (Puzzle Type #37)](#unequal-puzzle-type-37)
|
|
364
369
|
- [Norinori (Puzzle Type #38)](#norinori-puzzle-type-38)
|
|
365
370
|
- [Slitherlink (Puzzle Type #39)](#slitherlink-puzzle-type-39)
|
|
371
|
+
- [Yin-Yang (Puzzle Type #40)](#yin-yang-puzzle-type-40)
|
|
366
372
|
- [Why SAT / CP-SAT?](#why-sat--cp-sat)
|
|
367
373
|
- [Testing](#testing)
|
|
368
374
|
- [Contributing](#contributing)
|
|
@@ -488,6 +494,13 @@ You are given some of the numbers as clues; your aim is to place the rest of the
|
|
|
488
494
|
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/sudoku_unsolved.png" alt="Sudoku unsolved" width="500">
|
|
489
495
|
|
|
490
496
|
Code to utilize this package and solve the puzzle:
|
|
497
|
+
|
|
498
|
+
Note:
|
|
499
|
+
|
|
500
|
+
- The solver also supports solving the ["Sandwich" sudoku variant](https://dkmgames.com/SandwichSudoku/) through the optional parameter ``sandwich={'side': [...], 'bottom': [...] }``。
|
|
501
|
+
|
|
502
|
+
- The solver also supports solving the ["Sudoku-X" variant](https://www.sudopedia.org/wiki/Sudoku-X) through the optional parameter ``unique_diagonal=True``。
|
|
503
|
+
|
|
491
504
|
```python
|
|
492
505
|
import numpy as np
|
|
493
506
|
from puzzle_solver import sudoku_solver as solver
|
|
@@ -2220,6 +2233,7 @@ The numbers outside the grid show the number of filled cells horizontally and ve
|
|
|
2220
2233
|
Code to utilize this package and solve the puzzle:
|
|
2221
2234
|
|
|
2222
2235
|
```python
|
|
2236
|
+
import numpy as np
|
|
2223
2237
|
from puzzle_solver import thermometers_solver as solver
|
|
2224
2238
|
board = np.array([
|
|
2225
2239
|
['R', 'R', 'D', 'R', 'D', 'R', 'X', 'D', 'L', 'X', 'L', 'L', 'L', 'L', 'L'],
|
|
@@ -2299,6 +2313,7 @@ The numbers outside the grid show the number of filled cells horizontally and ve
|
|
|
2299
2313
|
Code to utilize this package and solve the puzzle:
|
|
2300
2314
|
|
|
2301
2315
|
```python
|
|
2316
|
+
import numpy as np
|
|
2302
2317
|
from puzzle_solver import aquarium_solver as solver
|
|
2303
2318
|
board = np.array([
|
|
2304
2319
|
['01', '01', '01', '01', '02', '02', '02', '03', '03', '03', '03', '04', '05', '05', '05'],
|
|
@@ -2376,6 +2391,7 @@ Time taken: 0.02 seconds
|
|
|
2376
2391
|
Code to utilize this package and solve the puzzle:
|
|
2377
2392
|
|
|
2378
2393
|
```python
|
|
2394
|
+
import numpy as np
|
|
2379
2395
|
from puzzle_solver import stitches_solver as solver
|
|
2380
2396
|
board = np.array([
|
|
2381
2397
|
["00", "00", "00", "00", "00", "01", "01", "01", "01", "01", "01", "01", "01", "02", "02"],
|
|
@@ -2456,6 +2472,7 @@ Time taken: 0.01 seconds
|
|
|
2456
2472
|
Code to utilize this package and solve the puzzle:
|
|
2457
2473
|
|
|
2458
2474
|
```python
|
|
2475
|
+
import numpy as np
|
|
2459
2476
|
from puzzle_solver import battleships_solver as solver
|
|
2460
2477
|
board = np.array([
|
|
2461
2478
|
[' ', ' ', ' ', ' ', ' ', 'S', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
|
|
@@ -2538,6 +2555,7 @@ The goal is to make some of the cells black in such a way that:
|
|
|
2538
2555
|
Code to utilize this package and solve the puzzle:
|
|
2539
2556
|
|
|
2540
2557
|
```python
|
|
2558
|
+
import numpy as np
|
|
2541
2559
|
from puzzle_solver import kakurasu_solver as solver
|
|
2542
2560
|
side = np.array([27, 6, 1, 12, 37, 37, 11, 4, 29, 23, 66, 55])
|
|
2543
2561
|
bottom = np.array([22, 1, 25, 36, 10, 22, 25, 35, 32, 28, 45, 45])
|
|
@@ -2598,6 +2616,7 @@ Code to utilize this package and solve the puzzle:
|
|
|
2598
2616
|
Note that as usual the board is an id of the shape (id is meaningless, just used to identify one shape), and the `star_count` parameter depenends on the puzzle type.
|
|
2599
2617
|
|
|
2600
2618
|
```python
|
|
2619
|
+
import numpy as np
|
|
2601
2620
|
from puzzle_solver import star_battle_solver as solver
|
|
2602
2621
|
board = np.array([
|
|
2603
2622
|
['00', '00', '00', '00', '00', '01', '01', '01', '01', '01', '01', '01', '01', '01', '02', '02', '02', '03', '03', '03', '03', '03', '03', '03', '03'],
|
|
@@ -2697,7 +2716,8 @@ Code to utilize this package and solve the puzzle:
|
|
|
2697
2716
|
The `star_count` parameter depenends on the puzzle type.
|
|
2698
2717
|
|
|
2699
2718
|
```python
|
|
2700
|
-
|
|
2719
|
+
import numpy as np
|
|
2720
|
+
from puzzle_solver import star_battle_shapeless as solver
|
|
2701
2721
|
board = np.array([
|
|
2702
2722
|
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
|
|
2703
2723
|
['B', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
|
|
@@ -2710,7 +2730,7 @@ board = np.array([
|
|
|
2710
2730
|
['B', 'B', ' ', ' ', ' ', ' ', 'B', 'B', 'B', ' '],
|
|
2711
2731
|
['B', ' ', ' ', ' ', 'B', ' ', ' ', ' ', ' ', ' '],
|
|
2712
2732
|
])
|
|
2713
|
-
binst =
|
|
2733
|
+
binst = solver.Board(board=board, star_count=2)
|
|
2714
2734
|
solutions = binst.solve_and_print()
|
|
2715
2735
|
```
|
|
2716
2736
|
|
|
@@ -2767,6 +2787,8 @@ Note: The solver is capable of solving variations where the puzzle pieces the ma
|
|
|
2767
2787
|
Code to utilize this package and solve the puzzle:
|
|
2768
2788
|
|
|
2769
2789
|
```python
|
|
2790
|
+
import numpy as np
|
|
2791
|
+
from puzzle_solver import lits_solver as solver
|
|
2770
2792
|
board = np.array([
|
|
2771
2793
|
['00', '00', '00', '01', '01', '02', '02', '02', '03', '03', '03', '04', '04', '05', '06', '07', '07', '08', '08', '09'],
|
|
2772
2794
|
['00', '00', '00', '00', '01', '02', '03', '03', '03', '10', '04', '04', '05', '05', '06', '07', '08', '08', '09', '09'],
|
|
@@ -2963,6 +2985,7 @@ Code to utilize this package and solve the puzzle:
|
|
|
2963
2985
|
Note: The number are arbitrary and simply number each galaxy as an integer.
|
|
2964
2986
|
|
|
2965
2987
|
```python
|
|
2988
|
+
import numpy as np
|
|
2966
2989
|
from puzzle_solver import galaxies_solver as solver
|
|
2967
2990
|
galaxies = np.array([
|
|
2968
2991
|
[' ', ' ', '00', ' ', ' ', '01', '01', '02', '02', '03', '03', ' ', '04', '04', ' '],
|
|
@@ -3047,6 +3070,7 @@ Code to utilize this package and solve the puzzle:
|
|
|
3047
3070
|
Note: For an NxM board you need an (N+1)x(M+1) array because the puzzle is to solve for the cells while the input is the values at the corners (there's always one more corner than cells in each dimension).
|
|
3048
3071
|
|
|
3049
3072
|
```python
|
|
3073
|
+
import numpy as np
|
|
3050
3074
|
from puzzle_solver import slant_solver as solver
|
|
3051
3075
|
board = np.array([
|
|
3052
3076
|
[' ', ' ', '1', ' ', '1', ' ', '1', ' ', '1', ' ', ' ', ' ', ' '],
|
|
@@ -3131,6 +3155,7 @@ Code to utilize this package and solve the puzzle:
|
|
|
3131
3155
|
Note: For an NxM board you need an (2N-1)x(2M-1) array because the puzzle involves input in between the cells. Each numbered cell has neighbors horizontally to represent ">", "<", and "|" (where "|" represents adjacency) and vertically to represent "∧", "∨" and "-" (where "-" represents adjacency). The "X" in the input are unused corners that shouldnt contain anything (just a corner). The numbers should never appear orthogonal to an "X", only diagonally to it. vice-versa for the comparison operators.
|
|
3132
3156
|
|
|
3133
3157
|
```python
|
|
3158
|
+
import numpy as np
|
|
3134
3159
|
from puzzle_solver import unequal_solver as solver
|
|
3135
3160
|
board = np.array([
|
|
3136
3161
|
[' ', ' ', ' ', ' ', '9', ' ', '1', ' ', '7', '>', ' ', '>', ' ', ' ', ' ', ' ', ' ', '>', ' '],
|
|
@@ -3210,6 +3235,7 @@ You have to shade some of the cells in such a way that:
|
|
|
3210
3235
|
Code to utilize this package and solve the puzzle:
|
|
3211
3236
|
|
|
3212
3237
|
```python
|
|
3238
|
+
import numpy as np
|
|
3213
3239
|
from puzzle_solver import norinori_solver as solver
|
|
3214
3240
|
board = np.array([
|
|
3215
3241
|
['00', '01', '01', '01', '01', '02', '03', '03', '04', '04', '04', '05', '05', '05', '06', '07', '08', '08', '09', '09'],
|
|
@@ -3299,6 +3325,7 @@ A line forming a single loop without crossings or branches means that every corn
|
|
|
3299
3325
|
Code to utilize this package and solve the puzzle:
|
|
3300
3326
|
|
|
3301
3327
|
```python
|
|
3328
|
+
import numpy as np
|
|
3302
3329
|
from puzzle_solver import slitherlink_solver as solver
|
|
3303
3330
|
board = np.array([
|
|
3304
3331
|
['3', ' ', ' ', '2', ' ', ' ', ' ', ' ', ' ', '3', ' ', ' ', ' ', ' ', ' ', '3', ' ', ' ', '1', ' '],
|
|
@@ -3385,6 +3412,97 @@ Applying the solution to the puzzle visually:
|
|
|
3385
3412
|
|
|
3386
3413
|
---
|
|
3387
3414
|
|
|
3415
|
+
|
|
3416
|
+
## Yin-Yang (Puzzle Type #40)
|
|
3417
|
+
|
|
3418
|
+
* [**Play online**](https://www.puzzle-yin-yang.com)
|
|
3419
|
+
|
|
3420
|
+
* [**Solver Code**][40]
|
|
3421
|
+
|
|
3422
|
+
<details>
|
|
3423
|
+
<summary><strong>Rules</strong></summary>
|
|
3424
|
+
|
|
3425
|
+
Yin-Yang is played on a rectangular grid with no standard size. Some cells start out filled with black or white. The rest of the cells are empty. The goal is to color all cells in such a way that:
|
|
3426
|
+
1. All black cells should be connected orthogonally in a single group.
|
|
3427
|
+
2. All white cells should be connected orthogonally in a single group.
|
|
3428
|
+
3. 2x2 areas of the same color are not allowed.
|
|
3429
|
+
|
|
3430
|
+
</details>
|
|
3431
|
+
|
|
3432
|
+
**Unsolved puzzle**
|
|
3433
|
+
|
|
3434
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/yin_yang_unsolved.png" alt="Yin-Yang unsolved" width="500">
|
|
3435
|
+
|
|
3436
|
+
Code to utilize this package and solve the puzzle:
|
|
3437
|
+
|
|
3438
|
+
```python
|
|
3439
|
+
import numpy as np
|
|
3440
|
+
from puzzle_solver import yin_yang_solver as solver
|
|
3441
|
+
board = np.array([
|
|
3442
|
+
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
|
|
3443
|
+
[' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', 'B', ' ', ' ', 'W', ' ', 'W', ' ', ' ', 'W', ' ', ' '],
|
|
3444
|
+
[' ', ' ', 'B', ' ', 'B', ' ', 'W', ' ', ' ', 'W', 'B', ' ', ' ', ' ', ' ', 'W', ' ', 'W', ' ', ' '],
|
|
3445
|
+
[' ', ' ', ' ', ' ', ' ', 'W', ' ', 'W', ' ', 'B', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'W', ' ', ' '],
|
|
3446
|
+
[' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' '],
|
|
3447
|
+
[' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', 'W', 'B', ' ', ' ', ' ', ' ', ' ', 'W', ' ', 'W', ' ', ' '],
|
|
3448
|
+
[' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', 'B', ' ', 'B', ' ', 'B', 'W', ' ', 'W', ' ', ' '],
|
|
3449
|
+
[' ', ' ', 'B', 'W', 'W', ' ', 'W', ' ', ' ', ' ', 'B', ' ', 'B', ' ', 'B', ' ', ' ', ' ', ' ', ' '],
|
|
3450
|
+
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'B', ' ', 'B', ' ', 'B', ' ', 'B', ' ', 'B', ' ', 'B', ' '],
|
|
3451
|
+
[' ', 'W', ' ', 'W', ' ', ' ', 'W', ' ', ' ', ' ', 'B', ' ', 'B', ' ', 'B', ' ', 'B', ' ', 'B', ' '],
|
|
3452
|
+
[' ', ' ', ' ', ' ', 'W', 'B', ' ', ' ', ' ', 'B', ' ', ' ', 'B', ' ', 'B', ' ', 'B', ' ', 'B', ' '],
|
|
3453
|
+
[' ', ' ', 'B', ' ', ' ', ' ', 'B', 'B', ' ', 'W', 'B', ' ', 'B', ' ', 'B', ' ', ' ', 'B', ' ', ' '],
|
|
3454
|
+
[' ', 'W', 'W', 'W', ' ', 'B', ' ', 'W', ' ', ' ', 'B', ' ', 'B', ' ', 'B', ' ', ' ', ' ', 'B', ' '],
|
|
3455
|
+
[' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'B', ' ', 'B', ' ', ' ', 'B', ' ', 'B', ' ', 'B', ' '],
|
|
3456
|
+
[' ', 'W', ' ', 'B', 'W', 'B', ' ', 'W', ' ', ' ', ' ', ' ', 'B', 'B', ' ', ' ', 'B', ' ', 'B', ' '],
|
|
3457
|
+
[' ', ' ', ' ', ' ', 'W', ' ', ' ', 'B', 'B', 'B', 'B', 'B', ' ', ' ', ' ', 'B', ' ', ' ', 'B', ' '],
|
|
3458
|
+
[' ', 'W', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'B', ' ', 'B', ' '],
|
|
3459
|
+
['W', ' ', ' ', 'W', ' ', ' ', 'B', ' ', ' ', 'B', 'B', 'B', 'B', 'B', ' ', ' ', 'B', ' ', 'B', ' '],
|
|
3460
|
+
[' ', 'W', 'W', ' ', 'W', ' ', ' ', 'B', ' ', ' ', ' ', ' ', ' ', ' ', 'B', ' ', 'B', ' ', 'B', ' '],
|
|
3461
|
+
['B', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'B', 'W']
|
|
3462
|
+
])
|
|
3463
|
+
binst = solver.Board(board=board)
|
|
3464
|
+
solutions = binst.solve_and_print()
|
|
3465
|
+
```
|
|
3466
|
+
|
|
3467
|
+
**Script Output**
|
|
3468
|
+
|
|
3469
|
+
```python
|
|
3470
|
+
Solution found
|
|
3471
|
+
[
|
|
3472
|
+
[ 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W' ],
|
|
3473
|
+
[ 'W', 'B', 'B', 'W', 'B', 'W', 'B', 'B', 'B', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'B', 'W', 'B', 'W' ],
|
|
3474
|
+
[ 'W', 'W', 'B', 'W', 'B', 'W', 'W', 'W', 'W', 'W', 'B', 'W', 'W', 'B', 'W', 'W', 'B', 'W', 'B', 'W' ],
|
|
3475
|
+
[ 'W', 'B', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'B', 'B', 'B', 'B', 'B', 'W', 'B', 'B', 'W', 'B', 'W' ],
|
|
3476
|
+
[ 'W', 'W', 'W', 'B', 'W', 'W', 'B', 'W', 'W', 'W', 'W', 'W', 'B', 'W', 'W', 'W', 'B', 'W', 'B', 'W' ],
|
|
3477
|
+
[ 'W', 'B', 'W', 'B', 'W', 'B', 'B', 'B', 'W', 'B', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
|
|
3478
|
+
[ 'W', 'B', 'B', 'B', 'W', 'W', 'W', 'B', 'W', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
|
|
3479
|
+
[ 'W', 'W', 'B', 'W', 'W', 'B', 'W', 'B', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
|
|
3480
|
+
[ 'W', 'B', 'B', 'B', 'B', 'B', 'W', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
|
|
3481
|
+
[ 'W', 'W', 'W', 'W', 'W', 'B', 'W', 'B', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
|
|
3482
|
+
[ 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'W', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
|
|
3483
|
+
[ 'W', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'B', 'B', 'W' ],
|
|
3484
|
+
[ 'W', 'W', 'W', 'W', 'W', 'B', 'W', 'W', 'W', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'W', 'W', 'B', 'W' ],
|
|
3485
|
+
[ 'W', 'B', 'B', 'B', 'B', 'B', 'W', 'B', 'B', 'B', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
|
|
3486
|
+
[ 'W', 'W', 'W', 'B', 'W', 'B', 'W', 'W', 'W', 'W', 'W', 'W', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'W' ],
|
|
3487
|
+
[ 'W', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'B', 'B', 'B', 'B', 'B', 'W', 'B', 'B', 'B', 'W', 'B', 'W' ],
|
|
3488
|
+
[ 'W', 'W', 'B', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'B', 'W', 'B', 'W' ],
|
|
3489
|
+
[ 'W', 'B', 'B', 'W', 'B', 'B', 'B', 'B', 'W', 'B', 'B', 'B', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'W' ],
|
|
3490
|
+
[ 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'B', 'W', 'W', 'W', 'W', 'W', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
|
|
3491
|
+
[ 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'W' ],
|
|
3492
|
+
]
|
|
3493
|
+
Solutions found: 1
|
|
3494
|
+
status: OPTIMAL
|
|
3495
|
+
Time taken: 3.10 seconds
|
|
3496
|
+
```
|
|
3497
|
+
|
|
3498
|
+
**Solved puzzle**
|
|
3499
|
+
|
|
3500
|
+
Applying the solution to the puzzle visually:
|
|
3501
|
+
|
|
3502
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/yin_yang_solved.png" alt="Yin-Yang solved" width="500">
|
|
3503
|
+
|
|
3504
|
+
---
|
|
3505
|
+
|
|
3388
3506
|
---
|
|
3389
3507
|
|
|
3390
3508
|
## Why SAT / CP-SAT?
|
|
@@ -3475,3 +3593,4 @@ Issues and PRs welcome!
|
|
|
3475
3593
|
[37]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/unequal "puzzle_solver/src/puzzle_solver/puzzles/unequal at master · Ar-Kareem/puzzle_solver · GitHub"
|
|
3476
3594
|
[38]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/norinori "puzzle_solver/src/puzzle_solver/puzzles/norinori at master · Ar-Kareem/puzzle_solver · GitHub"
|
|
3477
3595
|
[39]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/slitherlink "puzzle_solver/src/puzzle_solver/puzzles/slitherlink at master · Ar-Kareem/puzzle_solver · GitHub"
|
|
3596
|
+
[40]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/yin_yang "puzzle_solver/src/puzzle_solver/puzzles/yin_yang at master · Ar-Kareem/puzzle_solver · GitHub"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
puzzle_solver/__init__.py,sha256=
|
|
1
|
+
puzzle_solver/__init__.py,sha256=H7Tpw9yF9TDqwck6bXv1jiS9TGtO5nsVwcDd0Fp1Xyg,2926
|
|
2
2
|
puzzle_solver/core/utils.py,sha256=_LA81kHrsgvqPvq7RISBeaurXmYMKAU9N6qmV8n0G7s,8063
|
|
3
3
|
puzzle_solver/core/utils_ortools.py,sha256=2xEL9cMEKmNhRD9lhr2nGdZ3Lbmc9cnHY8xv6iLhUr0,10542
|
|
4
4
|
puzzle_solver/puzzles/aquarium/aquarium.py,sha256=BUfkAS2d9eG3TdMoe1cOGGeNYgKUebRvn-z9nsC9gvE,5708
|
|
@@ -38,7 +38,7 @@ puzzle_solver/puzzles/star_battle/star_battle.py,sha256=IX6w4H3sifN01kPPtrAVRCK0
|
|
|
38
38
|
puzzle_solver/puzzles/star_battle/star_battle_shapeless.py,sha256=lj05V0Y3A3NjMo1boMkPIwBhMtm6SWydjgAMeCf5EIo,225
|
|
39
39
|
puzzle_solver/puzzles/stitches/stitches.py,sha256=iK8t02q43gH3FPbuIDn4dK0sbaOgZOnw8yHNRNvNuIU,6534
|
|
40
40
|
puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=1LNJkIqpcz1LvY0H0uRedABQWm44dgNf9XeQuKm36WM,10275
|
|
41
|
-
puzzle_solver/puzzles/sudoku/sudoku.py,sha256=
|
|
41
|
+
puzzle_solver/puzzles/sudoku/sudoku.py,sha256=SE4TM_gic6Jj0fkDR_NzUJdX2XKyQ8eeOnVAQ011Xbo,8870
|
|
42
42
|
puzzle_solver/puzzles/tents/tents.py,sha256=iyVK2WXfIT5j_9qqlQg0WmwvixwXlZSsHGK3XA-KpII,6283
|
|
43
43
|
puzzle_solver/puzzles/thermometers/thermometers.py,sha256=nsvJZkm7G8FALT27bpaB0lv5E_AWawqmvapQI8QcYXw,4015
|
|
44
44
|
puzzle_solver/puzzles/towers/towers.py,sha256=QvL0Pp-Z2ewCeq9ZkNrh8MShKOh-Y52sFBSudve68wk,6496
|
|
@@ -46,8 +46,10 @@ puzzle_solver/puzzles/tracks/tracks.py,sha256=98xds9SKNqtOLFTRUX_KSMC7XYmZo567LO
|
|
|
46
46
|
puzzle_solver/puzzles/undead/undead.py,sha256=IrCUfzQFBem658P5KKqldG7vd2TugTHehcwseCarerM,6604
|
|
47
47
|
puzzle_solver/puzzles/unequal/unequal.py,sha256=ExY2XDCrqROCDpRLfHo8uVr1zuli1QvbCdNCiDhlCac,6978
|
|
48
48
|
puzzle_solver/puzzles/unruly/unruly.py,sha256=sDF0oKT50G-NshyW2DYrvAgD9q9Ku9ANUyNhGSAu7cQ,3827
|
|
49
|
+
puzzle_solver/puzzles/yin_yang/yin_yang.py,sha256=WrRdNhmKhIARdGOt_36gpRxRzrfLGv3wl7igBpPFM64,5259
|
|
50
|
+
puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py,sha256=l4b6eO30kspmiP20b60WbvxEEKNKgLQU1SgwDNhhLOA,6459
|
|
49
51
|
puzzle_solver/utils/visualizer.py,sha256=tsX1yEKwmwXBYuBJpx_oZGe2UUt1g5yV73G3UbtmvtE,6817
|
|
50
|
-
multi_puzzle_solver-0.9.
|
|
51
|
-
multi_puzzle_solver-0.9.
|
|
52
|
-
multi_puzzle_solver-0.9.
|
|
53
|
-
multi_puzzle_solver-0.9.
|
|
52
|
+
multi_puzzle_solver-0.9.22.dist-info/METADATA,sha256=X8hwi1PYI_Yka0rUuxUy5vck0_lSHt-Gwy0m1w8tS_k,187121
|
|
53
|
+
multi_puzzle_solver-0.9.22.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
54
|
+
multi_puzzle_solver-0.9.22.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
|
|
55
|
+
multi_puzzle_solver-0.9.22.dist-info/RECORD,,
|
puzzle_solver/__init__.py
CHANGED
|
@@ -37,7 +37,8 @@ from puzzle_solver.puzzles.tracks import tracks as tracks_solver
|
|
|
37
37
|
from puzzle_solver.puzzles.undead import undead as undead_solver
|
|
38
38
|
from puzzle_solver.puzzles.unequal import unequal as unequal_solver
|
|
39
39
|
from puzzle_solver.puzzles.unruly import unruly as unruly_solver
|
|
40
|
+
from puzzle_solver.puzzles.yin_yang import yin_yang as yin_yang_solver
|
|
40
41
|
|
|
41
42
|
from puzzle_solver.puzzles.inertia.parse_map.parse_map import main as inertia_image_parser
|
|
42
43
|
|
|
43
|
-
__version__ = '0.9.
|
|
44
|
+
__version__ = '0.9.22'
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
from typing import Union
|
|
1
|
+
from typing import Union, Optional
|
|
2
2
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
from ortools.sat.python import cp_model
|
|
5
5
|
|
|
6
6
|
from puzzle_solver.core.utils import Pos, get_pos, get_all_pos, get_char, set_char, get_row_pos, get_col_pos
|
|
7
|
-
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
7
|
+
from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, or_constraint, SingleSolution
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def get_value(board: np.array, pos: Pos) -> Union[int, str]:
|
|
11
|
-
c = get_char(board, pos)
|
|
11
|
+
c = get_char(board, pos).lower()
|
|
12
12
|
if c == ' ':
|
|
13
13
|
return c
|
|
14
14
|
if str(c).isdecimal():
|
|
@@ -27,22 +27,40 @@ def set_value(board: np.array, pos: Pos, value: Union[int, str]):
|
|
|
27
27
|
set_char(board, pos, value)
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
def get_block_pos(i: int,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
def get_block_pos(i: int, Bv: int, Bh: int) -> list[Pos]:
|
|
31
|
+
# Think: Bv=3 and Bh=4 while the board has 4 vertical blocks and 3 horizontal blocks
|
|
32
|
+
top_left_x = (i%Bv)*Bh
|
|
33
|
+
top_left_y = (i//Bv)*Bv
|
|
34
|
+
return [get_pos(x=top_left_x + x, y=top_left_y + y) for x in range(Bh) for y in range(Bv)]
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
class Board:
|
|
37
|
-
def __init__(self, board: np.array):
|
|
38
|
+
def __init__(self, board: np.array, block_size: Optional[tuple[int, int]] = None, sandwich: Optional[dict[str, list[int]]] = None, unique_diagonal: bool = False):
|
|
38
39
|
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
39
40
|
assert board.shape[0] == board.shape[1], 'board must be square'
|
|
40
41
|
assert all(isinstance(i.item(), str) and len(i.item()) == 1 and (i.item().isalnum() or i.item() == ' ') for i in np.nditer(board)), 'board must contain only alphanumeric characters or space'
|
|
41
42
|
self.board = board
|
|
42
|
-
self.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
self.V, self.H = board.shape
|
|
44
|
+
if block_size is None:
|
|
45
|
+
B = np.sqrt(self.V) # block size
|
|
46
|
+
assert B.is_integer(), 'board size must be a perfect square or provide block_size'
|
|
47
|
+
Bv, Bh = int(B), int(B)
|
|
48
|
+
else:
|
|
49
|
+
Bv, Bh = block_size
|
|
50
|
+
assert Bv * Bh == self.V, 'block size must be a factor of board size'
|
|
51
|
+
# can be different in 4x3 for example
|
|
52
|
+
self.Bv = Bv
|
|
53
|
+
self.Bh = Bh
|
|
54
|
+
self.B = Bv * Bh # block count
|
|
55
|
+
if sandwich is not None:
|
|
56
|
+
assert set(sandwich.keys()) == set(['side', 'bottom']), 'sandwich must contain only side and bottom'
|
|
57
|
+
assert len(sandwich['side']) == self.H, 'side must be equal to board width'
|
|
58
|
+
assert len(sandwich['bottom']) == self.V, 'bottom must be equal to board height'
|
|
59
|
+
self.sandwich = sandwich
|
|
60
|
+
else:
|
|
61
|
+
self.sandwich = None
|
|
62
|
+
self.unique_diagonal = unique_diagonal
|
|
63
|
+
|
|
46
64
|
self.model = cp_model.CpModel()
|
|
47
65
|
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
48
66
|
|
|
@@ -50,28 +68,50 @@ class Board:
|
|
|
50
68
|
self.add_all_constraints()
|
|
51
69
|
|
|
52
70
|
def create_vars(self):
|
|
53
|
-
for pos in get_all_pos(self.
|
|
54
|
-
self.model_vars[pos] = self.model.NewIntVar(1, self.
|
|
71
|
+
for pos in get_all_pos(self.V, self.H):
|
|
72
|
+
self.model_vars[pos] = self.model.NewIntVar(1, self.B, f'{pos}')
|
|
55
73
|
|
|
56
74
|
def add_all_constraints(self):
|
|
57
75
|
# some squares are already filled
|
|
58
|
-
for pos in get_all_pos(self.
|
|
76
|
+
for pos in get_all_pos(self.V, self.H):
|
|
59
77
|
c = get_value(self.board, pos)
|
|
60
78
|
if c != ' ':
|
|
61
79
|
self.model.Add(self.model_vars[pos] == c)
|
|
62
80
|
# every number appears exactly once in each row, each column and each block
|
|
63
81
|
# each row
|
|
64
|
-
for row in range(self.
|
|
65
|
-
row_vars = [self.model_vars[pos] for pos in get_row_pos(row, self.
|
|
82
|
+
for row in range(self.V):
|
|
83
|
+
row_vars = [self.model_vars[pos] for pos in get_row_pos(row, H=self.H)]
|
|
66
84
|
self.model.AddAllDifferent(row_vars)
|
|
67
85
|
# each column
|
|
68
|
-
for col in range(self.
|
|
69
|
-
col_vars = [self.model_vars[pos] for pos in get_col_pos(col, self.
|
|
86
|
+
for col in range(self.H):
|
|
87
|
+
col_vars = [self.model_vars[pos] for pos in get_col_pos(col, V=self.V)]
|
|
70
88
|
self.model.AddAllDifferent(col_vars)
|
|
71
89
|
# each block
|
|
72
|
-
for block_i in range(self.
|
|
73
|
-
block_vars = [self.model_vars[p] for p in get_block_pos(block_i, self.
|
|
90
|
+
for block_i in range(self.B):
|
|
91
|
+
block_vars = [self.model_vars[p] for p in get_block_pos(block_i, Bv=self.Bv, Bh=self.Bh)]
|
|
74
92
|
self.model.AddAllDifferent(block_vars)
|
|
93
|
+
if self.sandwich is not None:
|
|
94
|
+
self.add_sandwich_constraints()
|
|
95
|
+
if self.unique_diagonal:
|
|
96
|
+
self.add_unique_diagonal_constraints()
|
|
97
|
+
|
|
98
|
+
def add_sandwich_constraints(self):
|
|
99
|
+
for c, clue in enumerate(self.sandwich['bottom']):
|
|
100
|
+
if clue is None or int(clue) < 0:
|
|
101
|
+
continue
|
|
102
|
+
col_vars = [self.model_vars[p] for p in get_col_pos(c, V=self.V)]
|
|
103
|
+
add_single_sandwich(col_vars, int(clue), self.model, name=f"sand_side_{c}")
|
|
104
|
+
for r, clue in enumerate(self.sandwich['side']):
|
|
105
|
+
if clue is None or int(clue) < 0:
|
|
106
|
+
continue
|
|
107
|
+
row_vars = [self.model_vars[p] for p in get_row_pos(r, H=self.H)]
|
|
108
|
+
add_single_sandwich(row_vars, int(clue), self.model, name=f"sand_bottom_{r}")
|
|
109
|
+
|
|
110
|
+
def add_unique_diagonal_constraints(self):
|
|
111
|
+
main_diagonal_vars = [self.model_vars[get_pos(x=i, y=i)] for i in range(min(self.V, self.H))]
|
|
112
|
+
self.model.AddAllDifferent(main_diagonal_vars)
|
|
113
|
+
anti_diagonal_vars = [self.model_vars[get_pos(x=i, y=self.V-i-1)] for i in range(min(self.V, self.H))]
|
|
114
|
+
self.model.AddAllDifferent(anti_diagonal_vars)
|
|
75
115
|
|
|
76
116
|
def solve_and_print(self, verbose: bool = True):
|
|
77
117
|
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
@@ -81,10 +121,83 @@ class Board:
|
|
|
81
121
|
return SingleSolution(assignment=assignment)
|
|
82
122
|
def callback(single_res: SingleSolution):
|
|
83
123
|
print("Solution found")
|
|
84
|
-
res = np.full((self.
|
|
85
|
-
for pos in get_all_pos(self.
|
|
124
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
125
|
+
for pos in get_all_pos(self.V, self.H):
|
|
86
126
|
c = get_value(self.board, pos)
|
|
87
127
|
c = single_res.assignment[pos]
|
|
88
128
|
set_value(res, pos, c)
|
|
89
129
|
print(res)
|
|
90
130
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def add_single_sandwich(vars_line: list[cp_model.IntVar], clue: int, model: cp_model.CpModel, name: str):
|
|
135
|
+
# VAR count:
|
|
136
|
+
# is_min: L
|
|
137
|
+
# is_max: L
|
|
138
|
+
# pos_min/max/lt: 1+1+1
|
|
139
|
+
# between: L
|
|
140
|
+
# a1/a2/case_a: L+L+L
|
|
141
|
+
# b1/b2/case_b: L+L+L
|
|
142
|
+
# take: L
|
|
143
|
+
# 10L+3 per 1 call of the function (i.e. per 1 line)
|
|
144
|
+
# entire board will have 2L lines (rows and columns)
|
|
145
|
+
# in total: 20L^2+6L
|
|
146
|
+
|
|
147
|
+
L = len(vars_line)
|
|
148
|
+
is_min = [model.NewBoolVar(f"{name}_ismin_{i}") for i in range(L)]
|
|
149
|
+
is_max = [model.NewBoolVar(f"{name}_ismax_{i}") for i in range(L)]
|
|
150
|
+
for i, v in enumerate(vars_line):
|
|
151
|
+
model.Add(v == 1).OnlyEnforceIf(is_min[i])
|
|
152
|
+
model.Add(v != 1).OnlyEnforceIf(is_min[i].Not())
|
|
153
|
+
model.Add(v == L).OnlyEnforceIf(is_max[i])
|
|
154
|
+
model.Add(v != L).OnlyEnforceIf(is_max[i].Not())
|
|
155
|
+
|
|
156
|
+
# index of the minimum and maximum values (sum of the values inbetween must = clue)
|
|
157
|
+
pos_min = model.NewIntVar(0, L - 1, f"{name}_pos_min")
|
|
158
|
+
pos_max = model.NewIntVar(0, L - 1, f"{name}_pos_max")
|
|
159
|
+
model.Add(pos_min == sum(i * is_min[i] for i in range(L)))
|
|
160
|
+
model.Add(pos_max == sum(i * is_max[i] for i in range(L)))
|
|
161
|
+
|
|
162
|
+
# used later to handle both cases (A. pos_min < pos_max and B. pos_max < pos_min)
|
|
163
|
+
lt = model.NewBoolVar(f"{name}_lt") # pos_min < pos_max ?
|
|
164
|
+
model.Add(pos_min < pos_max).OnlyEnforceIf(lt)
|
|
165
|
+
model.Add(pos_min >= pos_max).OnlyEnforceIf(lt.Not())
|
|
166
|
+
|
|
167
|
+
between = [model.NewBoolVar(f"{name}_between_{i}") for i in range(L)]
|
|
168
|
+
for i in range(L):
|
|
169
|
+
# Case A: pos_min < i < pos_max (AND lt is true)
|
|
170
|
+
a1 = model.NewBoolVar(f"{name}_a1_{i}") # pos_min < i
|
|
171
|
+
a2 = model.NewBoolVar(f"{name}_a2_{i}") # i < pos_max
|
|
172
|
+
|
|
173
|
+
model.Add(pos_min < i).OnlyEnforceIf(a1)
|
|
174
|
+
model.Add(pos_min >= i).OnlyEnforceIf(a1.Not())
|
|
175
|
+
model.Add(i < pos_max).OnlyEnforceIf(a2)
|
|
176
|
+
model.Add(i >= pos_max).OnlyEnforceIf(a2.Not())
|
|
177
|
+
|
|
178
|
+
case_a = model.NewBoolVar(f"{name}_caseA_{i}")
|
|
179
|
+
and_constraint(model, case_a, [lt, a1, a2])
|
|
180
|
+
|
|
181
|
+
# Case B: pos_max < i < pos_min (AND lt is false)
|
|
182
|
+
b1 = model.NewBoolVar(f"{name}_b1_{i}") # pos_max < i
|
|
183
|
+
b2 = model.NewBoolVar(f"{name}_b2_{i}") # i < pos_min
|
|
184
|
+
|
|
185
|
+
model.Add(pos_max < i).OnlyEnforceIf(b1)
|
|
186
|
+
model.Add(pos_max >= i).OnlyEnforceIf(b1.Not())
|
|
187
|
+
model.Add(i < pos_min).OnlyEnforceIf(b2)
|
|
188
|
+
model.Add(i >= pos_min).OnlyEnforceIf(b2.Not())
|
|
189
|
+
|
|
190
|
+
case_b = model.NewBoolVar(f"{name}_caseB_{i}")
|
|
191
|
+
and_constraint(model, case_b, [lt.Not(), b1, b2])
|
|
192
|
+
|
|
193
|
+
# between[i] is true if we're in case A or case B
|
|
194
|
+
or_constraint(model, between[i], [case_a, case_b])
|
|
195
|
+
|
|
196
|
+
# sum values at indices that are "between"
|
|
197
|
+
take = [model.NewIntVar(0, L, f"{name}_take_{i}") for i in range(L)]
|
|
198
|
+
for i, v in enumerate(vars_line):
|
|
199
|
+
# take[i] = v if between[i] else 0
|
|
200
|
+
model.Add(take[i] == v).OnlyEnforceIf(between[i])
|
|
201
|
+
model.Add(take[i] == 0).OnlyEnforceIf(between[i].Not())
|
|
202
|
+
|
|
203
|
+
model.Add(sum(take) == clue)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
|
|
2
|
+
def extract_lines(bw):
|
|
3
|
+
horizontal = np.copy(bw)
|
|
4
|
+
vertical = np.copy(bw)
|
|
5
|
+
|
|
6
|
+
cols = horizontal.shape[1]
|
|
7
|
+
horizontal_size = max(5, cols // 20)
|
|
8
|
+
h_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (horizontal_size, 1))
|
|
9
|
+
horizontal = cv2.erode(horizontal, h_kernel)
|
|
10
|
+
horizontal = cv2.dilate(horizontal, h_kernel)
|
|
11
|
+
h_means = np.mean(horizontal, axis=1)
|
|
12
|
+
h_idx = np.where(h_means > np.percentile(h_means, 70))[0]
|
|
13
|
+
|
|
14
|
+
rows = vertical.shape[0]
|
|
15
|
+
verticalsize = max(5, rows // 20)
|
|
16
|
+
v_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, verticalsize))
|
|
17
|
+
vertical = cv2.erode(vertical, v_kernel)
|
|
18
|
+
vertical = cv2.dilate(vertical, v_kernel)
|
|
19
|
+
v_means = np.mean(vertical, axis=0)
|
|
20
|
+
v_idx = np.where(v_means > np.percentile(v_means, 70))[0]
|
|
21
|
+
return h_idx, v_idx
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cluster_line_indices(indices, min_run=3):
|
|
25
|
+
"""Group consecutive indices into line positions (take the mean of each run)."""
|
|
26
|
+
if len(indices) == 0:
|
|
27
|
+
return []
|
|
28
|
+
indices = np.sort(indices)
|
|
29
|
+
runs = []
|
|
30
|
+
run = [indices[0]]
|
|
31
|
+
for k in indices[1:]:
|
|
32
|
+
if k == run[-1] + 1:
|
|
33
|
+
run.append(k)
|
|
34
|
+
else:
|
|
35
|
+
if len(run) >= min_run:
|
|
36
|
+
runs.append(int(np.mean(run)))
|
|
37
|
+
run = [k]
|
|
38
|
+
if len(run) >= min_run:
|
|
39
|
+
runs.append(int(np.mean(run)))
|
|
40
|
+
# De-duplicate lines that are too close (rare)
|
|
41
|
+
dedup = []
|
|
42
|
+
for x in runs:
|
|
43
|
+
if not dedup or x - dedup[-1] > 2:
|
|
44
|
+
dedup.append(x)
|
|
45
|
+
return dedup
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def extract_yinyang_board(image_path, debug=False):
|
|
49
|
+
# Load and pre-process
|
|
50
|
+
img = cv2.imread(str(image_path))
|
|
51
|
+
assert img is not None, f"Failed to read image: {image_path}"
|
|
52
|
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
53
|
+
|
|
54
|
+
# Light grid lines → enhance lines using adaptive threshold
|
|
55
|
+
# (binary inverted so lines/dots become white)
|
|
56
|
+
bw = cv2.adaptiveThreshold(
|
|
57
|
+
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
58
|
+
cv2.THRESH_BINARY_INV, 35, 5
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Detect grid line indices (no guessing)
|
|
62
|
+
h_idx, v_idx = extract_lines(bw)
|
|
63
|
+
print(f"h_idx: {h_idx}")
|
|
64
|
+
print(f"v_idx: {v_idx}")
|
|
65
|
+
h_lines = h_idx
|
|
66
|
+
v_lines = v_idx
|
|
67
|
+
# h_lines = _cluster_line_indices(h_idx)
|
|
68
|
+
# v_lines = _cluster_line_indices(v_idx)
|
|
69
|
+
assert len(h_lines) >= 2 and len(v_lines) >= 2, "Could not detect grid lines"
|
|
70
|
+
|
|
71
|
+
# Cells are spans between successive grid lines
|
|
72
|
+
N_rows = len(h_lines) - 1
|
|
73
|
+
N_cols = len(v_lines) - 1
|
|
74
|
+
board = np.full((N_rows, N_cols), ' ', dtype='<U1')
|
|
75
|
+
|
|
76
|
+
# For robust per-cell analysis, also create a "dots" image with grid erased
|
|
77
|
+
# Remove thickened grid from bw
|
|
78
|
+
# Build masks for horizontal/vertical lines (reusing kernels sized by image dims)
|
|
79
|
+
cols = bw.shape[1]
|
|
80
|
+
rows = bw.shape[0]
|
|
81
|
+
h_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (max(5, cols // 20), 1))
|
|
82
|
+
v_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(5, rows // 20)))
|
|
83
|
+
horiz = cv2.morphologyEx(bw, cv2.MORPH_OPEN, h_kernel)
|
|
84
|
+
vert = cv2.morphologyEx(bw, cv2.MORPH_OPEN, v_kernel)
|
|
85
|
+
grid = cv2.bitwise_or(horiz, vert)
|
|
86
|
+
dots = cv2.bitwise_and(bw, cv2.bitwise_not(grid)) # mostly circles remain
|
|
87
|
+
|
|
88
|
+
# Iterate cells
|
|
89
|
+
print(f"N_rows: {N_rows}, N_cols: {N_cols}")
|
|
90
|
+
print(f"h_lines: {h_lines}")
|
|
91
|
+
print(f"v_lines: {v_lines}")
|
|
92
|
+
for r in range(N_rows):
|
|
93
|
+
y0, y1 = h_lines[r], h_lines[r + 1]
|
|
94
|
+
# shrink ROI to avoid line bleed
|
|
95
|
+
y0i = max(y0 + 2, 0)
|
|
96
|
+
y1i = max(min(y1 - 2, dots.shape[0]), y0i + 1)
|
|
97
|
+
for c in range(N_cols):
|
|
98
|
+
x0, x1 = v_lines[c], v_lines[c + 1]
|
|
99
|
+
x0i = max(x0 + 2, 0)
|
|
100
|
+
x1i = max(min(x1 - 2, dots.shape[1]), x0i + 1)
|
|
101
|
+
|
|
102
|
+
roi_gray = gray[y0i:y1i, x0i:x1i]
|
|
103
|
+
roi_dots = dots[y0i:y1i, x0i:x1i]
|
|
104
|
+
area = roi_dots.shape[0] * roi_dots.shape[1]
|
|
105
|
+
if area == 0:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# If no meaningful foreground, it's empty
|
|
109
|
+
fg_area = int(np.count_nonzero(roi_dots))
|
|
110
|
+
if fg_area < 0.03 * area:
|
|
111
|
+
board[r, c] = ' '
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Segment the largest blob (circle) inside the cell
|
|
115
|
+
contours, _ = cv2.findContours(roi_dots, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
116
|
+
if not contours:
|
|
117
|
+
board[r, c] = ' '
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
cnt = max(contours, key=cv2.contourArea)
|
|
121
|
+
if cv2.contourArea(cnt) < 0.02 * area:
|
|
122
|
+
board[r, c] = ' '
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
mask = np.zeros_like(roi_dots)
|
|
126
|
+
cv2.drawContours(mask, [cnt], -1, 255, thickness=-1)
|
|
127
|
+
|
|
128
|
+
mean_inside = float(cv2.mean(roi_gray, mask=mask)[0])
|
|
129
|
+
|
|
130
|
+
# Heuristic: black stones have dark interior; white stones bright interior
|
|
131
|
+
# (grid background is white; outlines contribute little to mean)
|
|
132
|
+
board[r, c] = 'B' if mean_inside < 150 else 'W'
|
|
133
|
+
non_empty_rows = []
|
|
134
|
+
non_empty_cols = []
|
|
135
|
+
for r in range(N_rows):
|
|
136
|
+
if not all(board[r, :] == ' '):
|
|
137
|
+
non_empty_rows.append(r)
|
|
138
|
+
for c in range(N_cols):
|
|
139
|
+
if not all(board[:, c] == ' '):
|
|
140
|
+
non_empty_cols.append(c)
|
|
141
|
+
board = board[non_empty_rows, :][:, non_empty_cols]
|
|
142
|
+
|
|
143
|
+
if debug:
|
|
144
|
+
for row in board:
|
|
145
|
+
print(row.tolist())
|
|
146
|
+
output_path = Path(__file__).parent / "input_output" / (image_path.stem + ".json")
|
|
147
|
+
with open(output_path, 'w') as f:
|
|
148
|
+
f.write('[\n')
|
|
149
|
+
for i, row in enumerate(board):
|
|
150
|
+
f.write(' ' + str(row.tolist()).replace("'", '"'))
|
|
151
|
+
if i != len(board) - 1:
|
|
152
|
+
f.write(',')
|
|
153
|
+
f.write('\n')
|
|
154
|
+
f.write(']')
|
|
155
|
+
print('output json: ', output_path)
|
|
156
|
+
|
|
157
|
+
return board
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
# python .\src\puzzle_solver\puzzles\yin_yang\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
|
|
161
|
+
import cv2
|
|
162
|
+
import numpy as np
|
|
163
|
+
from pathlib import Path
|
|
164
|
+
image_path = Path(__file__).parent / "input_output" / "MzoyLDcwMSw2NTY=.png"
|
|
165
|
+
# image_path = Path(__file__).parent / "input_output" / "Njo5MDcsNDk4.png"
|
|
166
|
+
# image_path = Path(__file__).parent / "input_output" / "MTE6Niw0NjEsMTIx.png"
|
|
167
|
+
assert image_path.exists(), f"Image file does not exist: {image_path}"
|
|
168
|
+
board = extract_yinyang_board(image_path, debug=True)
|
|
169
|
+
print(board)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from ortools.sat.python import cp_model
|
|
4
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
5
|
+
|
|
6
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, in_bounds, Direction, get_next_pos, get_pos
|
|
7
|
+
from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Board:
|
|
11
|
+
def __init__(self, board: np.array):
|
|
12
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
13
|
+
assert all(c.item() in [' ', 'B', 'W'] for c in np.nditer(board)), 'board must contain only space, B, or W'
|
|
14
|
+
self.board = board
|
|
15
|
+
self.V, self.H = board.shape
|
|
16
|
+
self.model = cp_model.CpModel()
|
|
17
|
+
self.B: dict[Pos, cp_model.IntVar] = {}
|
|
18
|
+
self.W: dict[Pos, cp_model.IntVar] = {}
|
|
19
|
+
|
|
20
|
+
self.create_vars()
|
|
21
|
+
self.add_all_constraints()
|
|
22
|
+
|
|
23
|
+
def create_vars(self):
|
|
24
|
+
for pos in get_all_pos(self.V, self.H):
|
|
25
|
+
self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
|
|
26
|
+
|
|
27
|
+
def add_all_constraints(self):
|
|
28
|
+
self.force_clues()
|
|
29
|
+
self.disallow_2x2()
|
|
30
|
+
self.disallow_checkers()
|
|
31
|
+
self.force_connected_component()
|
|
32
|
+
self.force_border_transitions()
|
|
33
|
+
|
|
34
|
+
def force_clues(self):
|
|
35
|
+
for pos in get_all_pos(self.V, self.H): # force clues
|
|
36
|
+
c = get_char(self.board, pos)
|
|
37
|
+
if c not in ['B', 'W']:
|
|
38
|
+
continue
|
|
39
|
+
self.model.Add(self.B[pos] == (c == 'B'))
|
|
40
|
+
|
|
41
|
+
def disallow_2x2(self):
|
|
42
|
+
for pos in get_all_pos(self.V, self.H): # disallow 2x2 (WW/WW) and (BB/BB)
|
|
43
|
+
tl = pos
|
|
44
|
+
tr = get_next_pos(pos, Direction.RIGHT)
|
|
45
|
+
bl = get_next_pos(pos, Direction.DOWN)
|
|
46
|
+
br = get_next_pos(bl, Direction.RIGHT)
|
|
47
|
+
if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
|
|
48
|
+
continue
|
|
49
|
+
self.model.AddBoolOr([self.B[tl], self.B[tr], self.B[bl], self.B[br]])
|
|
50
|
+
self.model.AddBoolOr([self.B[tl].Not(), self.B[tr].Not(), self.B[bl].Not(), self.B[br].Not()])
|
|
51
|
+
|
|
52
|
+
def disallow_checkers(self):
|
|
53
|
+
# from https://ralphwaldo.github.io/yinyang_summary.html
|
|
54
|
+
for pos in get_all_pos(self.V, self.H): # disallow (WB/BW) and (BW/WB)
|
|
55
|
+
tl = pos
|
|
56
|
+
tr = get_next_pos(pos, Direction.RIGHT)
|
|
57
|
+
bl = get_next_pos(pos, Direction.DOWN)
|
|
58
|
+
br = get_next_pos(bl, Direction.RIGHT)
|
|
59
|
+
if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
|
|
60
|
+
continue
|
|
61
|
+
self.model.AddBoolOr([self.B[tl], self.B[tr].Not(), self.B[bl].Not(), self.B[br]]) # disallow (WB/BW)
|
|
62
|
+
self.model.AddBoolOr([self.B[tl].Not(), self.B[tr], self.B[bl], self.B[br].Not()]) # disallow (BW/WB)
|
|
63
|
+
|
|
64
|
+
def force_connected_component(self):
|
|
65
|
+
# force single connected component for both colors
|
|
66
|
+
force_connected_component(self.model, self.B)
|
|
67
|
+
force_connected_component(self.model, {k: v.Not() for k, v in self.B.items()})
|
|
68
|
+
|
|
69
|
+
def force_border_transitions(self):
|
|
70
|
+
# from https://ralphwaldo.github.io/yinyang_summary.html
|
|
71
|
+
# The border cells cannot be split into four (or more) separate blocks of colours
|
|
72
|
+
# It is therefore either split into two blocks (one of each colour), or is just a single block of one colour or the other
|
|
73
|
+
border_cells = [] # go in a ring clockwise from top left
|
|
74
|
+
for x in range(self.H):
|
|
75
|
+
border_cells.append(get_pos(x=x, y=0))
|
|
76
|
+
for y in range(1, self.V):
|
|
77
|
+
border_cells.append(get_pos(x=self.H-1, y=y))
|
|
78
|
+
for x in range(self.H-2, -1, -1):
|
|
79
|
+
border_cells.append(get_pos(x=x, y=self.V-1))
|
|
80
|
+
for y in range(self.V-2, 0, -1):
|
|
81
|
+
border_cells.append(get_pos(x=0, y=y))
|
|
82
|
+
# tie the knot
|
|
83
|
+
border_cells.append(border_cells[0])
|
|
84
|
+
# unequal sum is 0 or 2
|
|
85
|
+
deltas = []
|
|
86
|
+
for i in range(len(border_cells)-1):
|
|
87
|
+
aux = self.model.NewBoolVar(f'border_transition_{i}') # i is black while i+1 is white
|
|
88
|
+
and_constraint(self.model, aux, [self.B[border_cells[i]], self.B[border_cells[i+1]].Not()])
|
|
89
|
+
deltas.append(aux)
|
|
90
|
+
self.model.Add(lxp.Sum(deltas) <= 1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def solve_and_print(self, verbose: bool = True):
|
|
94
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
95
|
+
assignment: dict[Pos, int] = {}
|
|
96
|
+
for pos, var in board.B.items():
|
|
97
|
+
assignment[pos] = 'B' if solver.BooleanValue(var) else 'W'
|
|
98
|
+
return SingleSolution(assignment=assignment)
|
|
99
|
+
def callback(single_res: SingleSolution):
|
|
100
|
+
print("Solution found")
|
|
101
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
102
|
+
for pos in get_all_pos(self.V, self.H):
|
|
103
|
+
c = get_char(self.board, pos)
|
|
104
|
+
c = single_res.assignment[pos]
|
|
105
|
+
set_char(res, pos, c)
|
|
106
|
+
print('[')
|
|
107
|
+
for row in res:
|
|
108
|
+
print(" [ '" + "', '".join(row.tolist()) + "' ],")
|
|
109
|
+
print(']')
|
|
110
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
File without changes
|
|
File without changes
|