File size: 9,386 Bytes
764e34c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334147e
 
 
 
764e34c
 
334147e
764e34c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
"""Rendering utilities for ARC-AGI-3 grids (64x64, 16-color palette).

Vendored from the ARC-AGI toolkit (`arc_agi/rendering.py`) and decoupled to
sit alongside the vendored ``arcengine`` engine in this folder. The core
``frame_to_rgb_array(frame)`` works on a plain 64x64 numpy array, so the
renderer can be used without the engine; ``FrameDataRaw`` is only needed by
the animation convenience functions.
"""

import sys
import time
from typing import Any, Optional, Tuple

from .arcengine import FrameDataRaw
from numpy import ndarray

# Color mapping for frame values (0-15)
COLOR_MAP: dict[int, str] = {
    0: "#FFFFFFFF",  # White
    1: "#CCCCCCFF",  # Off-white
    2: "#999999FF",  # neutral Light
    3: "#666666FF",  # neutral
    4: "#333333FF",  # Off Black
    5: "#000000FF",  # Black
    6: "#E53AA3FF",  # Magenta
    7: "#FF7BCCFF",  # Magenta Light
    8: "#F93C31FF",  # Red
    9: "#1E93FFFF",  # Blue
    10: "#88D8F1FF",  # Blue Light
    11: "#FFDC00FF",  # Yellow
    12: "#FF851BFF",  # Orange
    13: "#921231FF",  # Maroon
    14: "#4FCC30FF",  # Green
    15: "#A356D6FF",  # Purple
}


def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
    """Convert hex color string to RGB tuple.

    Args:
        hex_color: Hex color string (e.g., "#FFFFFFFF").

    Returns:
        RGB tuple (r, g, b).
    """
    # Remove '#' and convert to int
    hex_color = hex_color.lstrip("#")
    # Parse RGBA hex (8 chars) or RGB hex (6 chars)
    if len(hex_color) == 8:
        r = int(hex_color[0:2], 16)
        g = int(hex_color[2:4], 16)
        b = int(hex_color[4:6], 16)
        # Alpha is ignored for display
    else:
        r = int(hex_color[0:2], 16)
        g = int(hex_color[2:4], 16)
        b = int(hex_color[4:6], 16)
    return (r, g, b)


def frame_to_rgb_array(
    steps: int,
    frame: ndarray,
    scale: int = 4,
    color_map: Optional[dict[int, str]] = None,
) -> ndarray:
    """Convert a frame to an RGB numpy array for matplotlib.

    Args:
        frame: 2D numpy array (64x64) with values 0-15.
        scale: Upscaling factor (default 4, so 64x64 -> 256x256).
        color_map: Optional color mapping dict. Uses default if None.

    Returns:
        3D numpy array (height, width, 3) with RGB values.
    """
    import numpy as np

    if color_map is None:
        color_map = COLOR_MAP

    height, width = frame.shape
    upscaled_height = height * scale
    upscaled_width = width * scale

    # Create RGB array
    rgb_array = np.zeros((upscaled_height, upscaled_width, 3), dtype=np.uint8)

    # Fill pixels
    for y in range(height):
        for x in range(width):
            value = int(frame[y, x])
            hex_color = color_map.get(value, "#000000FF")
            rgb = hex_to_rgb(hex_color)

            # Fill the scaled block
            for dy in range(scale):
                for dx in range(scale):
                    rgb_array[y * scale + dy, x * scale + dx] = rgb

    return rgb_array


def render_frames(
    steps: int,
    frame_data: FrameDataRaw,
    default_fps: Optional[int] = None,
    scale: int = 4,
    color_map: Optional[dict[int, str]] = None,
) -> None:
    """Render multiple frames with optional FPS control using matplotlib.

    Args:
        frame_data: FrameDataRaw object containing frame data and other information.
        default_fps: Optional FPS for frame timing. If None, displays immediately.
        scale: Upscaling factor (default 4, so 64x64 -> 256x256).
        color_map: Optional color mapping dict. Uses default if None.
    """
    try:
        import matplotlib.animation as animation
        import matplotlib.pyplot as plt
    except ImportError as exc:
        raise ImportError(
            "matplotlib is required for rendering. Install with: pip install matplotlib"
        ) from exc

    frames = frame_data.frame
    if not frames:
        return

    # Convert frames to RGB arrays
    frame_images = [
        frame_to_rgb_array(steps, frame, scale, color_map) for frame in frames
    ]

    # Calculate interval between frames if FPS is specified
    interval = (1000.0 / default_fps) if default_fps and default_fps > 0 else 100

    # Create figure and axis
    fig, ax = plt.subplots(figsize=(8, 8))
    ax.axis("off")
    ax.set_title("ARC-AGI-3 Environment")

    # Display first frame
    im = ax.imshow(frame_images[0], interpolation="nearest")
    plt.tight_layout()

    def update_frame(frame_num: int) -> Tuple[Any, ...]:
        """Update the displayed frame."""
        if frame_num < len(frame_images):
            im.set_array(frame_images[frame_num])
            return (im,)
        return (im,)

    # Create animation that plays automatically
    # Keep reference to prevent garbage collection
    anim = animation.FuncAnimation(
        fig,
        update_frame,
        frames=len(frame_images),
        interval=interval,
        blit=False,
        repeat=False,
    )

    # Show the plot - animation will play automatically
    # Use non-blocking mode so frames play and code continues
    plt.ion()  # Turn on interactive mode
    plt.show(block=False)

    # Wait for animation to complete (frames * interval + small buffer)
    if interval > 0:
        total_time = len(frame_images) * interval / 1000.0
    else:
        total_time = len(frame_images) * 0.1  # Default 100ms per frame

    # Small initial pause to ensure window appears and animation starts
    time.sleep(0.1)

    # Use plt.pause to process events and let animation play
    # This ensures the animation actually renders
    plt.pause(total_time + 0.1)

    # Keep animation reference alive until we're done
    # Close the figure after animation completes
    plt.close(fig)
    plt.ioff()  # Turn off interactive mode

    # Explicitly delete animation after closing to avoid warnings
    del anim


