| | """ |
| | FrozenLakeProcessor - FrozenLake puzzle generation, solving, rendering, and evaluation. |
| | |
| | Grid cells: S=Start, F=Frozen(safe), H=Hole(death), G=Goal |
| | Table chars: @=Start, _=Frozen, #=Hole, *=Goal |
| | |
| | Generation strategy: |
| | - ``generate()``: Pure random + BFS retry. Fine for small grids (≤16). |
| | - ``generate_guided()``: Lay a random walk path first, then fill remaining |
| | cells. Guarantees long paths even at 32×32+ without exponential retries. |
| | - ``generate_auto()``: Auto-select best strategy based on difficulty. |
| | - ``generate_batch()``: Multiprocessing wrapper for high-throughput. |
| | |
| | Solving uses plain BFS (~10× faster than networkx). |
| | """ |
| | import os |
| | import random |
| | import warnings |
| | from collections import deque |
| | from concurrent.futures import ProcessPoolExecutor, as_completed |
| | from typing import List, Tuple, Optional |
| |
|
| | import numpy as np |
| | from PIL import Image, ImageDraw |
| |
|
| | try: |
| | os.environ.setdefault("SDL_AUDIODRIVER", "dummy") |
| | warnings.filterwarnings("ignore", category=UserWarning, module="pygame") |
| | warnings.filterwarnings("ignore", category=DeprecationWarning) |
| | import gymnasium as gym |
| | HAS_GYM = True |
| | except ImportError: |
| | HAS_GYM = False |
| |
|
| | TABLE_TO_GRID = {"@": "S", "_": "F", "#": "H", "*": "G"} |
| | GRID_TO_TABLE = {v: k for k, v in TABLE_TO_GRID.items()} |
| | MOVES = {"U": (-1, 0), "D": (1, 0), "L": (0, -1), "R": (0, 1)} |
| | GYM_ACTION_MAP = {"L": 0, "D": 1, "R": 2, "U": 3} |
| |
|
| | GridDesc = List[str] |
| |
|
| |
|
| | class FrozenLakeProcessor: |
| | """FrozenLake generation, BFS solving, rendering, and evaluation.""" |
| |
|
| | def __init__(self, img_size: int = 512): |
| | self.img_size = img_size |
| | self.path_color = "red" |
| |
|
| | |
| |
|
| | def generate( |
| | self, size: int, p: float = 0.8, |
| | min_path_len: int = 1, max_attempts: int = 500, |
| | ) -> Tuple[GridDesc, List[Tuple[int, int]]]: |
| | """ |
| | Random layout + BFS retry. Good for small grids or low min_path_len. |
| | |
| | For large grids with long path requirements, use ``generate_guided()``. |
| | """ |
| | for _ in range(max_attempts): |
| | desc = self._random_layout(size, p) |
| | path = self.solve(desc) |
| | if path is not None and (len(path) - 1) >= min_path_len: |
| | return desc, path |
| | raise RuntimeError( |
| | f"Failed after {max_attempts} attempts " |
| | f"(size={size}, p={p}, min_path_len={min_path_len})." |
| | ) |
| |
|
| | @staticmethod |
| | def _random_layout(size: int, p: float = 0.8) -> GridDesc: |
| | all_coords = [(r, c) for r in range(size) for c in range(size)] |
| | start, goal = random.sample(all_coords, 2) |
| | grid = [] |
| | for r in range(size): |
| | row = [] |
| | for c in range(size): |
| | if (r, c) == start: |
| | row.append("S") |
| | elif (r, c) == goal: |
| | row.append("G") |
| | else: |
| | row.append("F" if random.random() < p else "H") |
| | grid.append("".join(row)) |
| | return grid |
| |
|
| | |
| |
|
| | def simplify_path(self, path: List[Tuple[int, int]]) -> List[Tuple[int, int]]: |
| | """ |
| | Reduce the path |
| | """ |
| | if not path: |
| | return path |
| | |
| | simplified = [path[0]] |
| | curr_idx = 0 |
| | |
| | while curr_idx < len(path) - 1: |
| | found_shortcut = False |
| | for next_idx in range(len(path) - 1, curr_idx + 1, -1): |
| | r1, c1 = path[curr_idx] |
| | r2, c2 = path[next_idx] |
| | |
| | if abs(r1 - r2) + abs(c1 - c2) == 1: |
| | simplified.append(path[next_idx]) |
| | curr_idx = next_idx |
| | found_shortcut = True |
| | break |
| | |
| | if not found_shortcut: |
| | curr_idx += 1 |
| | simplified.append(path[curr_idx]) |
| | |
| | return simplified |
| | |
| | def generate_guided( |
| | self, size: int, p: float = 0.8, |
| | min_path_len: int = 1, max_attempts: int = 100, |
| | ) -> Tuple[GridDesc, List[Tuple[int, int]]]: |
| | """ |
| | Path-first generation using DFS spanning tree diameter. |
| | |
| | The walk is a valid S→G path by construction (all walk cells are |
| | Frozen, all others are Holes). We return the walk directly as |
| | the solution path — it may not be the BFS-shortest, but it IS a |
| | valid path of guaranteed minimum length. |
| | """ |
| | for _ in range(max_attempts): |
| | desc, walk = self._guided_layout(size, p, min_path_len) |
| | if desc is None: |
| | continue |
| | optimized_walk = self.simplify_path(walk) |
| | if len(optimized_walk) - 1 >= min_path_len: |
| | return desc, optimized_walk |
| | raise RuntimeError( |
| | f"Guided generation failed after {max_attempts} attempts " |
| | f"(size={size}, p={p}, min_path_len={min_path_len})." |
| | ) |
| |
|
| | def _guided_layout( |
| | self, size: int, p: float, min_path_len: int, |
| | ) -> Tuple[Optional[GridDesc], Optional[List[Tuple[int, int]]]]: |
| | """ |
| | Build grid with a guaranteed long path using a DFS spanning tree. |
| | |
| | Strategy: |
| | 1. Build random spanning tree of the grid via DFS. |
| | 2. Find tree diameter (longest path) via double-BFS — guaranteed |
| | unique path, no shortcuts possible. |
| | 3. Trim to desired length if much longer than needed. |
| | 4. Cells adjacent to ≥2 walk cells but OFF the walk become Holes |
| | (deterministically blocks all shortcuts). |
| | 5. Remaining off-path cells are cosmetically filled with p. |
| | |
| | Because tree paths are unique, the BFS shortest path in the resulting |
| | grid equals the walk length (no shortcuts exist). |
| | """ |
| | dirs = [(0, 1), (0, -1), (1, 0), (-1, 0)] |
| |
|
| | |
| | adj: dict = {(r, c): [] for r in range(size) for c in range(size)} |
| | vis = [[False] * size for _ in range(size)] |
| | sr, sc = random.randrange(size), random.randrange(size) |
| | vis[sr][sc] = True |
| | stack = [(sr, sc)] |
| |
|
| | while stack: |
| | r, c = stack[-1] |
| | nbrs = [] |
| | for dr, dc in dirs: |
| | nr, nc = r + dr, c + dc |
| | if 0 <= nr < size and 0 <= nc < size and not vis[nr][nc]: |
| | nbrs.append((nr, nc)) |
| | if nbrs: |
| | nr, nc = random.choice(nbrs) |
| | vis[nr][nc] = True |
| | adj[(r, c)].append((nr, nc)) |
| | adj[(nr, nc)].append((r, c)) |
| | stack.append((nr, nc)) |
| | else: |
| | stack.pop() |
| |
|
| | |
| | def _bfs_far(start): |
| | dist = {start: 0} |
| | q = deque([start]) |
| | far = start |
| | while q: |
| | node = q.popleft() |
| | for nb in adj[node]: |
| | if nb not in dist: |
| | dist[nb] = dist[node] + 1 |
| | q.append(nb) |
| | if dist[nb] > dist[far]: |
| | far = nb |
| | return far, dist |
| |
|
| | end1, _ = _bfs_far((sr, sc)) |
| | end2, dist1 = _bfs_far(end1) |
| |
|
| | if dist1[end2] < min_path_len: |
| | return None, None |
| |
|
| | |
| | prev = {end1: None} |
| | q = deque([end1]) |
| | while q: |
| | node = q.popleft() |
| | if node == end2: |
| | break |
| | for nb in adj[node]: |
| | if nb not in prev: |
| | prev[nb] = node |
| | q.append(nb) |
| |
|
| | walk = [] |
| | cur = end2 |
| | while cur is not None: |
| | walk.append(cur) |
| | cur = prev[cur] |
| | walk.reverse() |
| |
|
| | |
| | if len(walk) - 1 > min_path_len * 2: |
| | excess = len(walk) - 1 - min_path_len |
| | trim = random.randint(0, excess // 2) |
| | if trim > 0: |
| | walk = walk[trim:] |
| | excess2 = len(walk) - 1 - min_path_len |
| | trim2 = random.randint(0, excess2 // 2) |
| | if trim2 > 0: |
| | walk = walk[: len(walk) - trim2] |
| |
|
| | start_pos, end_pos = walk[0], walk[-1] |
| | walk_set = set(walk) |
| |
|
| | |
| | walk_nbr_ct: dict = {} |
| | for wr, wc in walk: |
| | for dr, dc in dirs: |
| | nr, nc = wr + dr, wc + dc |
| | if 0 <= nr < size and 0 <= nc < size and (nr, nc) not in walk_set: |
| | walk_nbr_ct[(nr, nc)] = walk_nbr_ct.get((nr, nc), 0) + 1 |
| |
|
| | |
| | |
| | |
| | |
| | grid = [[""] * size for _ in range(size)] |
| | for r in range(size): |
| | for c in range(size): |
| | if (r, c) == start_pos: |
| | grid[r][c] = "S" |
| | elif (r, c) == end_pos: |
| | grid[r][c] = "G" |
| | elif (r, c) in walk_set: |
| | grid[r][c] = "F" |
| | else: |
| | |
| | grid[r][c] = "F" if random.random() < p else "H" |
| |
|
| | return ["".join(row) for row in grid], walk |
| |
|
| | |
| |
|
| | def generate_auto( |
| | self, size: int, p: float = 0.8, |
| | min_path_len: int = 1, max_attempts: int = 200, |
| | ) -> Tuple[GridDesc, List[Tuple[int, int]]]: |
| | """Auto-select: random for easy cases, guided for hard ones.""" |
| | expected_max = size * 1.5 |
| | if min_path_len > expected_max * 0.5: |
| | return self.generate_guided(size, p, min_path_len, max_attempts) |
| | try: |
| | return self.generate(size, p, min_path_len, max_attempts) |
| | except RuntimeError: |
| | return self.generate_guided(size, p, min_path_len, max_attempts) |
| | |
| | |
| |
|
| | @staticmethod |
| | def _generate_one(args: tuple) -> Optional[Tuple[GridDesc, list]]: |
| | """Worker for multiprocessing.""" |
| | size, p, min_path_len, seed = args |
| | random.seed(seed) |
| | proc = FrozenLakeProcessor() |
| | try: |
| | return proc.generate_auto(size, p, min_path_len, max_attempts=200) |
| | except RuntimeError: |
| | return None |
| |
|
| | def generate_batch( |
| | self, size: int, count: int, p: float = 0.8, |
| | min_path_len: int = 1, workers: int = 8, base_seed: int = 42, |
| | ) -> List[Tuple[GridDesc, List[Tuple[int, int]]]]: |
| | """Generate *count* puzzles in parallel.""" |
| | tasks = [(size, p, min_path_len, base_seed + i) for i in range(count * 2)] |
| | results = [] |
| | with ProcessPoolExecutor(max_workers=workers) as executor: |
| | futures = {executor.submit(self._generate_one, t): t for t in tasks} |
| | for future in as_completed(futures): |
| | res = future.result() |
| | if res is not None: |
| | results.append(res) |
| | if len(results) >= count: |
| | executor.shutdown(wait=False, cancel_futures=True) |
| | break |
| | return results[:count] |
| |
|
| | |
| |
|
| | @staticmethod |
| | def solve(desc: GridDesc) -> Optional[List[Tuple[int, int]]]: |
| | """BFS shortest path from S to G, avoiding H.""" |
| | rows, cols = len(desc), len(desc[0]) |
| | start = goal = None |
| | for r in range(rows): |
| | for c in range(cols): |
| | if desc[r][c] == "S": |
| | start = (r, c) |
| | elif desc[r][c] == "G": |
| | goal = (r, c) |
| | if start is None or goal is None: |
| | return None |
| | visited = [[False] * cols for _ in range(rows)] |
| | visited[start[0]][start[1]] = True |
| | queue: deque = deque([(start, [start])]) |
| | while queue: |
| | (r, c), path = queue.popleft() |
| | if (r, c) == goal: |
| | return path |
| | for dr, dc in ((-1, 0), (1, 0), (0, -1), (0, 1)): |
| | nr, nc = r + dr, c + dc |
| | if 0 <= nr < rows and 0 <= nc < cols and not visited[nr][nc]: |
| | if desc[nr][nc] != "H": |
| | visited[nr][nc] = True |
| | queue.append(((nr, nc), path + [(nr, nc)])) |
| | return None |
| |
|
| | |
| |
|
| | @staticmethod |
| | def path_to_udrl(path: List[Tuple[int, int]]) -> str: |
| | moves = [] |
| | for i in range(len(path) - 1): |
| | r1, c1 = path[i] |
| | r2, c2 = path[i + 1] |
| | if r2 < r1: moves.append("U") |
| | elif r2 > r1: moves.append("D") |
| | elif c2 < c1: moves.append("L") |
| | else: moves.append("R") |
| | return "".join(moves) |
| |
|
| | |
| |
|
| | def verify_path_sim(self, desc: GridDesc, udrl: str) -> bool: |
| | rows, cols = len(desc), len(desc[0]) |
| | start = self.find_start(desc) |
| | if start is None: |
| | return False |
| | r, c = start |
| | clean = udrl.replace(",", "").replace(" ", "").strip() |
| | if "Action plan" in clean: |
| | clean = clean.rsplit("Action plan", 1)[-1] |
| | for ch in clean: |
| | if ch not in MOVES: |
| | continue |
| | dr, dc = MOVES[ch] |
| | nr, nc = r + dr, c + dc |
| | if not (0 <= nr < rows and 0 <= nc < cols): |
| | return False |
| | if desc[nr][nc] == "H": |
| | return False |
| | r, c = nr, nc |
| | if desc[nr][nc] == "G": |
| | return True |
| | return desc[r][c] == "G" |
| |
|
| | def verify_path_gym(self, desc: GridDesc, udrl: str) -> bool: |
| | if not HAS_GYM: |
| | return self.verify_path_sim(desc, udrl) |
| | rows, cols = len(desc), len(desc[0]) |
| | try: |
| | env = gym.make( |
| | "FrozenLake-v1", desc=desc, |
| | map_name=f"{rows}x{cols}", is_slippery=False, render_mode=None, |
| | ) |
| | env.reset(seed=42) |
| | success = False |
| | clean = udrl.replace(",", "").replace(" ", "").strip() |
| | if "Action plan" in clean: |
| | clean = clean.rsplit("Action plan", 1)[-1] |
| | for ch in clean: |
| | if ch not in GYM_ACTION_MAP: |
| | continue |
| | _, reward, terminated, truncated, _ = env.step(GYM_ACTION_MAP[ch]) |
| | if terminated or truncated: |
| | success = reward > 0 |
| | break |
| | env.close() |
| | return success |
| | except Exception: |
| | return self.verify_path_sim(desc, udrl) |
| |
|
| | |
| |
|
| | def encode_table(self, desc: GridDesc) -> str: |
| | size = len(desc) |
| | lines = ["| | " + " | ".join(f"Col {i+1}" for i in range(size)) + " |"] |
| | for r in range(size): |
| | mapped = [GRID_TO_TABLE[ch] for ch in desc[r]] |
| | lines.append(f"| Row {r+1} | " + " | ".join(mapped) + " |") |
| | return "\n".join(lines) |
| |
|
| | def decode_table(self, text: str) -> Optional[GridDesc]: |
| | try: |
| | rows = [] |
| | for line in text.strip().splitlines(): |
| | line = line.strip() |
| | if not line or "Col" in line or "---" in line: |
| | continue |
| | parts = [p.strip() for p in line.split("|")] |
| | clean = [p for p in parts if p] |
| | if len(clean) < 2: |
| | continue |
| | row_str = "".join( |
| | TABLE_TO_GRID[ch] for ch in clean[1:] if ch in TABLE_TO_GRID |
| | ) |
| | if row_str: |
| | rows.append(row_str) |
| | return rows if rows else None |
| | except Exception: |
| | return None |
| |
|
| | def save_table(self, filepath: str, desc: GridDesc) -> None: |
| | with open(filepath, "w") as f: |
| | f.write(self.encode_table(desc)) |
| |
|
| | def load_table(self, filepath: str) -> Optional[GridDesc]: |
| | try: |
| | with open(filepath) as f: |
| | return self.decode_table(f.read()) |
| | except Exception: |
| | return None |
| |
|
| | def find_start(self, desc: GridDesc) -> Optional[Tuple[int, int]]: |
| | for r, row in enumerate(desc): |
| | for c, ch in enumerate(row): |
| | if ch == "S": |
| | return (r, c) |
| | return None |
| |
|
| | def fingerprint(self, desc: GridDesc) -> str: |
| | return "".join(desc) |
| |
|
| | |
| |
|
| | def render_gym(self, desc: GridDesc) -> Optional[Image.Image]: |
| | if not HAS_GYM: |
| | return None |
| | try: |
| | env = gym.make( |
| | "FrozenLake-v1", desc=desc, |
| | is_slippery=False, render_mode="rgb_array", |
| | ) |
| | env.reset() |
| | rgb = env.render() |
| | env.close() |
| | return Image.fromarray(rgb).resize( |
| | (self.img_size, self.img_size), Image.NEAREST |
| | ) |
| | except Exception: |
| | return None |
| |
|
| | def render_simple(self, desc: GridDesc) -> Image.Image: |
| | """Float-aligned renderer (handles non-power-of-2 sizes correctly).""" |
| | size = len(desc) |
| | cell_f = self.img_size / size |
| | img = Image.new("RGB", (self.img_size, self.img_size), (255, 255, 255)) |
| | draw = ImageDraw.Draw(img) |
| | colors = { |
| | "S": (0, 0, 255), "F": (200, 220, 255), |
| | "H": (80, 80, 80), "G": (0, 200, 0), |
| | } |
| | for r in range(size): |
| | for c in range(size): |
| | x0 = int(round(c * cell_f)) |
| | y0 = int(round(r * cell_f)) |
| | x1 = int(round((c + 1) * cell_f)) - 1 |
| | y1 = int(round((r + 1) * cell_f)) - 1 |
| | draw.rectangle( |
| | [x0, y0, x1, y1], |
| | fill=colors.get(desc[r][c], (200, 220, 255)), |
| | ) |
| | for i in range(size + 1): |
| | pos = int(round(i * cell_f)) |
| | draw.line([(pos, 0), (pos, self.img_size)], fill="black", width=1) |
| | draw.line([(0, pos), (self.img_size, pos)], fill="black", width=1) |
| | return img |
| |
|
| | def render(self, desc: GridDesc, use_gym: bool = True) -> Image.Image: |
| | if use_gym: |
| | img = self.render_gym(desc) |
| | if img is not None: |
| | return img |
| | return self.render_simple(desc) |
| |
|
| | def draw_solution_line( |
| | self, image: Image.Image, path: List[Tuple[int, int]], grid_size: int, |
| | ) -> Image.Image: |
| | draw = ImageDraw.Draw(image) |
| | w, h = image.size |
| | cw, ch_ = w / grid_size, h / grid_size |
| | pts = [(c * cw + cw / 2, r * ch_ + ch_ / 2) for r, c in path] |
| | draw.line(pts, fill=self.path_color, width=max(1, int(cw / 4)), joint="curve") |
| | return image |
| |
|
| | |
| |
|
| | def generate_video_frames( |
| | self, desc: GridDesc, path: List[Tuple[int, int]], |
| | n_start: int = 5, m_end: int = 5, |
| | frames: Optional[int] = None, use_gym: bool = True, |
| | ) -> List[Image.Image]: |
| | size = len(desc) |
| | n_steps = len(path) - 1 |
| | base_img = self.render(desc, use_gym=use_gym) |
| | if n_steps <= 0: |
| | return [base_img] * (n_start + m_end + 1) |
| | content = frames if frames is not None else n_steps |
| | content = max(1, content) |
| | result = [base_img.copy() for _ in range(n_start)] |
| |
|
| | def _partial(steps): |
| | return self.draw_solution_line(base_img.copy(), path[:steps+1], size) |
| |
|
| | if content == n_steps: |
| | for s in range(1, n_steps + 1): |
| | result.append(_partial(s)) |
| | elif content > n_steps: |
| | for s in range(1, n_steps + 1): |
| | lo = (s - 1) * content // n_steps |
| | hi = s * content // n_steps |
| | frame = _partial(s) |
| | result.append(frame) |
| | for _ in range(hi - lo - 1): |
| | result.append(frame.copy()) |
| | else: |
| | for f in range(content): |
| | result.append(_partial((f + 1) * n_steps // content)) |
| |
|
| | result.extend([_partial(n_steps).copy() for _ in range(m_end)]) |
| | return result |
| |
|
| | |
| |
|
| | def extract_path_from_pixels( |
| | self, pixels: np.ndarray, rows: int, cols: int, |
| | start: Tuple[int, int], desc: Optional[GridDesc] = None, |
| | pixel_threshold: float = 0.01, |
| | ) -> str: |
| | """Detect red path (float-aligned cells to match renderer).""" |
| | img = Image.fromarray(pixels) |
| | w, h = img.size |
| | px = np.array(img, dtype=float) |
| | r_ch, g_ch, b_ch = px[:, :, 0], px[:, :, 1], px[:, :, 2] |
| | red_mask = (r_ch > 100) & (r_ch > g_ch * 1.2) & (r_ch > b_ch * 1.2) |
| |
|
| | cell_h_f, cell_w_f = h / rows, w / cols |
| | path_grid = np.zeros((rows, cols), dtype=bool) |
| | for r in range(rows): |
| | y0 = int(round(r * cell_h_f)) |
| | y1 = int(round((r + 1) * cell_h_f)) |
| | for c in range(cols): |
| | x0 = int(round(c * cell_w_f)) |
| | x1 = int(round((c + 1) * cell_w_f)) |
| | sub = red_mask[y0:y1, x0:x1] |
| | if sub.size > 0 and np.mean(sub) > pixel_threshold: |
| | path_grid[r, c] = True |
| |
|
| | visited = {start} |
| | cr, cc = start |
| | actions: List[str] = [] |
| | for _ in range(rows * cols * 2): |
| | found = False |
| | for act, (dr, dc) in [("R",(0,1)),("D",(1,0)),("L",(0,-1)),("U",(-1,0))]: |
| | nr, nc = cr + dr, cc + dc |
| | if 0 <= nr < rows and 0 <= nc < cols: |
| | if path_grid[nr, nc] and (nr, nc) not in visited: |
| | visited.add((nr, nc)) |
| | actions.append(act) |
| | cr, cc = nr, nc |
| | found = True |
| | break |
| | if not found: |
| | break |
| | return "".join(actions) |
| |
|
| | def extract_path_from_image(self, img_path, rows, cols, start, desc=None): |
| | try: |
| | pixels = np.array(Image.open(img_path).convert("RGB")) |
| | return self.extract_path_from_pixels(pixels, rows, cols, start, desc) |
| | except Exception: |
| | return "" |
| |
|
| |
|
| | if __name__ == "__main__": |
| | import time |
| |
|
| | proc = FrozenLakeProcessor(img_size=512) |
| |
|
| | |
| | print("=== Yield Rate: random vs guided ===") |
| | for sz in [8, 16, 32]: |
| | min_len = max(1, int(sz * sz * 0.1)) |
| | random.seed(42) |
| | t0 = time.perf_counter() |
| | found_r = 0 |
| | for _ in range(500): |
| | desc = proc._random_layout(sz, 0.8) |
| | path = proc.solve(desc) |
| | if path and (len(path) - 1) >= min_len: |
| | found_r += 1 |
| | t_rand = time.perf_counter() - t0 |
| |
|
| | random.seed(42) |
| | t0 = time.perf_counter() |
| | found_g = 0 |
| | for _ in range(50): |
| | try: |
| | desc, path = proc.generate_guided(sz, 0.8, min_len, max_attempts=5) |
| | found_g += 1 |
| | except RuntimeError: |
| | pass |
| | t_guid = time.perf_counter() - t0 |
| |
|
| | print(f" Size {sz:2d} (min={min_len:3d}): " |
| | f"random={found_r}/500 ({found_r/5:.1f}%) {t_rand:.2f}s | " |
| | f"guided={found_g}/50 ({found_g*2:.0f}%) {t_guid:.2f}s") |
| |
|
| | |
| | print("\n=== generate_auto ===") |
| | for sz in [8, 16, 32, 64]: |
| | min_len = max(1, int(sz * sz * 0.1)) |
| | random.seed(42) |
| | t0 = time.perf_counter() |
| | desc, path = proc.generate_auto(sz, 0.8, min_len) |
| | elapsed = time.perf_counter() - t0 |
| | udrl = proc.path_to_udrl(path) |
| | ok = proc.verify_path_sim(desc, udrl) |
| | print(f" Size {sz:2d}: path={len(path)-1:3d} (min={min_len:3d}) " |
| | f"verify={ok} {elapsed:.3f}s") |
| |
|
| | |
| | print("\n=== Extract round-trip ===") |
| | for sz in [8, 16, 24, 32]: |
| | random.seed(42 + sz) |
| | |
| | min_len = max(1, sz) |
| | try: |
| | desc, path = proc.generate(sz, 0.8, min_len, max_attempts=1000) |
| | except RuntimeError: |
| | desc, path = proc.generate_guided(sz, 0.8, min_len) |
| | img = proc.render(desc, use_gym=False) |
| | sol = proc.draw_solution_line(img.copy(), path, sz) |
| | start = proc.find_start(desc) |
| | extracted = proc.extract_path_from_pixels(np.array(sol), sz, sz, start) |
| | ok = proc.verify_path_sim(desc, extracted) |
| | print(f" Size {sz:2d}: verify={ok} " |
| | f"(GT={len(path)-1}, extracted={len(extracted)})") |
| |
|
| | print("\nAll tests passed ✓") |