Coverage for src / puzzletree / reconstruct / render.py: 87.80%

153 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-12 20:35 +0000

1from __future__ import annotations 

2 

3import math 

4import random 

5from collections.abc import Sequence 

6from pathlib import Path 

7from typing import List, Tuple 

8 

9import numpy as np 

10from PIL import Image 

11 

12from puzzletree.reconstruct.core import AdjList, chargeds, connected_components, reconstruct_layout 

13 

14 

15def render_reconstruction(tiles: Sequence[np.ndarray], placements: dict[int, tuple[int, int]]) -> Image.Image: 

16 if not placements: 16 ↛ 17line 16 didn't jump to line 17 because the condition on line 16 was never true

17 raise ValueError("No placements available") 

18 

19 h, w = tiles[0].shape[:2] 

20 xs = [coord[0] for coord in placements.values()] 

21 ys = [coord[1] for coord in placements.values()] 

22 minx, maxx = min(xs), max(xs) 

23 miny, maxy = min(ys), max(ys) 

24 

25 canvas_w = (maxx - minx + 1) * w 

26 canvas_h = (maxy - miny + 1) * h 

27 canvas = np.ones((canvas_h, canvas_w, 3), dtype=np.float32) 

28 

29 for idx, (gx, gy) in placements.items(): 

30 x = (gx - minx) * w 

31 y = (maxy - gy) * h 

32 canvas[y : y + h, x : x + w, :] = tiles[idx] 

33 

34 return Image.fromarray(np.clip(canvas * 255.0, 0, 255).astype(np.uint8), mode="RGB") 

35 

36 

37def tile_rgba_images(tiles: Sequence[np.ndarray]) -> List[Image.Image]: 

38 out: List[Image.Image] = [] 

39 for tile in tiles: 

40 rgb = np.clip(tile * 255.0, 0, 255).astype(np.uint8) 

41 out.append(Image.fromarray(rgb, mode="RGB").convert("RGBA")) 

42 return out 

43 

44 

45def component_image(tile_imgs: Sequence[Image.Image], adjs: AdjList, component: Sequence[int]) -> Image.Image: 

46 root = component[0] 

47 comp_set = set(component) 

48 coords = {node: coord for node, coord in chargeds(adjs, root) if node in comp_set} 

49 tile_w, tile_h = tile_imgs[0].size 

50 

51 xs = [c[0] for c in coords.values()] 

52 ys = [c[1] for c in coords.values()] 

53 minx, maxx = min(xs), max(xs) 

54 miny, maxy = min(ys), max(ys) 

55 

56 out_w = (maxx - minx + 1) * tile_w 

57 out_h = (maxy - miny + 1) * tile_h 

58 out = Image.new("RGBA", (out_w, out_h), (255, 255, 255, 0)) 

59 

60 for node, (x, y) in coords.items(): 

61 px = (x - minx) * tile_w 

62 py = (maxy - y) * tile_h 

63 out.alpha_composite(tile_imgs[node], (px, py)) 

64 return out 

65 

66 

67def boxes_overlap(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int], margin: int) -> bool: 

68 ax1, ay1, ax2, ay2 = a 

69 bx1, by1, bx2, by2 = b 

70 return not (ax2 + margin <= bx1 or bx2 + margin <= ax1 or ay2 + margin <= by1 or by2 + margin <= ay1) 

71 

72 

73def pack_images_non_overlapping( 

74 images: Sequence[Image.Image], 

75 canvas_size: Tuple[int, int], 

76 rng: random.Random, 

77 margin: int = 10, 

78 tries_per_image: int = 200, 

79) -> Image.Image: 

80 cw, ch = canvas_size 

81 canvas = Image.new("RGBA", (cw, ch), (255, 255, 255, 255)) 

82 placed_boxes: List[Tuple[int, int, int, int]] = [] 

83 

84 for img in images: 

85 placed = False 

86 iw, ih = img.size 

87 if iw > cw or ih > ch: 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true

88 raise RuntimeError("Could not pack all components without overlap; try a larger animation frame size.") 

89 

90 for use_margin in range(margin, -1, -1): 

91 max_x = cw - iw - use_margin 

92 max_y = ch - ih - use_margin 

93 if max_x < use_margin or max_y < use_margin: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true

94 continue 

95 

96 for _ in range(tries_per_image): 

97 x = rng.randint(use_margin, max_x) 

98 y = rng.randint(use_margin, max_y) 

99 box = (x, y, x + iw, y + ih) 

100 if any(boxes_overlap(box, old, use_margin) for old in placed_boxes): 

101 continue 

102 canvas.alpha_composite(img, (x, y)) 

103 placed_boxes.append(box) 

104 placed = True 

105 break 

106 if placed: 

107 break 

108 

109 stride = max(1, min(8, max(1, use_margin))) 

110 for y in range(use_margin, max_y + 1, stride): 

111 ok = False 

112 for x in range(use_margin, max_x + 1, stride): 

113 box = (x, y, x + iw, y + ih) 

114 if any(boxes_overlap(box, old, use_margin) for old in placed_boxes): 114 ↛ 116line 114 didn't jump to line 116 because the condition on line 114 was always true

