multi-puzzle-solver 1.1.8__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.
Files changed (106) hide show
  1. multi_puzzle_solver-1.1.8.dist-info/METADATA +4326 -0
  2. multi_puzzle_solver-1.1.8.dist-info/RECORD +106 -0
  3. multi_puzzle_solver-1.1.8.dist-info/WHEEL +5 -0
  4. multi_puzzle_solver-1.1.8.dist-info/top_level.txt +1 -0
  5. puzzle_solver/__init__.py +184 -0
  6. puzzle_solver/core/utils.py +298 -0
  7. puzzle_solver/core/utils_ortools.py +333 -0
  8. puzzle_solver/core/utils_visualizer.py +575 -0
  9. puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
  10. puzzle_solver/puzzles/aquarium/aquarium.py +97 -0
  11. puzzle_solver/puzzles/area_51/area_51.py +159 -0
  12. puzzle_solver/puzzles/battleships/battleships.py +139 -0
  13. puzzle_solver/puzzles/binairo/binairo.py +98 -0
  14. puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
  15. puzzle_solver/puzzles/black_box/black_box.py +243 -0
  16. puzzle_solver/puzzles/branches/branches.py +64 -0
  17. puzzle_solver/puzzles/bridges/bridges.py +104 -0
  18. puzzle_solver/puzzles/chess_range/chess_melee.py +6 -0
  19. puzzle_solver/puzzles/chess_range/chess_range.py +406 -0
  20. puzzle_solver/puzzles/chess_range/chess_solo.py +9 -0
  21. puzzle_solver/puzzles/chess_sequence/chess_sequence.py +262 -0
  22. puzzle_solver/puzzles/circle_9/circle_9.py +44 -0
  23. puzzle_solver/puzzles/clouds/clouds.py +81 -0
  24. puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
  25. puzzle_solver/puzzles/cow_and_cactus/cow_and_cactus.py +66 -0
  26. puzzle_solver/puzzles/dominosa/dominosa.py +67 -0
  27. puzzle_solver/puzzles/filling/filling.py +94 -0
  28. puzzle_solver/puzzles/flip/flip.py +64 -0
  29. puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
  30. puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +197 -0
  31. puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
  32. puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
  33. puzzle_solver/puzzles/guess/guess.py +232 -0
  34. puzzle_solver/puzzles/heyawake/heyawake.py +152 -0
  35. puzzle_solver/puzzles/hidden_stars/hidden_stars.py +52 -0
  36. puzzle_solver/puzzles/hidoku/hidoku.py +59 -0
  37. puzzle_solver/puzzles/inertia/inertia.py +121 -0
  38. puzzle_solver/puzzles/inertia/parse_map/parse_map.py +207 -0
  39. puzzle_solver/puzzles/inertia/tsp.py +400 -0
  40. puzzle_solver/puzzles/kakurasu/kakurasu.py +38 -0
  41. puzzle_solver/puzzles/kakuro/kakuro.py +81 -0
  42. puzzle_solver/puzzles/kakuro/krypto_kakuro.py +95 -0
  43. puzzle_solver/puzzles/keen/keen.py +76 -0
  44. puzzle_solver/puzzles/kropki/kropki.py +94 -0
  45. puzzle_solver/puzzles/light_up/light_up.py +58 -0
  46. puzzle_solver/puzzles/linesweeper/linesweeper.py +71 -0
  47. puzzle_solver/puzzles/link_a_pix/link_a_pix.py +91 -0
  48. puzzle_solver/puzzles/lits/lits.py +138 -0
  49. puzzle_solver/puzzles/magnets/magnets.py +96 -0
  50. puzzle_solver/puzzles/map/map.py +56 -0
  51. puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
  52. puzzle_solver/puzzles/mathrax/mathrax.py +93 -0
  53. puzzle_solver/puzzles/minesweeper/minesweeper.py +123 -0
  54. puzzle_solver/puzzles/mosaic/mosaic.py +38 -0
  55. puzzle_solver/puzzles/n_queens/n_queens.py +71 -0
  56. puzzle_solver/puzzles/nonograms/nonograms.py +121 -0
  57. puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -0
  58. puzzle_solver/puzzles/norinori/norinori.py +96 -0
  59. puzzle_solver/puzzles/number_path/number_path.py +76 -0
  60. puzzle_solver/puzzles/numbermaze/numbermaze.py +97 -0
  61. puzzle_solver/puzzles/nurikabe/nurikabe.py +130 -0
  62. puzzle_solver/puzzles/palisade/palisade.py +91 -0
  63. puzzle_solver/puzzles/pearl/pearl.py +107 -0
  64. puzzle_solver/puzzles/pipes/pipes.py +82 -0
  65. puzzle_solver/puzzles/range/range.py +59 -0
  66. puzzle_solver/puzzles/rectangles/rectangles.py +128 -0
  67. puzzle_solver/puzzles/ripple_effect/ripple_effect.py +83 -0
  68. puzzle_solver/puzzles/rooms/rooms.py +75 -0
  69. puzzle_solver/puzzles/schurs_numbers/schurs_numbers.py +73 -0
  70. puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
  71. puzzle_solver/puzzles/shingoki/shingoki.py +116 -0
  72. puzzle_solver/puzzles/signpost/signpost.py +93 -0
  73. puzzle_solver/puzzles/singles/singles.py +53 -0
  74. puzzle_solver/puzzles/slant/parse_map/parse_map.py +135 -0
  75. puzzle_solver/puzzles/slant/slant.py +111 -0
  76. puzzle_solver/puzzles/slitherlink/slitherlink.py +130 -0
  77. puzzle_solver/puzzles/snail/snail.py +97 -0
  78. puzzle_solver/puzzles/split_ends/split_ends.py +93 -0
  79. puzzle_solver/puzzles/star_battle/star_battle.py +75 -0
  80. puzzle_solver/puzzles/star_battle/star_battle_shapeless.py +7 -0
  81. puzzle_solver/puzzles/stitches/parse_map/parse_map.py +267 -0
  82. puzzle_solver/puzzles/stitches/stitches.py +96 -0
  83. puzzle_solver/puzzles/sudoku/sudoku.py +267 -0
  84. puzzle_solver/puzzles/suguru/suguru.py +55 -0
  85. puzzle_solver/puzzles/suko/suko.py +54 -0
  86. puzzle_solver/puzzles/tapa/tapa.py +97 -0
  87. puzzle_solver/puzzles/tatami/tatami.py +64 -0
  88. puzzle_solver/puzzles/tents/tents.py +80 -0
  89. puzzle_solver/puzzles/thermometers/thermometers.py +82 -0
  90. puzzle_solver/puzzles/towers/towers.py +89 -0
  91. puzzle_solver/puzzles/tracks/tracks.py +88 -0
  92. puzzle_solver/puzzles/trees_logic/trees_logic.py +48 -0
  93. puzzle_solver/puzzles/troix/dumplings.py +7 -0
  94. puzzle_solver/puzzles/troix/troix.py +75 -0
  95. puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
  96. puzzle_solver/puzzles/undead/undead.py +130 -0
  97. puzzle_solver/puzzles/unequal/unequal.py +128 -0
  98. puzzle_solver/puzzles/unruly/unruly.py +54 -0
  99. puzzle_solver/puzzles/vectors/vectors.py +94 -0
  100. puzzle_solver/puzzles/vermicelli/vermicelli.py +74 -0
  101. puzzle_solver/puzzles/walls/walls.py +52 -0
  102. puzzle_solver/puzzles/yajilin/yajilin.py +87 -0
  103. puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +172 -0
  104. puzzle_solver/puzzles/yin_yang/yin_yang.py +103 -0
  105. puzzle_solver/utils/etc/parser/board_color_digit.py +497 -0
  106. puzzle_solver/utils/visualizer.py +155 -0
