Coverage for src / puzzletree / reconstruct / core.py: 97.47%
226 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-12 20:35 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-12 20:35 +0000
1from __future__ import annotations
3from collections.abc import Sequence
4from typing import Dict, List, Tuple
6import numpy as np
8SideEdge = Tuple[int, int] # (side_label, neighbor_index)
9AdjList = List[List[SideEdge]]
10Coord = Tuple[int, int]
11COMPONENT_GAP_TILES = 3
14def reverse_side(side: int) -> int:
15 return {1: 2, 2: 1, 3: 4, 4: 3}[side]
18def var2(arr: np.ndarray) -> float:
19 if arr.ndim == 1: 19 ↛ 20line 19 didn't jump to line 20 because the condition on line 19 was never true
20 return float(np.var(arr))
21 return float(np.var(arr, axis=0).sum())
24def gaussian_filter1d_nearest(arr: np.ndarray, sigma: float, axis: int = 0) -> np.ndarray:
25 if sigma <= 0: 25 ↛ 26line 25 didn't jump to line 26 because the condition on line 25 was never true
26 return arr.astype(np.float64, copy=False)
28 radius = max(1, int(round(4.0 * sigma)))
29 offsets = np.arange(-radius, radius + 1, dtype=np.float64)
30 kernel = np.exp(-(offsets**2) / (2.0 * sigma * sigma))
31 kernel /= kernel.sum()
33 moved = np.moveaxis(arr.astype(np.float64, copy=False), axis, 0)
34 pad_width = [(radius, radius)] + [(0, 0)] * (moved.ndim - 1)
35 padded = np.pad(moved, pad_width, mode="edge")
37 smoothed = np.empty_like(moved, dtype=np.float64)
38 for idx in range(moved.shape[0]):
39 window = padded[idx : idx + kernel.size]
40 smoothed[idx] = np.tensordot(kernel, window, axes=(0, 0))
42 return np.moveaxis(smoothed, 0, axis)
45def corr(edge1: np.ndarray, edge2: np.ndarray, r: float) -> float:
46 diff = gaussian_filter1d_nearest(edge1 - edge2, sigma=r, axis=0)
47 summ = gaussian_filter1d_nearest(edge1 + edge2, sigma=r, axis=0)
48 denom = max(var2(summ), 1e-12)
49 return var2(diff) / denom
52def edge_features(tiles: Sequence[np.ndarray]) -> List[Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]]:
53 # (left, right, top, bottom)
54 return [(t[:, 0, :], t[:, -1, :], t[0, :, :], t[-1, :, :]) for t in tiles]
57def build_weight_matrices(tiles: Sequence[np.ndarray], r: float = 12.0) -> Tuple[np.ndarray, np.ndarray]:
58 n = len(tiles)
59 feats = edge_features(tiles)
60 h, w = tiles[0].shape[:2]
61 lr_sentinel = float(w * w)
62 ud_sentinel = float(h * h)
64 lr = np.empty((n, n), dtype=np.float64)
65 ud = np.empty((n, n), dtype=np.float64)
67 for i in range(n):
68 for j in range(n):
69 if i == j:
70 lr[i, j] = lr_sentinel
71 ud[i, j] = ud_sentinel
72 else:
73 lr[i, j] = corr(feats[i][1], feats[j][0], r)
74 ud[i, j] = corr(feats[i][3], feats[j][2], r)
75 return lr, ud
78def directed_to_unlabeled(adjs: AdjList) -> List[List[int]]:
79 return [[nbr for _, nbr in edges] for edges in adjs]
82def fillin(adj: List[List[int]]) -> List[List[int]]:
83 out = [neighbors[:] for neighbors in adj]
84 for node, neighbors in enumerate(adj):
85 for nbr in neighbors:
86 if node not in out[nbr]: 86 ↛ 85line 86 didn't jump to line 85 because the condition on line 86 was always true
87 out[nbr].append(node)
88 return out
91def fillin2(adjs: AdjList) -> AdjList:
92 out: AdjList = [[(side, nbr) for side, nbr in edges] for edges in adjs]
93 for node, edges in enumerate(adjs):
94 for side, nbr in edges:
95 rev = reverse_side(side)
96 rev_edge = (rev, node)
97 if rev_edge not in out[nbr]: 97 ↛ 94line 97 didn't jump to line 94 because the condition on line 97 was always true
98 out[nbr].append(rev_edge)
99 return out
102def ita_path(in_adjgraph: AdjList, point1: int, point2: int) -> bool:
103 adjgraph = fillin(directed_to_unlabeled(in_adjgraph))
104 marked = [False] * len(adjgraph)
105 marked[point1] = True
106 stack = list(adjgraph[point1])
108 if point1 == point2: 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true
109 return True
111 while stack:
112 current = stack.pop()
113 if not marked[current]:
114 if current == point2:
115 return True
116 marked[current] = True
117 stack.extend(adjgraph[current])
119 return False
122def charged_path(in_adjgraph: AdjList, point1: int, charge: Coord) -> bool:
123 adjgraph = fillin2(in_adjgraph)
124 marked = [False] * len(adjgraph)
125 parent: List[Tuple[Coord, int] | None] = [None] * len(adjgraph)
127 marked[point1] = True
128 origin = (0, 0)
129 if origin == charge:
130 return True
132 stack = list(adjgraph[point1])
133 for _, nbr in adjgraph[point1]:
134 parent[nbr] = (origin, point1)
136 side_to_delta = {
137 1: (-1, 0),
138 2: (1, 0),
139 3: (0, 1),
140 4: (0, -1),
141 }
143 while stack:
144 side, current = stack.pop()
145 if marked[current]:
146 continue
148 current_parent = parent[current]
149 base = current_parent[0] if current_parent is not None else origin
150 add = side_to_delta[side]
151 current_charge = (base[0] + add[0], base[1] + add[1])
153 if current_charge == charge:
154 return True
156 marked[current] = True
157 for edge in adjgraph[current]:
158 stack.append(edge)
159 parent[edge[1]] = (current_charge, current)
161 return False
164def chargeds(in_adjgraph: AdjList, point1: int) -> List[Tuple[int, Coord]]:
165 adjgraph = fillin2(in_adjgraph)
166 marked = [False] * len(adjgraph)
167 parent: List[Tuple[Coord, int] | None] = [None] * len(adjgraph)
169 marked[point1] = True
170 out: List[Tuple[int, Coord]] = [(point1, (0, 0))]
172 stack = list(adjgraph[point1])
173 for _, nbr in adjgraph[point1]:
174 parent[nbr] = ((0, 0), point1)
176 side_to_delta = {
177 1: (-1, 0),
178 2: (1, 0),
179 3: (0, 1),
180 4: (0, -1),
181 }
183 while stack:
184 side, current = stack.pop()
185 if marked[current]:
186 continue
188 current_parent = parent[current]
189 base = current_parent[0] if current_parent is not None else (0, 0)
190 add = side_to_delta[side]
191 current_charge = (base[0] + add[0], base[1] + add[1])
192 out.append((current, current_charge))
194 marked[current] = True
195 for edge in adjgraph[current]:
196 stack.append(edge)
197 parent[edge[1]] = (current_charge, current)
199 return out
202def edge_label_exists(edges: List[SideEdge], label: int) -> bool:
203 return any(side == label for side, _ in edges)
206def global_edge_exists(adjs: AdjList, label: int, node: int) -> bool:
207 return any((side == label and nbr == node) for edges in adjs for side, nbr in edges)
210def clone_adjs(adjs: AdjList) -> AdjList:
211 return [[(side, nbr) for side, nbr in edges] for edges in adjs]
214def msgt(
215 leftright: np.ndarray,
216 updown: np.ndarray,
217 minset: float = 50.0,
218 lr_side_size: int = 180,
219 ud_side_size: int = 72,
220 record_history: bool = False,
221) -> AdjList | Tuple[AdjList, List[AdjList]]:
222 n = leftright.shape[0]
223 lr = leftright.copy()
224 ud = updown.copy()
225 adjs: AdjList = [[] for _ in range(n)]
226 history: List[AdjList] | None = [clone_adjs(adjs)] if record_history else None
228 k = n - 1
229 while k > 0:
230 minlr = float(lr.min())
231 minud = float(ud.min())
233 if minlr < minud:
234 i, j = np.unravel_index(int(np.argmin(lr)), lr.shape)
235 lr[i, j] = float(lr_side_size * lr_side_size)
236 directions = (-1, 0)
237 labels = [1, 2]
238 else:
239 i, j = np.unravel_index(int(np.argmin(ud)), ud.shape)
240 ud[i, j] = float(ud_side_size * ud_side_size)
241 directions = (0, 1)
242 labels = [3, 4]
244 location1, location2 = int(i), int(j)
246 if location1 < location2:
247 location1, location2 = location2, location1
248 labels = [labels[1], labels[0]]
249 directions = (-directions[0], -directions[1])
251 if minlr > minset and minud > minset:
252 break
254 if edge_label_exists(adjs[location1], labels[-1]):
255 continue
256 if global_edge_exists(adjs, labels[-1], location2):
257 continue
258 if global_edge_exists(adjs, labels[0], location1):
259 continue
260 if edge_label_exists(adjs[location2], labels[0]):
261 continue
263 if ita_path(adjs, location1, location2):
264 continue
266 charges_l2 = [coord for _, coord in chargeds(adjs, location2)]
267 overlap_l1 = any(
268 charged_path(adjs, location1, (coord[0] - directions[0], coord[1] - directions[1])) for coord in charges_l2
269 )
271 charges_l1 = [coord for _, coord in chargeds(adjs, location1)]
272 overlap_l2 = any(
273 charged_path(adjs, location2, (coord[0] + directions[0], coord[1] + directions[1])) for coord in charges_l1
274 )
276 if not overlap_l1 and not overlap_l2:
277 adjs[location1].append((labels[-1], location2))
278 if history is not None:
279 history.append(clone_adjs(adjs))
280 k -= 1
282 if history is not None:
283 return adjs, history
284 return adjs
287def connected_components(adjs: AdjList) -> List[List[int]]:
288 undirected = fillin(directed_to_unlabeled(adjs))
289 seen = [False] * len(adjs)
290 components: List[List[int]] = []
292 for start in range(len(adjs)):
293 if seen[start]:
294 continue
295 stack = [start]
296 seen[start] = True
297 comp = []
298 while stack:
299 node = stack.pop()
300 comp.append(node)
301 for nbr in undirected[node]:
302 if not seen[nbr]:
303 seen[nbr] = True
304 stack.append(nbr)
305 components.append(comp)
307 return components
310def reconstruct_layout(adjs: AdjList, component_gap: int = COMPONENT_GAP_TILES) -> Dict[int, Coord]:
311 components = connected_components(adjs)
312 components.sort(key=len, reverse=True)
314 placements: Dict[int, Coord] = {}
315 x_offset = 0
317 for comp in components:
318 root = comp[0]
319 comp_coords = {node: coord for node, coord in chargeds(adjs, root)}
320 xs = [coord[0] for coord in comp_coords.values()]
321 ys = [coord[1] for coord in comp_coords.values()]
322 minx, maxx = min(xs), max(xs)
323 miny = min(ys)
325 for node, (x, y) in comp_coords.items():
326 placements[node] = (x - minx + x_offset, y - miny)
328 width = maxx - minx + 1
329 x_offset += width + component_gap
331 return placements