115 continue 

116 canvas.alpha_composite(img, (x, y)) 

117 placed_boxes.append(box) 

118 placed = True 

119 ok = True 

120 break 

121 if ok: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true

122 break 

123 if placed: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true

124 break 

125 

126 if not placed: 

127 raise RuntimeError("Could not pack all components without overlap; try a larger animation frame size.") 

128 

129 return canvas.convert("RGB") 

130 

131 

132def minimum_canvas_side(images: Sequence[Image.Image], margin: int) -> int: 

133 if not images: 133 ↛ 134line 133 didn't jump to line 134 because the condition on line 133 was never true

134 return 1 

135 

136 max_dim = max(max(img.size) for img in images) + 2 * margin 

137 total_area = sum((img.size[0] + margin) * (img.size[1] + margin) for img in images) 

138 return max(max_dim, int(math.ceil(math.sqrt(total_area)))) 

139 

140 

141def pack_images_with_growing_canvas( 

142 images: Sequence[Image.Image], 

143 rng: random.Random, 

144 frame_size: int, 

145 margin: int = 12, 

146 max_growth_steps: int = 12, 

147) -> Image.Image: 

148 work_size = max(frame_size, minimum_canvas_side(images, margin)) 

149 for _ in range(max_growth_steps): 149 ↛ 154line 149 didn't jump to line 154 because the loop on line 149 didn't complete

150 try: 

151 return pack_images_non_overlapping(images, (work_size, work_size), rng=rng, margin=margin) 

152 except RuntimeError: 

153 work_size = int(math.ceil(work_size * 1.25)) 

154 raise RuntimeError("Failed to pack animation frame with all components.") 

155 

156 

157def pad_image_to_square(image: Image.Image, side: int) -> Image.Image: 

158 if image.width > side or image.height > side: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true

159 raise ValueError("Image is larger than the requested square canvas.") 

160 

161 canvas = Image.new("RGB", (side, side), (255, 255, 255)) 

162 x = (side - image.width) // 2 

163 y = (side - image.height) // 2 

164 canvas.paste(image, (x, y)) 

165 return canvas 

166 

167 

168def build_tree_animation_frames( 

169 tiles: Sequence[np.ndarray], 

170 history: Sequence[AdjList], 

171 seed: int = 0, 

172 frame_size: int = 1024, 

173 max_angle: float = 35.0, 

174) -> List[Image.Image]: 

175 rng = random.Random(seed) 

176 tile_imgs = tile_rgba_images(tiles) 

177 frames: List[Image.Image] = [] 

178 max_side = frame_size 

179 

180 for adjs in history: 

181 components = connected_components(adjs) 

182 component_imgs: List[Image.Image] = [] 

183 for comp in components: 

184 img = component_image(tile_imgs, adjs, comp) 

185 angle = rng.uniform(-max_angle, max_angle) 

186 rotated = img.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC) 

187 component_imgs.append(rotated) 

188 

189 component_imgs.sort(key=lambda im: im.size[0] * im.size[1], reverse=True) 

190 frame = pack_images_with_growing_canvas(component_imgs, rng=rng, frame_size=frame_size, margin=12) 

191 max_side = max(max_side, frame.width, frame.height) 

192 frames.append(frame) 

193 

194 return [pad_image_to_square(frame, max_side) for frame in frames] 

195 

196 

197def save_tree_build_animation( 

198 tiles: Sequence[np.ndarray], 

199 history: Sequence[AdjList], 

200 output_path: Path, 

201 seed: int = 0, 

202 frame_size: int = 1024, 

203 max_angle: float = 35.0, 

204 duration_ms: int = 1000, 

205 frames_dir: Path | None = None, 

206) -> None: 

207 frames = build_tree_animation_frames( 

208 tiles=tiles, 

209 history=history, 

210 seed=seed, 

211 frame_size=frame_size, 

212 max_angle=max_angle, 

213 ) 

214 if not frames: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true

215 raise ValueError("No animation frames generated") 

216 final_placements = reconstruct_layout(history[-1]) 

217 final_frame = render_reconstruction(tiles, final_placements) 

218 common_side = max( 

219 max(frame.width for frame in frames), 

220 max(frame.height for frame in frames), 

221 final_frame.width, 

222 final_frame.height, 

223 ) 

224 frames = [pad_image_to_square(frame, common_side) for frame in frames] 

225 frames.append(pad_image_to_square(final_frame, common_side)) 

226 if frames_dir is not None: 226 ↛ 232line 226 didn't jump to line 232 because the condition on line 226 was always true

227 frames_dir.mkdir(parents=True, exist_ok=True) 

228 for existing in frames_dir.glob("frame_*.png"): 

229 existing.unlink() 

230 for i, frame in enumerate(frames): 

231 frame.save(frames_dir / f"frame_{i:04d}.png") 

232 frames[0].save( 

233 output_path, 

234 save_all=True, 

235 append_images=frames[1:], 

236 duration=duration_ms, 

237 loop=0, 

238 optimize=False, 

239 )