CodeShell.kr - Mosaic

Challenge

640 个 32×32 碎片拼 24×24 网格,允许旋转、禁止镜像。难点在于碎片不是精确裁剪,而且单个种子的结果不稳定,需要多种子共识加独立校验。

Some assembly required

需要一些拼装。

1
https://codeshell.kr/challenges/misc-mosaic/

Solution

pieces/ 下 640 个 32×32 PNG,目标网格 24×24 = 576 格(64 片是干扰片),允许旋转、禁止镜像,拼好是一张 768×768 海报:彩色模糊渐变背景 + 大号白色描边文字。

Step 1:碎片不是逐位精确裁剪:2560 个朝向里只有 3 组左右边缘完全相等。但匹配信号够强:最优/次优距离比中位数 6.02,无并列。多列或曲率度量反而更差(比值降到 1.81),因为海报是大面积平滑渐变。

Step 2:自测暴露了第一个求解器的 bug:它把种子固定在 (0,0) 并把生长限制在 0..23 方框内,方向被截断。改成无边界生长后又散成 49×69。

Step 3:当前用紧凑矩形生长:反复填满当前 bbox 内的空格(按置信度),填满后才朝匹配最好的一侧扩一行/一列,并限制 bbox 不超过 24×24;之后做单槽重指派与两槽交换两轮局部搜索。24×24 全部填满,背景连续。

Step 4:单个种子的结果不稳定(OCR 每次读出的行首都不同),所以改用多种子共识:9 个不同种子各跑一遍求解器,每张结果按 4 个朝向分别 OCR,取含 CodeShell 且字母数最多的朝向。4 个种子收敛到同一行首,另几个给出 CORNER / CORNERS

Step 5:用字符宽度自校验定死字符数(白色像素按列投影分段,段宽 ≈ 33px = 1 字符):

1
2
3
line1 span 324px / 10 chars = 32.4 px/char   CodeShell{
line2 span 528px / 16 chars = 33.0 px/char CORNERS_REMEMBER
line3 span 361px / 11 chars = 32.8 px/char _THE_WHOLE}

三行像素密度一致,第 2 行 528px ÷ 33 只能容纳 16 个字符(不是 15),且分段结构为 C | O | RN | E | RS | _ | RE | M | E | M | B | E | R,与 CORNERS_REMEMBER 逐位吻合。第 2 行中间那段 40px 空隙正好是 1 个字符(下划线),不是之前估计的 2 个。

1
2
3
CodeShell{
CORNERS_REMEMBER
_THE_WHOLE}

Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
#!/usr/bin/env python3
"""CodeShell.kr — misc-mosaic: compact-rectangle jigsaw assembly.

Free-form frontier growth sprawls: slots with a single constraint can look
confident yet be wrong, so the blob drifts and ends up 49x69 instead of 24x24.
This version keeps the assembly a solid rectangle: it repeatedly fills every
empty cell inside the current bounding box (highest confidence first), and only
then expands the box by one row/column on whichever side matches best.
"""

import argparse
from pathlib import Path

import numpy as np
from PIL import Image

PIECES = "extracted/misc-mosaic/pieces"
OUT = "extracted/misc-mosaic/assembled3.png"


class Assembler:
def __init__(self, rows, cols, minconf, maxcost):
ps = sorted(Path(PIECES).glob("*.png"))
arrs = [np.array(Image.open(p).convert("RGB"), dtype=np.int32) for p in ps]
self.O = np.stack([np.rot90(a, k=-r) for a in arrs for r in range(4)])
m = len(self.O)
self.L = self.O[:, :, 0, :].reshape(m, -1)
self.R = self.O[:, :, -1, :].reshape(m, -1)
self.T = self.O[:, 0, :, :].reshape(m, -1)
self.B = self.O[:, -1, :, :].reshape(m, -1)
self.owner = np.arange(m) // 4
self.m = m
self.avail = np.ones(m, dtype=bool)
self.grid = {}
self.rows, self.cols = rows, cols
self.minconf = minconf
self.maxcost = maxcost

def best_for(self, r, c):
left = self.grid.get((r, c - 1))
right = self.grid.get((r, c + 1))
top = self.grid.get((r - 1, c))
bottom = self.grid.get((r + 1, c))
cost = np.zeros(self.m, dtype=np.int64)
ncon = 0
if left is not None:
cost += np.abs(self.L - self.R[left]).sum(axis=1)
ncon += 1
if right is not None:
cost += np.abs(self.R - self.L[right]).sum(axis=1)
ncon += 1
if top is not None:
cost += np.abs(self.T - self.B[top]).sum(axis=1)
ncon += 1
if bottom is not None:
cost += np.abs(self.B - self.T[bottom]).sum(axis=1)
ncon += 1
if ncon == 0:
return None
cost = cost / ncon
cost[~self.avail] = 10**15
j = int(cost.argmin())
bcost = int(cost[j])
cost[self.owner == self.owner[j]] = 10**15
second = int(cost.min())
return j, bcost, (second + 1) / (bcost + 1), ncon

