Spaces:
Running
Running
Download src/bench/solver.py from arittrabag/Dataset-Maker: direct link, hf CLI and curl.
- Browser
- Download file 14.3 kB
-
https://huggingface.co/spaces/arittrabag/Dataset-Maker/resolve/main/src/bench/solver.py
- Command line
-
hf download hf://spaces/arittrabag/Dataset-Maker/src/bench/solver.py
-
curl -L -o solver.py https://huggingface.co/spaces/arittrabag/Dataset-Maker/resolve/main/src/bench/solver.py
14.3 kB
| """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, | |
| } | |