File size: 14,326 Bytes
c96d602
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
"""edge-greedy v0.1: the dm-bench baseline reassembly solver.

Answer-free by construction: reads only a puzzle directory (puzzle.json +
RGBA fragments) and imports nothing from the generator (tests enforce this).

    1. contour: Moore tracing of each fragment's alpha, resampled every
       STEP px and lightly smoothed; cut into CHUNK-sample windows
    2. pair hypotheses: complementary chunks (one traversed in reverse) are
       aligned by closed-form 2D Kabsch; best TOPK residuals per pair
    3. refine: boundary ICP; score = seam length * fit * colour continuity
       * (1 - overlap)
    4. assemble: greedy spanning placement by score with an overlap check;
       stuck fragments seed new clusters (shifted apart)

Deterministic: float64, no SVD, scores quantised before ranking, ties broken
by fragment ids.
"""
from __future__ import annotations

import json
import math
import os

import numpy as np
from PIL import Image
from scipy.ndimage import binary_erosion, gaussian_filter1d
from scipy.spatial import cKDTree

SOLVER = "edge-greedy"
SOLVER_VERSION = "0.1"
SOLUTION_SCHEMA = "dm-bench/solution/1"

STEP = 2.0            # contour resampling (px)
CHUNK = 48            # samples per matching window (~96 px); tuned on val
STRIDE = 4            # window stride (samples)
TOPK = 12             # hypotheses refined per fragment pair
SEAM_TOL = 4.0        # px: boundary points this close count as seam
MIN_SEAM = 24.0       # px of seam needed to accept a join
MAX_OVERLAP = 0.04    # share of a fragment's pixels allowed on placed ones
CLUSTER_SHIFT = 5000.0
_N8 = ((0, -1), (-1, -1), (-1, 0), (-1, 1), (0, 1), (1, 1), (1, 0), (1, -1))   # (dy, dx), clockwise from W


# ---------------------------------------------------------------------------
# Fragment geometry
# ---------------------------------------------------------------------------
class Frag:
    def __init__(self, fid: str, rgba: np.ndarray):
        self.id = fid
        self.alpha = rgba[..., 3] > 0
        self.rgb = rgba[..., :3].astype(np.float32)
        self.contour = resample(moore_contour(self.alpha), STEP)
        self.smooth = smooth_closed(self.contour, 1.5)
        self.tree = cKDTree(self.smooth) if len(self.smooth) else None
        self.border = straight_runs(self.smooth)
        self.colour = inward_colours(self.smooth, self.alpha, self.rgb)
        n = len(self.smooth)
        starts = [s for s in range(0, n, STRIDE)
                  if n >= CHUNK and not self.border[[(s + k) % n for k in range(CHUNK)]].any()]
        self.windows = np.array([[(s + k) % n for k in range(CHUNK)] for s in starts], dtype=int) \
            .reshape(-1, CHUNK)
        ys, xs = np.nonzero(self.alpha)
        self.pixels = np.stack([xs, ys], axis=1)[::3].astype(float)      # subsample for overlap
        self.inner = binary_erosion(self.alpha, iterations=2)


def straight_runs(pts: np.ndarray, window: int = 48, tol: float = 0.2) -> np.ndarray:
    """Flag contour samples on long straight runs (page borders): they have
    no mate, and two of them would otherwise align as a perfect white seam."""
    n = len(pts)
    flags = np.zeros(n, dtype=bool)
    if n < window:
        return flags
    for s in range(0, n, 4):
        idx = [(s + k) % n for k in range(window)]
        seg = pts[idx] - pts[idx].mean(axis=0)
        # RMS distance to the best-fit line = smallest singular value / sqrt(n)
        cov = seg.T @ seg / window
        tr, det = cov[0, 0] + cov[1, 1], cov[0, 0] * cov[1, 1] - cov[0, 1] ** 2
        small = tr / 2 - math.sqrt(max(tr * tr / 4 - det, 0.0))
        if math.sqrt(max(small, 0.0)) < tol:
            flags[idx] = True
    return flags