def rgb_to_ansi(rgb: tuple[int, int, int]) -> str:
    """Convert RGB tuple to ANSI color code.

    Args:
        rgb: RGB tuple (r, g, b).

    Returns:
        ANSI escape sequence for the color.
    """
    r, g, b = rgb
    return f"\033[38;2;{r};{g};{b}m"


def render_frames_terminal(
    steps: int,
    frame_data: FrameDataRaw,
    default_fps: Optional[int] = None,
    scale: int = 1,
    color_map: Optional[dict[int, str]] = None,
    skip_deplay: bool = False,
) -> None:
    """Render frames in the terminal using ANSI color codes, overwriting in place.

    Args:
        frame_data: FrameDataRaw object containing frame data and other information.
        default_fps: Optional FPS for frame timing. If None, displays immediately.
        scale: Scaling factor (default 1, terminal uses 2 chars per pixel for better visibility).
        color_map: Optional color mapping dict. Uses default if None.
    """
    frames = frame_data.frame
    if not frames:
        return

    if color_map is None:
        color_map = COLOR_MAP

    # Check if terminal supports colors
    if not sys.stdout.isatty():
        print(
            "Warning: Not a terminal, colors may not display correctly", file=sys.stderr
        )

    # Calculate delay between frames (default 5 FPS if not specified)
    fps = default_fps if default_fps and default_fps > 0 else 5
    delay = 1.0 / fps

    # ANSI codes
    RESET = "\033[0m"
    HOME = "\033[H"  # Move cursor to home position (top-left)
    HIDE_CURSOR = "\033[?25l"  # Hide cursor
    SHOW_CURSOR = "\033[?25h"  # Show cursor
    # Use block character for better visibility (2 chars wide)
    BLOCK = "██"

    # Get frame dimensions
    height, width = frames[0].shape

    # Build frame strings for all frames (as single strings to reduce flicker)
    frame_strings = []
    for frame_idx, frame in enumerate(frames):
        # Build entire frame as one string
        frame_str = f"Step: {steps} - State: {frame_data.state.name}\n\n"

        # Frame content - build all lines first, then join
        frame_lines = []
        for y in range(height):
            line_parts = []
            for x in range(width):
                value = int(frame[y, x])
                hex_color = color_map.get(value, "#000000FF")
                rgb = hex_to_rgb(hex_color)
                ansi_color = rgb_to_ansi(rgb)
                # Use block character (2 chars) for better visibility
                line_parts.append(f"{ansi_color}{BLOCK}{RESET}")
            frame_lines.append("".join(line_parts))

        frame_str += "\n".join(frame_lines)
        frame_strings.append(frame_str)

    # Hide cursor to reduce flicker
    print(HIDE_CURSOR, end="", flush=True)

    try:
        # First frame: clear screen and display in single operation to reduce flicker
        print(f"{HOME}\033[2J{frame_strings[0]}", end="", flush=True)

        # Update frames in place - single operation per frame to minimize flicker
        for frame_idx in range(1, len(frames)):
            # Single operation: move home + print new frame (no separate operations)
            print(f"{HOME}{frame_strings[frame_idx]}", end="", flush=True)
            # Sleep for 1/fps seconds between frames
            if not skip_deplay:
                time.sleep(delay)
    finally:
        # Always show cursor again
        print(SHOW_CURSOR, end="", flush=True)

    # Reset terminal colors and move cursor below the frame
    print(f"\n{RESET}", end="", flush=True)
    if not skip_deplay:
        time.sleep(delay)