def place(self, r, c, j):
self.grid[(r, c)] = j
self.avail[self.owner == self.owner[j]] = False

def fill_box(self, r0, r1, c0, c1):
placed_any = False
while True:
best = None
for r in range(r0, r1 + 1):
for c in range(c0, c1 + 1):
if (r, c) in self.grid:
continue
res = self.best_for(r, c)
if res is None:
continue
j, bcost, conf, ncon = res
if conf < self.minconf or bcost > self.maxcost:
continue
score = (ncon, conf)
if best is None or score > best[0]:
best = (score, r, c, j, bcost, conf)
if best is None:
break
_, r, c, j, bcost, conf = best
self.place(r, c, j)
placed_any = True
return placed_any


def main():
ap = argparse.ArgumentParser()
ap.add_argument("--rows", type=int, default=24)
ap.add_argument("--cols", type=int, default=24)
ap.add_argument("--minconf", type=float, default=1.15)
ap.add_argument("--maxcost", type=float, default=1500.0)
ap.add_argument("--out", default=OUT)
ap.add_argument("--seed", type=int, default=0,
help="pick the Nth most confident seed pair")
args = ap.parse_args()

A = Assembler(args.rows, args.cols, args.minconf, args.maxcost)
print(f"pieces {len(A.owner)//4} oriented {A.m}")

# seed: the Nth most confident horizontal pair
seeds = []
for i in range(0, A.m, 11):
d = np.abs(A.L - A.R[i]).sum(axis=1)
d[A.owner == A.owner[i]] = 10**15
j = int(d.argmin())
bcost = int(d[j])
d[A.owner == A.owner[j]] = 10**15
conf = (int(d.min()) + 1) / (bcost + 1)
seeds.append((conf, i, j))
seeds.sort(reverse=True)
conf, a, b = seeds[min(args.seed, len(seeds) - 1)]
print(f"seed #{args.seed} {a}->{b} conf {conf:.2f}")
A.place(0, 0, a)
A.place(0, 1, b)

r0 = r1 = 0
c0, c1 = 0, 1
total = args.rows * args.cols

while len(A.grid) < total:
A.fill_box(r0, r1, c0, c1)
if len(A.grid) >= total:
break
# try expanding one side; score the side by its best cell
options = []
sides = {
"top": [(r0 - 1, cc) for cc in range(c0, c1 + 1)],
"bottom": [(r1 + 1, cc) for cc in range(c0, c1 + 1)],
"left": [(rr, c0 - 1) for rr in range(r0, r1 + 1)],
"right": [(rr, c1 + 1) for rr in range(r0, r1 + 1)],
}
# never let the box exceed the declared 24x24 grid
if r1 - r0 + 1 >= args.rows:
sides.pop("top", None)
sides.pop("bottom", None)
if c1 - c0 + 1 >= args.cols:
sides.pop("left", None)
sides.pop("right", None)
for side, cells in sides.items():
best = None
for (rr, cc) in cells:
if (rr, cc) in A.grid:
continue
res = A.best_for(rr, cc)
if res is None:
continue
j, bcost, cf, ncon = res
if cf < A.minconf or bcost > A.maxcost:
continue
if best is None or cf > best:
best = cf
if best is not None:
options.append((best, side))
if not options:
print(f"stop: no expandable side (placed {len(A.grid)})")
break
options.sort(reverse=True)
side = options[0][1]
if side == "top":
r0 -= 1
elif side == "bottom":
r1 += 1
elif side == "left":
c0 -= 1
else:
c1 += 1
print(f"expand {side}: box {r1-r0+1}x{c1-c0+1} placed {len(A.grid)} "
f"conf {options[0][0]:.2f}")

h, w = r1 - r0 + 1, c1 - c0 + 1
print(f"placed {len(A.grid)} tiles, bbox {h}x{w} "
f"(target {args.rows}x{args.cols})")

# fill any remaining cell with the best available piece, then refine
A.minconf = 0.0
A.maxcost = float("inf")
A.fill_box(r0, r1, c0, c1)
print(f"after force-fill: {len(A.grid)} tiles")