def inward_colours(pts: np.ndarray, alpha: np.ndarray, rgb: np.ndarray, depth: float = 2.5) -> np.ndarray:
    """RGB sampled `depth` px inside the fragment along the contour normal
    (boundary pixels are darkened by rotation resampling)."""
    n = len(pts)
    if n < 3:
        return np.zeros((n, 3), np.float32)
    tan = np.roll(pts, -1, axis=0) - np.roll(pts, 1, axis=0)
    tan /= np.maximum(np.linalg.norm(tan, axis=1, keepdims=True), 1e-9)
    normal = np.stack([-tan[:, 1], tan[:, 0]], axis=1)
    h, w = alpha.shape
    out = np.zeros((n, 3), np.float32)
    for sign in (1.0, -1.0):
        q = np.rint(pts + sign * depth * normal).astype(int)
        qx, qy = np.clip(q[:, 0], 0, w - 1), np.clip(q[:, 1], 0, h - 1)
        inside = alpha[qy, qx] & ~out.any(axis=1)
        out[inside] = rgb[qy[inside], qx[inside]]
    return out


def moore_contour(mask: np.ndarray) -> np.ndarray:
    """Ordered outer boundary pixels (x, y), clockwise (Moore tracing).

    Stops when the step out of the start pixel repeats the first step, which
    also terminates on 1-px necks where the start is revisited.
    """
    m = np.pad(mask, 1)
    ys, xs = np.nonzero(m)
    if ys.size == 0:
        return np.zeros((0, 2))
    start = (int(ys[0]), int(xs[0]))                  # top-most, then left-most
    cur, back = start, (start[0], start[1] - 1)       # its west neighbour is background
    pts = [start]
    first_step = None
    for _ in range(8 * int(m.sum()) + 8):
        d0 = _N8.index((back[0] - cur[0], back[1] - cur[1]))
        prev, found = back, None
        for k in range(1, 9):
            dy, dx = _N8[(d0 + k) % 8]
            cand = (cur[0] + dy, cur[1] + dx)
            if m[cand]:
                found = cand
                break
            prev = cand
        if found is None:                             # isolated pixel
            break
        if cur == start:
            if first_step is None:
                first_step = found
            elif found == first_step:
                break
        cur, back = found, prev
        if cur != start:
            pts.append(cur)
    arr = np.array(pts, dtype=float)
    return np.stack([arr[:, 1] - 1, arr[:, 0] - 1], axis=1)


def resample(pts: np.ndarray, step: float) -> np.ndarray:
    if len(pts) < 3:
        return pts
    closed = np.vstack([pts, pts[:1]])
    seg = np.linalg.norm(np.diff(closed, axis=0), axis=1)
    cum = np.concatenate([[0.0], np.cumsum(seg)])
    t = np.arange(0.0, cum[-1], step)
    return np.stack([np.interp(t, cum, closed[:, 0]), np.interp(t, cum, closed[:, 1])], axis=1)


def smooth_closed(pts: np.ndarray, sigma: float) -> np.ndarray:
    if len(pts) < 5:
        return pts
    return np.stack([gaussian_filter1d(pts[:, i], sigma, mode="wrap") for i in (0, 1)], axis=1)


# ---------------------------------------------------------------------------
# Rigid helpers (closed form, deterministic)
# ---------------------------------------------------------------------------
def rigid(angle: float, tx: float, ty: float) -> np.ndarray:
    c, s = math.cos(angle), math.sin(angle)
    return np.array([[c, -s, tx], [s, c, ty], [0.0, 0.0, 1.0]])


def apply(m: np.ndarray, pts: np.ndarray) -> np.ndarray:
    return pts @ m[:2, :2].T + m[:2, 2]


