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

1from __future__ import annotations 

2 

3from collections.abc import Sequence 

4from typing import Dict, List, Tuple 

5 

6import numpy as np 

7 

8SideEdge = Tuple[int, int] # (side_label, neighbor_index) 

9AdjList = List[List[SideEdge]] 

10Coord = Tuple[int, int] 

11COMPONENT_GAP_TILES = 3 

12 

13 

14def reverse_side(side: int) -> int: 

15 return {1: 2, 2: 1, 3: 4, 4: 3}[side] 

16 

17 

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()) 

22 

23 

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) 

27 

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() 

32 

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") 

36 

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)) 

41 

42 return np.moveaxis(smoothed, 0, axis) 

43 

44 

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 

50 

51 

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] 

55 

56 

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) 

63 

64 lr = np.empty((n, n), dtype=np.float64) 

65 ud = np.empty((n, n), dtype=np.float64) 

66 

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 

76 

77 

78def directed_to_unlabeled(adjs: AdjList) -> List[List[int]]: 

79 return [[nbr for _, nbr in edges] for edges in adjs] 

80 

81 

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 

89 

90 

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 

100 

101 

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]) 

107 

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 

110 

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]) 

118 

119 return False 

120 

121 

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) 

126 

127 marked[point1] = True 

128 origin = (0, 0) 

129 if origin == charge: 

130 return True 

131 

132 stack = list(adjgraph[point1]) 

133 for _, nbr in adjgraph[point1]: 

134 parent[nbr] = (origin, point1) 

135 

136 side_to_delta = { 

137 1: (-1, 0), 

138 2: (1, 0), 

139 3: (0, 1), 

140 4: (0, -1), 

141 } 

142 

143 while stack: 

144 side, current = stack.pop() 

145 if marked[current]: 

146 continue 

147 

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]) 

152 

153 if current_charge == charge: 

154 return True 

155 

156 marked[current] = True 

157 for edge in adjgraph[current]: 

158 stack.append(edge) 

159 parent[edge[1]] = (current_charge, current) 

160 

161 return False 

162 

163 

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) 

168 

169 marked[point1] = True 

170 out: List[Tuple[int, Coord]] = [(point1, (0, 0))] 

171 

172 stack = list(adjgraph[point1]) 

173 for _, nbr in adjgraph[point1]: 

174 parent[nbr] = ((0, 0), point1) 

175 

176 side_to_delta = { 

177 1: (-1, 0), 

178 2: (1, 0), 

179 3: (0, 1), 

180 4: (0, -1), 

181 } 

182 

183 while stack: 

184 side, current = stack.pop() 

185 if marked[current]: 

186 continue 

187 

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)) 

193 

194 marked[current] = True 

195 for edge in adjgraph[current]: 

196 stack.append(edge) 

197 parent[edge[1]] = (current_charge, current) 

198 

199 return out 

200 

201 

202def edge_label_exists(edges: List[SideEdge], label: int) -> bool: 

203 return any(side == label for side, _ in edges) 

204 

205 

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) 

208 

209 

210def clone_adjs(adjs: AdjList) -> AdjList: 

211 return [[(side, nbr) for side, nbr in edges] for edges in adjs] 

212 

213 

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 

227 

228 k = n - 1 

229 while k > 0: 

230 minlr = float(lr.min()) 

231 minud = float(ud.min()) 

232 

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] 

243 

244 location1, location2 = int(i), int(j) 

245 

246 if location1 < location2: 

247 location1, location2 = location2, location1 

248 labels = [labels[1], labels[0]] 

249 directions = (-directions[0], -directions[1]) 

250 

251 if minlr > minset and minud > minset: 

252 break 

253 

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 

262 

263 if ita_path(adjs, location1, location2): 

264 continue 

265 

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 ) 

270 

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 ) 

275 

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 

281 

282 if history is not None: 

283 return adjs, history 

284 return adjs 

285 

286 

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]] = [] 

291 

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) 

306 

307 return components 

308 

309 

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) 

313 

314 placements: Dict[int, Coord] = {} 

315 x_offset = 0 

316 

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) 

324 

325 for node, (x, y) in comp_coords.items(): 

326 placements[node] = (x - minx + x_offset, y - miny) 

327 

328 width = maxx - minx + 1 

329 x_offset += width + component_gap 

330 

331 return placements