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
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-12 20:35 +0000
1from __future__ import annotations
3import math
4import random
5from collections.abc import Sequence
6from pathlib import Path
7from typing import List, Tuple
9import numpy as np
10from PIL import Image
12from puzzletree.reconstruct.core import AdjList, chargeds, connected_components, reconstruct_layout
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")
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)
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)
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]
34 return Image.fromarray(np.clip(canvas * 255.0, 0, 255).astype(np.uint8), mode="RGB")
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
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
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)
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))
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
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)
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]] = []
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.")
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
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
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
126 if not placed:
127 raise RuntimeError("Could not pack all components without overlap; try a larger animation frame size.")
129 return canvas.convert("RGB")
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
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))))
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.")
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.")
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
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
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)
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)
194 return [pad_image_to_square(frame, max_side) for frame in frames]
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 )