def kabsch(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
    cs, cd = src.mean(axis=0), dst.mean(axis=0)
    s, d = src - cs, dst - cd
    angle = math.atan2(float((s[:, 0] * d[:, 1] - s[:, 1] * d[:, 0]).sum()), float((s * d).sum()))
    m = rigid(angle, 0.0, 0.0)
    m[:2, 2] = cd - m[:2, :2] @ cs
    return m


def batch_kabsch_residual(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
    """RMS residual of the best rigid fit for each (src[k], dst[k]) pair set."""
    s = src - src.mean(axis=1, keepdims=True)
    d = dst - dst.mean(axis=1, keepdims=True)
    cross = (s[..., 0] * d[..., 1] - s[..., 1] * d[..., 0]).sum(axis=1)
    dot = (s * d).sum(axis=(1, 2))
    ang = np.arctan2(cross, dot)
    c, n = np.cos(ang)[:, None], np.sin(ang)[:, None]
    rx = c * s[..., 0] - n * s[..., 1]
    ry = n * s[..., 0] + c * s[..., 1]
    return np.sqrt(((rx - d[..., 0]) ** 2 + (ry - d[..., 1]) ** 2).mean(axis=1))


# ---------------------------------------------------------------------------
# Pair scoring
# ---------------------------------------------------------------------------
def _hypotheses(a: Frag, b: Frag) -> list[np.ndarray]:
    """Rigid maps b -> a from the best complementary chunk alignments."""
    if not len(a.windows) or not len(b.windows):
        return []
    pa = a.smooth[a.windows]                               # (na, CHUNK, 2)
    pb = b.smooth[b.windows][:, ::-1]                      # reversed traversal
    na, nb = len(pa), len(pb)
    src = np.repeat(pb[None], na, axis=0).reshape(na * nb, CHUNK, 2)
    dst = np.repeat(pa[:, None], nb, axis=1).reshape(na * nb, CHUNK, 2)
    res = np.round(batch_kabsch_residual(src, dst), 9)
    order = np.lexsort((np.arange(res.size), res))[:TOPK]
    return [kabsch(src[k], dst[k]) for k in order]


def _icp(a: Frag, b: Frag, t: np.ndarray, iters: int = 6) -> np.ndarray:
    for _ in range(iters):
        dist, idx = a.tree.query(apply(t, b.smooth))
        near = (dist < SEAM_TOL * 2) & ~b.border & ~a.border[idx]
        if near.sum() < 6:
            break
        t = kabsch(b.smooth[near], a.smooth[idx[near]])
    return t


def _colour_diff(a: Frag, b: Frag, seam_b: np.ndarray, idx_a: np.ndarray) -> float:
    """Mean abs RGB difference between matched inward samples of both sides."""
    if not len(seam_b):
        return 255.0
    return float(np.abs(a.colour[idx_a] - b.colour[seam_b]).mean())


def _overlap(a: Frag, b: Frag, t: np.ndarray) -> float:
    p = np.rint(apply(t, b.pixels)).astype(int)
    h, w = a.inner.shape
    ok = (p[:, 0] >= 0) & (p[:, 0] < w) & (p[:, 1] >= 0) & (p[:, 1] < h)
    return float(a.inner[p[ok, 1], p[ok, 0]].sum()) / max(len(p), 1)


def score_pair(a: Frag, b: Frag) -> tuple[float, np.ndarray | None]:
    """Best (quality, map b -> a). Quality 0 = no plausible join."""
    best_q, best_t = 0.0, None
    for t in _hypotheses(a, b):
        t = _icp(a, b, t)
        dist, idx = a.tree.query(apply(t, b.smooth))
        seam = (dist < SEAM_TOL) & ~b.border & ~a.border[idx]
        seam_len = float(seam.sum()) * STEP
        if seam_len < MIN_SEAM:
            continue
        rms = float(np.sqrt((dist[seam] ** 2).mean()))
        ov = _overlap(a, b, t)
        col = _colour_diff(a, b, np.nonzero(seam)[0], idx[seam])
        q = seam_len * math.exp(-(rms / 1.5) ** 2) * math.exp(-col / 40.0) * max(0.0, 1.0 - 10.0 * ov)
        q = round(q, 9)
        if q > best_q:
            best_q, best_t = q, t
    return best_q, best_t


# ---------------------------------------------------------------------------
# Assembly
# ---------------------------------------------------------------------------
class _Occupancy:
    """Placed pixels of one cluster on a fixed grid around the cluster seed."""

    SIZE = 7000

    def __init__(self, origin: np.ndarray):
        self.grid = np.zeros((self.SIZE, self.SIZE), dtype=bool)
        self.off = np.array([self.SIZE / 2, self.SIZE / 2]) - origin[:2, 2]

    def _cells(self, pose: np.ndarray, pts: np.ndarray):
        p = np.rint(apply(pose, pts) + self.off).astype(int)
        ok = (p[:, 0] >= 0) & (p[:, 0] < self.SIZE) & (p[:, 1] >= 0) & (p[:, 1] < self.SIZE)
        return p[ok, 1], p[ok, 0], max(len(p), 1)

    def overlap(self, frag: Frag, pose: np.ndarray) -> float:
        ys, xs, n = self._cells(pose, frag.pixels)
        return float(self.grid[ys, xs].sum()) / n

    def add(self, frag: Frag, pose: np.ndarray) -> None:
        ys, xs = np.nonzero(frag.inner)
        gy, gx, _ = self._cells(pose, np.stack([xs, ys], axis=1).astype(float))
        self.grid[gy, gx] = True


def assemble(frags: dict[str, Frag], pairs: dict[tuple[str, str], tuple[float, np.ndarray]]):
    """Greedy placement. Returns (poses, n_clusters)."""
    edges = []                                   # (-q, a, b, T_b->a)
    for (a, b), (q, t) in pairs.items():
        if t is not None and q > 0:
            edges.append((-q, a, b, t))
            edges.append((-q, b, a, np.linalg.inv(t)))
    edges.sort(key=lambda e: (e[0], e[1], e[2]))
    poses: dict[str, np.ndarray] = {}
    unplaced = set(frags)
    cluster = 0
    while unplaced:
        seed_edges = [e for e in edges if e[1] in unplaced and e[2] in unplaced]
        if not seed_edges:
            for fid in sorted(unplaced):                     # isolated fragments
                poses[fid] = rigid(0.0, CLUSTER_SHIFT * (cluster + 1), 0.0)
                cluster += 1
            break
        _, a, b, t = seed_edges[0]
        base = rigid(0.0, CLUSTER_SHIFT * cluster, 0.0)
        occ = _Occupancy(base)
        poses[a] = base
        occ.add(frags[a], base)
        unplaced.discard(a)
        members = {a}
        grew = True
        while grew:
            grew = False
            for q, p, u, tpu in edges:
                if p in members and u in unplaced:
                    pose = poses[p] @ tpu
                    if occ.overlap(frags[u], pose) <= MAX_OVERLAP:
                        poses[u] = pose
                        occ.add(frags[u], pose)
                        unplaced.discard(u)
                        members.add(u)
                        grew = True
                        break
        cluster += 1
    return poses, cluster


def load_puzzle(puzzle_dir: str) -> tuple[dict, dict[str, Frag]]:
    with open(os.path.join(puzzle_dir, "puzzle.json"), encoding="utf-8") as fh:
        puzzle = json.load(fh)
    frags = {}
    for f in puzzle["fragments"]:
        with Image.open(os.path.join(puzzle_dir, f["file"])) as im:
            frags[f["id"]] = Frag(f["id"], np.array(im.convert("RGBA")))
    return puzzle, frags


def solve(puzzle_dir: str) -> dict:
    puzzle, frags = load_puzzle(puzzle_dir)
    ids = sorted(frags)
    pairs = {}
    for i, a in enumerate(ids):
        for b in ids[i + 1:]:
            pairs[(a, b)] = score_pair(frags[a], frags[b])
    poses, _ = assemble(frags, pairs)
    candidates = {}
    for fid in ids:
        ranked = [((-q), (b if a == fid else a)) for (a, b), (q, _) in pairs.items() if fid in (a, b) and q > 0]
        candidates[fid] = [other for _, other in sorted(ranked)]
    return {
        "schema": SOLUTION_SCHEMA,
        "page_id": puzzle["page_id"],
        "solver": f"{SOLVER}@{SOLVER_VERSION}",
        "fragments": {fid: {"affine": [[float(v) for v in poses[fid][r]] for r in (0, 1)]} for fid in ids},
        "candidates": candidates,
    }