refine(A, r0, r1, c0, c1, passes=6)
cells = [(r, c) for r in range(r0, r1 + 1) for c in range(c0, c1 + 1)]
refine_swaps(A, cells, passes=6)

canvas = np.zeros((h * 32, w * 32, 3), dtype=np.uint8)
for (r, c), o in A.grid.items():
canvas[(r - r0) * 32:(r - r0 + 1) * 32, (c - c0) * 32:(c - c0 + 1) * 32] = A.O[o]
Image.fromarray(canvas).save(args.out)
print(f"wrote {args.out} ({canvas.shape[1]}x{canvas.shape[0]})")


def cell_costs_all(A, r, c):
"""Vectorised local cost of every oriented piece at slot (r,c)."""
cost = np.zeros(A.m, dtype=np.int64)
left = A.grid.get((r, c - 1))
right = A.grid.get((r, c + 1))
top = A.grid.get((r - 1, c))
bottom = A.grid.get((r + 1, c))
if left is not None:
cost += np.abs(A.L - A.R[left]).sum(axis=1)
if right is not None:
cost += np.abs(A.R - A.L[right]).sum(axis=1)
if top is not None:
cost += np.abs(A.T - A.B[top]).sum(axis=1)
if bottom is not None:
cost += np.abs(A.B - A.T[bottom]).sum(axis=1)
return cost


def local_cost_at(A, r, c, o):
"""Edge cost of piece o sitting at slot (r,c) against placed neighbours."""
tot = 0
left = A.grid.get((r, c - 1))
right = A.grid.get((r, c + 1))
top = A.grid.get((r - 1, c))
bottom = A.grid.get((r + 1, c))
if left is not None:
tot += int(np.abs(A.L[o] - A.R[left]).sum())
if right is not None:
tot += int(np.abs(A.R[o] - A.L[right]).sum())
if top is not None:
tot += int(np.abs(A.T[o] - A.B[top]).sum())
if bottom is not None:
tot += int(np.abs(A.B[o] - A.T[bottom]).sum())
return tot


def refine_swaps(A, cells, passes=4):
"""Local search allowing both substitution and swaps between two slots."""
for p in range(passes):
pos = {}
for rc, o in A.grid.items():
pos[A.owner[o]] = rc
changed = 0
for (r, c) in cells:
cur = A.grid[(r, c)]
base = local_cost_at(A, r, c, cur)
cost = cell_costs_all(A, r, c)
for o in np.argsort(cost)[:40]:
o = int(o)
if o == cur:
break
if cost[o] >= base:
break # cannot beat the incumbent
oc = A.owner[o]
if oc not in pos: # unused piece: substitute
A.grid[(r, c)] = o
pos.pop(A.owner[cur], None)
pos[oc] = (r, c)
changed += 1
break
r2, c2 = pos[oc]
if (r2, c2) == (r, c):
break
other = A.grid[(r2, c2)]
base2 = local_cost_at(A, r2, c2, other)
A.grid[(r, c)] = o
A.grid[(r2, c2)] = cur
if local_cost_at(A, r, c, o) + local_cost_at(A, r2, c2, cur) < base + base2:
pos[oc] = (r, c)
pos[A.owner[cur]] = (r2, c2)
changed += 1
break
A.grid[(r, c)] = cur
A.grid[(r2, c2)] = other
print(f"swap pass {p}: {changed} slots changed")
if changed == 0:
break


def refine(A, r0, r1, c0, c1, passes=6):
"""Local search: reassign cells to the best-fitting piece/rotation."""
cells = [(r, c) for r in range(r0, r1 + 1) for c in range(c0, c1 + 1)]
for p in range(passes):
occupied = {}
for rc, o in A.grid.items():
occupied[A.owner[o]] = rc
changed = 0
for (r, c) in cells:
cur = A.grid[(r, c)]
cost = cell_costs_all(A, r, c)
order = np.argsort(cost)
pick = None
for o in order[:60]:
o = int(o)
if o == cur:
pick = cur
break
oc = A.owner[o]
if oc in occupied and occupied[oc] != (r, c):
continue
pick = o
break
if pick is None:
continue
if pick != cur:
A.grid[(r, c)] = pick
occupied.pop(A.owner[cur], None)
occupied[A.owner[pick]] = (r, c)
changed += 1
print(f"refine pass {p}: {changed} cells changed")
if changed == 0:
break


if __name__ == "__main__":
main()
CodeShell{CORNERS_REMEMBER_THE_WHOLE}