multi-puzzle-solver 0.9.10__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.

@@ -1,211 +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
- import numpy as np
9
- import matplotlib.pyplot as plt
10
- cv = None
11
- Image = None
12
-
13
-
14
- def extract_lines(bw):
15
- # Create the images that will use to extract the horizontal and vertical lines
16
- horizontal = np.copy(bw)
17
- vertical = np.copy(bw)
18
-
19
- cols = horizontal.shape[1]
20
- horizontal_size = cols // 5
21
- # Create structure element for extracting horizontal lines through morphology operations
22
- horizontalStructure = cv.getStructuringElement(cv.MORPH_RECT, (horizontal_size, 1))
23
- horizontal = cv.erode(horizontal, horizontalStructure)
24
- horizontal = cv.dilate(horizontal, horizontalStructure)
25
- horizontal_means = np.mean(horizontal, axis=1)
26
- horizontal_cutoff = np.percentile(horizontal_means, 50)
27
- # location where the horizontal lines are
28
- horizontal_idx = np.where(horizontal_means > horizontal_cutoff)[0]
29
- # print(f"horizontal_idx: {horizontal_idx}")
30
- height = len(horizontal_idx)
31
- # show_wait_destroy("horizontal", horizontal) # this has the horizontal lines
32
-
33
- rows = vertical.shape[0]
34
- verticalsize = rows // 5
35
- # Create structure element for extracting vertical lines through morphology operations
36
- verticalStructure = cv.getStructuringElement(cv.MORPH_RECT, (1, verticalsize))
37
- vertical = cv.erode(vertical, verticalStructure)
38
- vertical = cv.dilate(vertical, verticalStructure)
39
- vertical_means = np.mean(vertical, axis=0)
40
- vertical_cutoff = np.percentile(vertical_means, 50)
41
- vertical_idx = np.where(vertical_means > vertical_cutoff)[0]
42
- # print(f"vertical_idx: {vertical_idx}")
43
- width = len(vertical_idx)
44
- # print(f"height: {height}, width: {width}")
45
- # print(f"vertical_means: {vertical_means}")
46
- # show_wait_destroy("vertical", vertical) # this has the vertical lines
47
-
48
- vertical = cv.bitwise_not(vertical)
49
- # show_wait_destroy("vertical_bit", vertical)
50
-
51
- return horizontal_idx, vertical_idx
52
-
53
- def show_wait_destroy(winname, img):
54
- cv.imshow(winname, img)
55
- cv.moveWindow(winname, 500, 0)
56
- cv.waitKey(0)
57
- cv.destroyWindow(winname)
58
-
59
-
60
- def mean_consecutives(arr: np.ndarray) -> np.ndarray:
61
- """if a sequence of values is consecutive, then average the values"""
62
- sums = []
63
- counts = []
64
- for i in range(len(arr)):
65
- if i == 0:
66
- sums.append(arr[i])
67
- counts.append(1)
68
- elif arr[i] == arr[i-1] + 1:
69
- sums[-1] += arr[i]
70
- counts[-1] += 1
71
- else:
72
- sums.append(arr[i])
73
- counts.append(1)
74
- return np.array(sums) // np.array(counts)
75
-
76
- def dfs(x, y, out, output, current_num):
77
- if x < 0 or x >= out.shape[1] or y < 0 or y >= out.shape[0]:
78
- return
79
- if out[y, x] != ' ':
80
- return
81
- out[y, x] = current_num
82
- if output['top'][y, x] == 0:
83
- dfs(x, y-1, out, output, current_num)
84
- if output['left'][y, x] == 0:
85
- dfs(x-1, y, out, output, current_num)
86
- if output['right'][y, x] == 0:
87
- dfs(x+1, y, out, output, current_num)
88
- if output['bottom'][y, x] == 0:
89
- dfs(x, y+1, out, output, current_num)
90
-
91
- def main(image):
92
- global Image
93
- global cv
94
- from PIL import Image as Image_module
95
- import cv2 as cv_module
96
- Image = Image_module
97
- cv = cv_module
98
-
99
-
100
- image_path = Path(image)
101
- output_path = image_path.parent / (image_path.stem + '.json')
102
- src = cv.imread(image, cv.IMREAD_COLOR)
103
- assert src is not None, f'Error opening image: {image}'
104
- if len(src.shape) != 2:
105
- gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
106
- else:
107
- gray = src
108
- # now the image is in grayscale
109
-
110
- # Apply adaptiveThreshold at the bitwise_not of gray, notice the ~ symbol
111
- gray = cv.bitwise_not(gray)
112
- bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
113
- cv.THRESH_BINARY, 15, -2)
114
- # show_wait_destroy("binary", bw)
115
-
116
- # show_wait_destroy("src", src)
117
- horizontal_idx, vertical_idx = extract_lines(bw)
118
- horizontal_idx = mean_consecutives(horizontal_idx)
119
- vertical_idx = mean_consecutives(vertical_idx)
120
- height = len(horizontal_idx)
121
- width = len(vertical_idx)
122
- print(f"height: {height}, width: {width}")
123
- print(f"horizontal_idx: {horizontal_idx}")
124
- print(f"vertical_idx: {vertical_idx}")
125
- arr = np.zeros((height - 1, width - 1), dtype=object)
126
- output = {'top': arr.copy(), 'left': arr.copy(), 'right': arr.copy(), 'bottom': arr.copy()}
127
- target = 200_000
128
- hists = {'top': {}, 'left': {}, 'right': {}, 'bottom': {}}
129
- for j in range(height - 1):
130
- for i in range(width - 1):
131
- hidx1, hidx2 = horizontal_idx[j], horizontal_idx[j+1]
132
- vidx1, vidx2 = vertical_idx[i], vertical_idx[i+1]
133
- hidx1 = max(0, hidx1 - 2)
134
- hidx2 = min(src.shape[0], hidx2 + 4)
135
- vidx1 = max(0, vidx1 - 2)
136
- vidx2 = min(src.shape[1], vidx2 + 4)
137
- cell = src[hidx1:hidx2, vidx1:vidx2]
138
- mid_x = cell.shape[1] // 2
139
- mid_y = cell.shape[0] // 2
140
- # show_wait_destroy(f"cell_{i}_{j}", cell)
141
- cell = cv.bitwise_not(cell) # invert colors
142
- top = cell[0:10, mid_y-5:mid_y+5]
143
- hists['top'][j, i] = np.sum(top)
144
- left = cell[mid_x-5:mid_x+5, 0:10]
145
- hists['left'][j, i] = np.sum(left)
146
- right = cell[mid_x-5:mid_x+5, -10:]
147
- hists['right'][j, i] = np.sum(right)
148
- bottom = cell[-10:, mid_y-5:mid_y+5]
149
- hists['bottom'][j, i] = np.sum(bottom)
150
-
151
- fig, axs = plt.subplots(2, 2)
152
- axs[0, 0].hist(list(hists['top'].values()), bins=100)
153
- axs[0, 0].set_title('Top')
154
- axs[0, 1].hist(list(hists['left'].values()), bins=100)
155
- axs[0, 1].set_title('Left')
156
- axs[1, 0].hist(list(hists['right'].values()), bins=100)
157
- axs[1, 0].set_title('Right')
158
- axs[1, 1].hist(list(hists['bottom'].values()), bins=100)
159
- axs[1, 1].set_title('Bottom')
160
- target_top = np.mean(list(hists['top'].values()))
161
- target_left = np.mean(list(hists['left'].values()))
162
- target_right = np.mean(list(hists['right'].values()))
163
- target_bottom = np.mean(list(hists['bottom'].values()))
164
- axs[0, 0].axvline(target_top, color='red')
165
- axs[0, 1].axvline(target_left, color='red')
166
- axs[1, 0].axvline(target_right, color='red')
167
- axs[1, 1].axvline(target_bottom, color='red')
168
- # plt.show()
169
- # 1/0
170
- print(f"target_top: {target_top}, target_left: {target_left}, target_right: {target_right}, target_bottom: {target_bottom}")
171
- for j in range(height - 1):
172
- for i in range(width - 1):
173
- if hists['top'][j, i] > target_top:
174
- output['top'][j, i] = 1
175
- if hists['left'][j, i] > target_left:
176
- output['left'][j, i] = 1
177
- if hists['right'][j, i] > target_right:
178
- output['right'][j, i] = 1
179
- if hists['bottom'][j, i] > target_bottom:
180
- output['bottom'][j, i] = 1
181
- print(f"cell_{j}_{i}", end=': ')
182
- print('T' if output['top'][j, i] else '', end='')
183
- print('L' if output['left'][j, i] else '', end='')
184
- print('R' if output['right'][j, i] else '', end='')
185
- print('B' if output['bottom'][j, i] else '', end='')
186
- print(' Sums: ', hists['top'][j, i], hists['left'][j, i], hists['right'][j, i], hists['bottom'][j, i])
187
-
188
- current_count = 0
189
- out = np.full_like(output['top'], ' ', dtype='U2')
190
- for j in range(out.shape[0]):
191
- for i in range(out.shape[1]):
192
- if out[j, i] == ' ':
193
- dfs(i, j, out, output, str(current_count).zfill(2))
194
- current_count += 1
195
-
196
- with open(output_path, 'w') as f:
197
- f.write('[\n')
198
- for i, row in enumerate(out):
199
- f.write(' ' + str(row.tolist()).replace("'", '"'))
200
- if i != len(out) - 1:
201
- f.write(',')
202
- f.write('\n')
203
- f.write(']')
204
- print('output json: ', output_path)
205
-
206
- if __name__ == '__main__':
207
- # to run this script and visualize the output, in the root run:
208
- # python .\src\puzzle_solver\puzzles\stitches\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
209
- # main(Path(__file__).parent / 'input_output' / 'MTM6OSw4MjEsNDAx.png')
210
- # main(Path(__file__).parent / 'input_output' / 'weekly_oct_3rd_2025.png')
211
- main(Path(__file__).parent / 'input_output' / 'star_battle_67f73ff90cd8cdb4b3e30f56f5261f4968f5dac940bc6.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')
@@ -3,6 +3,7 @@ import argparse
3
3
  from typing import Any, Mapping, Tuple