@@ -0,0 +1,103 @@
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, 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
+ from puzzle_solver.core.utils_visualizer import combined_function
9
+
10
+
11
+ class Board:
12
+ def __init__(self, board: np.array):
13
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
14
+ assert all(c.item() in [' ', 'B', 'W'] for c in np.nditer(board)), 'board must contain only space, B, or W'
15
+ self.board = board
16
+ self.V, self.H = board.shape
17
+ self.model = cp_model.CpModel()
18
+ self.B: dict[Pos, cp_model.IntVar] = {}
19
+ self.W: dict[Pos, cp_model.IntVar] = {}
20
+
21
+ self.create_vars()
22
+ self.add_all_constraints()
23
+
24
+ def create_vars(self):
25
+ for pos in get_all_pos(self.V, self.H):
26
+ self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
27
+
28
+ def add_all_constraints(self):
29
+ self.force_clues()
30
+ self.disallow_2x2()
31
+ self.disallow_checkers()
32
+ self.force_connected_component()
33
+ self.force_border_transitions()
34
+
35
+ def force_clues(self):
36
+ for pos in get_all_pos(self.V, self.H): # force clues
37
+ c = get_char(self.board, pos)
38
+ if c not in ['B', 'W']:
39
+ continue
40
+ self.model.Add(self.B[pos] == (c == 'B'))
41
+
42
+ def disallow_2x2(self):
43
+ for pos in get_all_pos(self.V, self.H): # disallow 2x2 (WW/WW) and (BB/BB)
44
+ tl = pos
45
+ tr = get_next_pos(pos, Direction.RIGHT)
46
+ bl = get_next_pos(pos, Direction.DOWN)
47
+ br = get_next_pos(bl, Direction.RIGHT)
48
+ if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
49
+ continue
50
+ self.model.AddBoolOr([self.B[tl], self.B[tr], self.B[bl], self.B[br]])
51
+ self.model.AddBoolOr([self.B[tl].Not(), self.B[tr].Not(), self.B[bl].Not(), self.B[br].Not()])
52
+
53
+ def disallow_checkers(self):
54
+ # from https://ralphwaldo.github.io/yinyang_summary.html
55
+ for pos in get_all_pos(self.V, self.H): # disallow (WB/BW) and (BW/WB)
56
+ tl = pos
57
+ tr = get_next_pos(pos, Direction.RIGHT)
58
+ bl = get_next_pos(pos, Direction.DOWN)
59
+ br = get_next_pos(bl, Direction.RIGHT)
60
+ if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
61
+ continue
62
+ self.model.AddBoolOr([self.B[tl], self.B[tr].Not(), self.B[bl].Not(), self.B[br]]) # disallow (WB/BW)
63
+ self.model.AddBoolOr([self.B[tl].Not(), self.B[tr], self.B[bl], self.B[br].Not()]) # disallow (BW/WB)
64
+
65
+ def force_connected_component(self):
66
+ # force single connected component for both colors
67
+ force_connected_component(self.model, self.B)
68
+ force_connected_component(self.model, {k: v.Not() for k, v in self.B.items()})
69
+
70
+ def force_border_transitions(self):
71
+ # from https://ralphwaldo.github.io/yinyang_summary.html
72
+ # The border cells cannot be split into four (or more) separate blocks of colours
73
+ # It is therefore either split into two blocks (one of each colour), or is just a single block of one colour or the other
74
+ border_cells = [] # go in a ring clockwise from top left
75
+ for x in range(self.H):
76
+ border_cells.append(get_pos(x=x, y=0))
77
+ for y in range(1, self.V):
78
+ border_cells.append(get_pos(x=self.H-1, y=y))
79
+ for x in range(self.H-2, -1, -1):
80
+ border_cells.append(get_pos(x=x, y=self.V-1))
81
+ for y in range(self.V-2, 0, -1):
82
+ border_cells.append(get_pos(x=0, y=y))
83
+ # tie the knot
84
+ border_cells.append(border_cells[0])
85
+ # unequal sum is 0 or 2
86
+ deltas = []
87
+ for i in range(len(border_cells)-1):
88
+ aux = self.model.NewBoolVar(f'border_transition_{i}') # i is black while i+1 is white
89
+ and_constraint(self.model, aux, [self.B[border_cells[i]], self.B[border_cells[i+1]].Not()])
90
+ deltas.append(aux)
91
+ self.model.Add(lxp.Sum(deltas) <= 1)
92
+
93
+
94
+ def solve_and_print(self, verbose: bool = True):
95
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
96
+ assignment: dict[Pos, int] = {}
97
+ for pos, var in board.B.items():
98
+ assignment[pos] = 'B' if solver.BooleanValue(var) else 'W'
99
+ return SingleSolution(assignment=assignment)
100
+ def callback(single_res: SingleSolution):
101
+ print("Solution found")
102
+ print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 'B'))
103
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,497 @@
1
+ from pathlib import Path
2
+ from functools import lru_cache
3
+ import numpy as np
4
+ from PIL import Image
5
+
6
+ cv2 = None
7
+ plt = None
8
+
9
+
10
+ DIGITS_DIR = Path(__file__).parent / "digits"
11
+
12
+
13
+ # ------------------------------------------------------------------
14
+ # helpers
15
+ # ------------------------------------------------------------------
16
+ def cluster_positions(lines, axis=0, tol=3):
17
+ """Group nearly identical x (or y) positions into one line."""
18
+ pts = []
19
+ for x1, y1, x2, y2 in lines:
20
+ pts.append(x1 if axis == 0 else y1)
21
+ pts = sorted(pts)
22
+ groups = []
23
+ current = [pts[0]]
24
+ for p in pts[1:]:
25
+ if abs(p - current[-1]) <= tol:
26
+ current.append(p)
27
+ else:
28
+ groups.append(current)
29
+ current = [p]
30
+ groups.append(current)
31
+ return [int(np.mean(g)) for g in groups]
32
+
33
+
34
+
35
+ def detect_color_simple(cell_bgr):
36
+ """Roughly decide whether the cell text is orange, yellow or black."""
37
+ hsv = cv2.cvtColor(cell_bgr, cv2.COLOR_BGR2HSV)
38
+ h = hsv[:, :, 0]
39
+ s = hsv[:, :, 1]
40
+ v = hsv[:, :, 2]
41
+ # print(f'H: {h}')
42
+ # print(f'S: {s}')
43
+ # print(f'V: {v}')
44
+
45
+ mask = (h != 0) | (s != 0) | (v != 255)
46
+ if not np.any(mask):
47
+ return None
48
+
49
+ mh = int(np.mean(h[mask]))
50
+ ms = int(np.mean(s[mask]))
51
+ mv = int(np.mean(v[mask]))
52
+ # print('masked %: ', np.sum(mask) / mask.size * 100)
53
+
54
+ return (mh, ms, mv)
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
65
+
66
+
67
+
68
+
69
+
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+
101
+
102
+
103
+
104
+ def normalize_cell(img_bgr, size=28):
105
+ """
106
+ Take a BGR cell image, make it grayscale, binarize, and resize to a fixed size.
107
+ Returns a 2D uint8 array (0 or 255).
108
+ """
109
+ gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
110
+
111
+ # light background / dark text is common, so threshold with OTSU
112
+ # blur a bit first
113
+ _, bw = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
114
+
115
+ # Sometimes text becomes white on black, sometimes black on white.
116
+ # Let's make "ink" be black (0) and background be white (255).
117
+ # Heuristic: if the mean is low, invert.
118
+ if bw.mean() < 127:
119
+ bw = 255 - bw
120
+
121
+ # resize to fixed size
122
+ bw_resized = cv2.resize(bw, (size, size), interpolation=cv2.INTER_AREA)
123
+
124
+ return bw_resized
125
+
126
+
127
+ @lru_cache(maxsize=1)
128
+ def _load_digit_templates(size=28):
129
+ """
130
+ Load all images from ./digits/ and normalize them to the same shape.
131
+ Returns list of (name_without_ext, normalized_image, full_path).
132
+ Cached so we don’t reload every call — but if we add new digits,
133
+ we can clear the cache.
134
+ """
135
+ entries = []
136
+ for p in DIGITS_DIR.iterdir():
137
+ if not p.is_file():
138
+ continue
139
+ if p.suffix.lower() not in [".png", ".jpg", ".jpeg", ".bmp"]:
140
+ continue
141
+ img = cv2.imread(str(p), cv2.IMREAD_GRAYSCALE)
142
+ if img is None:
143
+ continue
144
+ digit = int(p.stem.split("_")[0])
145
+ # ensure same size / format
146
+ if img.shape != (size, size):
147
+ img = cv2.resize(img, (size, size), interpolation=cv2.INTER_AREA)
148
+ entries.append((digit, img, p))
149
+ return entries
150
+
151
+
152
+ def _save_digit_image(norm_img, label, size=28):
153
+ """
154
+ Save normalized img to ./digits/ as `{label}.png`.
155
+ If file exists, append a counter.
156
+ """
157
+ label = str(label).strip()
158
+ out_path = DIGITS_DIR / f"{label}.png"
159
+ if out_path.exists():
160
+ # find a free name like label_1.png, label_2.png, ...
161
+ i = 1
162
+ while True:
163
+ cand = DIGITS_DIR / f"{label}_{i}.png"
164
+ if not cand.exists():
165
+ out_path = cand
166
+ break
167
+ i += 1
168
+
169
+ cv2.imwrite(str(out_path), norm_img)
170
+ # refresh cache since we added a new file
171
+ _load_digit_templates.cache_clear()
172
+ return out_path
173
+
174
+
175
+ def classify_digit(cell_bgr, visualize=False, size=28, dist_thresh=500):
176
+ """
177
+ 1. normalize the cell
178
+ 2. compare pixel-wise to files in ./digits/
179
+ 3. if close enough -> return that filename (digit)
180
+ 4. else -> ask user in terminal, save, return label
181
+
182
+ Returns the recognized digit as string.
183
+ """
184
+ norm = normalize_cell(cell_bgr, size=size)
185
+
186
+ # load templates
187
+ templates = _load_digit_templates(size=size)
188
+
189
+ best_label = None
190
+ best_dist = float("inf")
191
+
192
+ if templates:
193
+ for label, tmpl_img, _ in templates:
194
+ # simple pixel-wise L1 distance
195
+ dist = np.sum(np.abs(norm.astype(np.int16) - tmpl_img.astype(np.int16)))
196
+ if dist < best_dist:
197
+ best_dist = dist
198
+ best_label = label
199
+
200
+ # if it's close enough, accept
201
+ if best_dist <= dist_thresh:
202
+ return best_label
203
+
204
+ # otherwise we need user input
205
+ if visualize:
206
+ print(f"Unknown digit encountered., best_label: {best_label}, best_dist: {best_dist}, dist_thresh: {dist_thresh}")
207
+ show_wait_destroy("unknown digit", norm)
208
+
209
+ print("\n[Digit classifier] Unknown digit encountered.")
210
+ print(f"Best match dist = {best_dist:.0f} (threshold={dist_thresh}), so asking user.")
211
+ user_label = input("Enter the digit/label for this cell (e.g. 0-9 or letter): ").strip()
212
+ if not user_label:
213
+ user_label = "unknown"
214
+
215
+ saved_path = _save_digit_image(norm, user_label, size=size)
216
+ print(f"Saved new digit template to: {saved_path}")
217
+
218
+ return user_label
219
+
220
+
221
+ def show_wait_destroy(winname, img):
222
+ cv2.imshow(winname, img)
223
+ cv2.moveWindow(winname, 500, 1000)
224
+ cv2.waitKey(0)
225
+ cv2.destroyWindow(winname)
226
+
227
+
228
+
229
+ def cluster_triplets(
230
+ triplets,
231
+ k_min=2,
232
+ k_max=8,
233
+ dim_reduce="pca",
234
+ random_state=42,
235
+ show_plot=True,):
236
+ from sklearn.cluster import KMeans
237
+ from sklearn.decomposition import PCA
238
+ from sklearn.manifold import TSNE
239
+
240
+ X = np.array(triplets, dtype=float)
241
+ n_samples = X.shape[0]
242
+ k_max = min(k_max, n_samples)
243
+ ks = list(range(k_min, k_max + 1))
244
+
245
+ sse = []
246
+ kmeans_models = {}
247
+ for k in ks:
248
+ km = KMeans(n_clusters=k, n_init="auto", random_state=random_state)
249
+ km.fit(X)
250
+ sse.append(km.inertia_)
251
+ kmeans_models[k] = km
252
+
253
+ x1, y1 = ks[0], sse[0]
254
+ x2, y2 = ks[-1], sse[-1]
255
+ distances = []
256
+ for x, y in zip(ks, sse):
257
+ num = abs((y2 - y1) * x - (x2 - x1) * y + x2*y1 - y2*x1)
258
+ den = np.sqrt((y2 - y1)**2 + (x2 - x1)**2)
259
+ distances.append(num / den)
260
+
261
+ best_idx = int(np.argmax(distances))
262
+ best_k = ks[best_idx]
263
+ best_model = kmeans_models[best_k]
264
+ labels = best_model.labels_
265
+
266
+ if show_plot:
267
+ if dim_reduce.lower() == "tsne":
268
+ reducer = TSNE(n_components=2, random_state=random_state, init="pca")
269
+ X_2d = reducer.fit_transform(X)
270
+ else:
271
+ reducer = PCA(n_components=2, random_state=random_state)
272
+ X_2d = reducer.fit_transform(X)
273
+ plt.figure(figsize=(6, 5))
274
+ scatter = plt.scatter(
275
+ X_2d[:, 0], X_2d[:, 1],
276
+ c=labels,
277
+ s=50
278
+ )
279
+ plt.title(f"Clusters (k={best_k})")
280
+ plt.xlabel("Component 1")
281
+ plt.ylabel("Component 2")
282
+ plt.colorbar(scatter, label="Cluster")
283
+ plt.tight_layout()
284
+ plt.show()
285
+
286
+ return labels, best_k
287
+
288
+
289
+
290
+ def parse_board(image_path):
291
+ """Main function: detects grid, loops through cells, returns 2D array."""
292
+ global cv2, plt
293
+
294
+ VISUALIZE_LINES = False
295
+
296
+ import cv2
297
+ import matplotlib.pyplot as plt
298
+ assert image_path.exists(), f"Image file does not exist: {image_path}"
299
+ if image_path.suffix.lower() == '.pdf':
300
+ image_bgr = pdf_to_cv_img(image_path)
301
+ else:
302
+ image_bgr = cv2.imread(image_path)
303
+ gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
304
+ edges = cv2.Canny(gray, 50, 150, apertureSize=3)
305
+ if VISUALIZE_LINES:
306
+ show_wait_destroy("edges", edges)
307
+ lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=80, minLineLength=150, maxLineGap=5)
308
+ vert, horiz = [], []
309
+ for l in lines:
310
+ x1, y1, x2, y2 = l[0]
311
+ if abs(x1 - x2) < 5:
312
+ vert.append((x1, y1, x2, y2))
313
+ elif abs(y1 - y2) < 5:
314
+ horiz.append((x1, y1, x2, y2))
315
+
316
+ # add_borders = False
317
+ add_borders = True
318
+ clip_ends = False
319
+ # clip_ends = True
320
+
321
+ x_lines = cluster_positions(vert, axis=0, tol=3)
322
+ y_lines = cluster_positions(horiz, axis=1, tol=3)
323
+ if add_borders:
324
+ x_lines = [0] + x_lines + [image_bgr.shape[1]]
325
+ y_lines = [0] + y_lines + [image_bgr.shape[0]]
326
+ if clip_ends:
327
+ x_lines = x_lines[1:-1]
328
+ y_lines = y_lines[1:-1]
329
+ print(f"x_lines: {x_lines}")
330
+ print(f"y_lines: {y_lines}")
331
+ # visualize the lines
332
+ if VISUALIZE_LINES:
333
+ for x in x_lines:
334
+ cv2.line(image_bgr, (x, 0), (x, image_bgr.shape[0]), (0, 0, 255), 2)
335
+ for y in y_lines:
336
+ cv2.line(image_bgr, (0, y), (image_bgr.shape[1], y), (0, 0, 255), 2)
337
+ show_wait_destroy("image_bgr", image_bgr)
338
+
339
+ n_rows = len(y_lines) - 1
340
+ n_cols = len(x_lines) - 1
341
+ colors = []
342
+ cell_ri_ci = []
343
+ digit_ri_ci = []
344
+ for ri in range(n_rows):
345
+ print('--------------------------------')
346
+ print(f'row: {ri}')
347
+ for ci in range(n_cols):
348
+ print(f'percentage complete: {(ri * n_cols + ci) / (n_rows * n_cols):.1%}')
349
+ # print(f"ri: {ri}, ci: {ci}")
350
+ y1, y2 = y_lines[ri], y_lines[ri + 1]
351
+ x1, x2 = x_lines[ci], x_lines[ci + 1]
352
+ if y2 - y1 <= 0 or x2 - x1 <= 0:
353
+ continue
354
+ cell = image_bgr[y1:y2, x1:x2]
355
+
356
+ # make a mask for “anything that looks like text”
357
+ hsv = cv2.cvtColor(cell, cv2.COLOR_BGR2HSV)
358
+ s = hsv[:, :, 1]
359
+ v = hsv[:, :, 2]
360
+ mask = ((s > 20) | (v < 210)).astype(np.uint8) * 255
361
+ mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8), iterations=1)
362
+
363
+ cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
364
+ cell_area = cell.shape[0] * cell.shape[1]
365
+
366
+ parts = []
367
+ for c in cnts:
368
+ x, y, wc, hc = cv2.boundingRect(c)
369
+ # print(f"x: {x}, y: {y}, wc: {wc}, hc: {hc}")
370
+ if wc * hc > cell_area * 0.6:
371
+ continue
372
+ if wc * hc < 20:
373
+ continue
374
+ if wc < cell.shape[1] * 0.1:
375
+ continue
376
+ if hc < cell.shape[0] * 0.1:
377
+ continue
378
+ parts.append((x, y, wc, hc))
379
+
380
+ if not parts:
381
+ # print(f"no parts for ri: {ri}, ci: {ci}")
382
+ continue
383
+
384
+ color = detect_color_simple(cell)
385
+ assert color is not None, f"color is None for ri: {ri}, ci: {ci}"
386
+ colors.append(color)
387
+ cell_ri_ci.append((ri, ci))
388
+ digit = classify_digit(cell, visualize=True)
389
+ digit_ri_ci.append((ri, ci, digit))
390
+ print(f"digit: {digit}")
391
+ # show_wait_destroy("cell", cell)
392
+
393
+ min_ri = min(ri for ri, _ in cell_ri_ci)
394
+ max_ri = max(ri for ri, _ in cell_ri_ci)
395
+ min_ci = min(ci for _, ci in cell_ri_ci)
396
+ max_ci = max(ci for _, ci in cell_ri_ci)
397
+ num_r = max_ri - min_ri + 1
398
+ num_c = max_ci - min_ci + 1
399
+
400
+ # print(f"colors: {colors}")
401
+ print(len(colors))
402
+ # print(colors)
403
+ l, k = cluster_triplets(colors, show_plot=False)
404
+ random_colors = [tuple(int(x*8)+128 for x in np.random.randint(0, 128//8, 3)) for _ in range(k)]
405
+ board = np.full((num_r, num_c), '', dtype=object)
406
+ for i, (ri, ci) in enumerate(cell_ri_ci):
407
+ label = l[i]
408
+ digit = digit_ri_ci[i][2]
409
+ board[ri-min_ri, ci-min_ci] = f"{label}_{digit}"
410
+ # y1, y2 = y_lines[ri], y_lines[ri + 1]
411
+ # x1, x2 = x_lines[ci], x_lines[ci + 1]
412
+ # circle_x = (x1 + x2) // 2
413
+ # circle_y = (y1 + y2) // 2
414
+ # circle_radius = min(x2 - x1, y2 - y1) // 2
415
+ # cv2.circle(image_bgr, (circle_x, circle_y), circle_radius, random_colors[label], 30)
416
+ # for i, (ri, ci) in enumerate(cell_ri_ci):
417
+ # digit = digit_ri_ci[i][2]
418
+ # y1, y2 = y_lines[ri], y_lines[ri + 1]
419
+ # x1, x2 = x_lines[ci], x_lines[ci + 1]
420
+ # circle_x = (x1 + x2) // 2
421
+ # circle_y = (y1 + y2) // 2
422
+ # cv2.putText(image_bgr, str(digit), (circle_x-5, circle_y+5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (250, 0, 0), 2)
423
+ # show_wait_destroy("image", image_bgr)
424
+ print(board.shape)
425
+ print('[')
426
+ for row in board:
427
+ row = [f"'{c}'" + ' ' * (4 - len(c)) for c in row]
428
+ print(" [ " + ", ".join(row) + " ],")
429
+ print(' ]')
430
+ return board
431
+
432
+ def pdf_to_cv_img(pdf_path, dpi=200, page=0):
433
+ """
434
+ Convert a PDF page to a CV2 BGR image using a given dpi (default 200).
435
+ Uses pdf2image to do the conversion. Assumes pdf2image and pillow are installed.
436
+ Returns a numpy (H, W, 3) dtype=uint8 BGR (cv2) image.
437
+ """
438
+ from pdf2image import convert_from_path
439
+
440
+ # Convert PDF to PIL Images, pick specified page
441
+ pil_images = convert_from_path(str(pdf_path), dpi=dpi, first_page=page+1, last_page=page+1)
442
+ if not pil_images:
443
+ raise RuntimeError(f"No pages rendered from {pdf_path}")
444
+
445
+ pil_img = pil_images[0]
446
+ # Convert PIL RGB image to numpy array, then to BGR for OpenCV
447
+ img = np.array(pil_img)
448
+ if img.ndim == 2: # grayscale, expand to 3 channels
449
+ img = np.stack([img]*3, axis=-1)
450
+ elif img.shape[2] == 4:
451
+ img = img[:,:,:3]
452
+ # PIL is RGB; OpenCV expects BGR
453
+ img = img[:, :, ::-1].copy()
454
+ return img
455
+
456
+ def mark_white_runs(inp_path, out_path):
457
+ img = Image.open(inp_path).convert("RGB")
458
+ img_to_edit = Image.open(inp_path).convert("RGB")
459
+ px = img.load()
460
+ px_to_edit = img_to_edit.load()
461
+ w, h = img.size
462
+ # vertical
463
+ x = 0
464
+ while x < w:
465
+ if all(px[x, y] == (255, 255, 255) for y in range(h)):
466
+ start = x
467
+ x += 1
468
+ while x < w and all(px[x, y] == (255, 255, 255) for y in range(h)):
469
+ x += 1
470
+ mid = (start + x - 1) // 2
471
+ for y in range(h): px_to_edit[mid, y] = (0, 0, 0)
472
+ else:
473
+ x += 1
474
+ # horizontal
475
+ y = 0
476
+ while y < h:
477
+ if all(px[x, y] == (255, 255, 255) for x in range(w)):
478
+ start = y
479
+ y += 1
480
+ while y < h and all(px[x, y] == (255, 255, 255) for x in range(w)):
481
+ y += 1
482
+ mid = (start + y - 1) // 2
483
+ for x in range(w): px_to_edit[x, mid] = (0, 0, 0)
484
+ else:
485
+ y += 1
486
+ img_to_edit.save(out_path)
487
+
488
+
489
+ if __name__ == "__main__":
490
+ # python .\src\puzzle_solver\utils\etc\parser\board_color_digit.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
491
+ # board = parse_board(Path(__file__).parent / 'board.png')
492
+ # board = parse_board(Path(__file__).parent / 'Screenshot 2025-11-04 025046.png')
493
+ # board = parse_board(Path(__file__).parent / 'Screenshot 2025-11-04 030025.png')
494
+ inp = Path(__file__).parent / 'Screenshot 2025-11-05 at 18-41-57 Special Monthly Dominosa.png'
495
+ outp = inp.with_suffix('.marked.png')
496
+ mark_white_runs(inp, outp)
497
+ board = parse_board(outp)