4
4
  import json
5
5
  import sys
6
+ import random
6
7
 
7
8
  import numpy as np
8
9
  from PIL import Image, ImageDraw, ImageFont
@@ -86,6 +87,7 @@ def render_board_image(
86
87
  def get_input():
87
88
  parser = argparse.ArgumentParser()
88
89
  parser.add_argument('--read_stdin', action='store_true')
90
+ parser.add_argument('--clipboard', action='store_true')
89
91
  args = parser.parse_args()
90
92
  if args.read_stdin:
91
93
  # read from stdin until the line starts with "output json: "
@@ -99,6 +101,15 @@ def get_input():
99
101
  with open(json_path, 'r') as f:
100
102
  board = np.array(json.load(f))
101
103
  print(f'read board from {json_path}')
104
+ elif args.clipboard:
105
+ import pyperclip
106
+ pasted = pyperclip.paste()
107
+ print('got from clipboard: ', pasted)
108
+ print('eval: ', eval(pasted))
109
+ board = np.array(eval(pasted))
110
+ assert board.ndim == 2, f'board must be a 2D numpy array, got {board.ndim}'
111
+ assert board.shape[0] > 0, 'board must have at least one row'
112
+ assert board.shape[1] > 0, 'board must have at least one column'
102
113
  else:
103
114
  # with open('src/puzzle_solver/puzzles/stitches/parse_map/input_output/MTM6OSw4MjEsNDAx.json', 'r') as f:
104
115
  # board = np.array(json.load(f))
@@ -122,6 +133,7 @@ def get_input():
122
133
  return board
123
134
 
124
135
  if __name__ == '__main__':
136
+ # to read numpy array from clipboard run: python .\src\puzzle_solver\utils\visualizer.py --clipboard
125
137
  board = get_input()
126
138
  print('Visualizing board:')
127
139
  print('[')
@@ -133,9 +145,11 @@ if __name__ == '__main__':
133
145
  # rcolors = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0), (0, 255, 255), (255, 0, 255), (255, 255, 255), (128, 128, 128)]
134
146
  vs =[0, 128, 255]
135
147
  rcolors = [(v1, v2, v3) for v1 in vs for v2 in vs for v3 in vs if (v1, v2, v3) != (0, 0, 0)]
148
+ random.shuffle(rcolors)
136
149
  nums = set([c.item() for c in np.nditer(board)])
137
150
  colors = {c: rcolors[i % len(rcolors)] for i, c in enumerate(nums)}
138
151
  print(nums)
139
152
  print('max i:', max(nums))
140
- print('skipped:', set(range(int(max(nums)) + 1)) - set(int(i) for i in nums))
153
+ if all(str(c).isdigit() for c in nums):
154
+ print('skipped:', set(range(int(max(nums)) + 1)) - set(int(i) for i in nums))
141
155
  render_board_image(board, colors)