irregular6612 Claude Sonnet 4.6 commited on
Commit
9511d23
·
1 Parent(s): 721a1d8

feat(cp5): viz.png — per-frame titled PNGs via matplotlib Agg

Browse files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

proteus/viz/__init__.py CHANGED
@@ -6,6 +6,7 @@ panel) or to per-frame PNGs. matplotlib (the `viz` extra) is imported lazily by
6
  the PNG writer only, so importing this package stays offline-safe.
7
  """
8
 
 
9
  from proteus.viz.reconstruct import (
10
  FrameMeta,
11
  FrameStep,
@@ -20,4 +21,5 @@ __all__ = [
20
  "TraceReconstructionError",
21
  "reconstruct",
22
  "render_terminal",
 
23
  ]
 
6
  the PNG writer only, so importing this package stays offline-safe.
7
  """
8
 
9
+ from proteus.viz.png import write_pngs
10
  from proteus.viz.reconstruct import (
11
  FrameMeta,
12
  FrameStep,
 
21
  "TraceReconstructionError",
22
  "reconstruct",
23
  "render_terminal",
24
+ "write_pngs",
25
  ]
proteus/viz/png.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Write reconstructed frames as per-frame PNGs (headless, matplotlib Agg).
2
+
3
+ Each frame is upscaled via the vendored frame_to_rgb_array and saved as
4
+ frame_000.png, frame_001.png ... with a one-line title (turn + action). The rich
5
+ side panel is a terminal feature; PNGs stay clean images with a label. matplotlib
6
+ is the optional `viz` extra and is imported lazily so the offline core never
7
+ needs it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Union
14
+
15
+ from proteus.arc_grid.rendering import COLOR_MAP, frame_to_rgb_array
16
+ from proteus.viz.reconstruct import FrameStep
17
+
18
+
19
+ def _title(step: FrameStep) -> str:
20
+ meta = step.meta
21
+ if meta.phase == "cut":
22
+ return f"Cut {meta.index} (pre-roll)"
23
+ mark = "✓" if meta.was_congruent else "✗"
24
+ title = f"Turn {meta.turn_idx}: action={meta.action} {mark}"
25
+ if meta.terminal:
26
+ title += f" [{meta.terminal}]"
27
+ return title
28
+
29
+
30
+ def write_pngs(
31
+ steps: list[FrameStep],
32
+ out_dir: Union[str, Path],
33
+ *,
34
+ scale: int = 32,
35
+ ) -> list[Path]:
36
+ """Render every frame to `<out_dir>/frame_NNN.png`; return the paths in order."""
37
+ import matplotlib
38
+
39
+ matplotlib.use("Agg") # headless: no window, no display required
40
+ import matplotlib.pyplot as plt
41
+
42
+ out_dir = Path(out_dir)
43
+ out_dir.mkdir(parents=True, exist_ok=True)
44
+ paths: list[Path] = []
45
+ for i, step in enumerate(steps):
46
+ # Real signature: frame_to_rgb_array(steps: int, frame: ndarray,
47
+ # scale: int = 4, color_map: Optional[dict] = None) -> ndarray
48
+ # The first `steps` arg is a display counter (ignored inside the function).
49
+ rgb = frame_to_rgb_array(0, step.frame, scale, COLOR_MAP)
50
+ fig, ax = plt.subplots(figsize=(4.0, 4.4))
51
+ ax.imshow(rgb, interpolation="nearest")
52
+ ax.axis("off")
53
+ ax.set_title(_title(step), fontsize=9)
54
+ path = out_dir / f"frame_{i:03d}.png"
55
+ fig.savefig(path, dpi=100, bbox_inches="tight")
56
+ plt.close(fig)
57
+ paths.append(path)
58
+ return paths
tests/viz/test_png.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ pytest.importorskip("matplotlib") # PNG path needs the `viz` extra
4
+
5
+ from proteus.agents import VanillaAgent
6
+ from proteus.providers import FakeProvider
7
+ from proteus.runtime.session import SessionRunner
8
+ from proteus.viz import reconstruct, write_pngs
9
+
10
+
11
+ def _steps():
12
+ agent = VanillaAgent(FakeProvider(["ACTION: up"]))
13
+ trace = SessionRunner(
14
+ "predator_evade", agent, seed=42, play_turns=4, use_probe=False,
15
+ ).run()
16
+ return reconstruct(trace)
17
+
18
+
19
+ def test_write_pngs_one_nonempty_file_per_frame(tmp_path):
20
+ steps = _steps()
21
+ out_dir = tmp_path / "frames"
22
+ paths = write_pngs(steps, out_dir)
23
+ assert len(paths) == len(steps)
24
+ for p in paths:
25
+ assert p.exists()
26
+ assert p.stat().st_size > 0
27
+ # Files are zero-padded and ordered.
28
+ assert (out_dir / "frame_000.png").exists()
29
+
30
+
31
+ def test_write_pngs_creates_missing_dir(tmp_path):
32
+ steps = _steps()
33
+ nested = tmp_path / "a" / "b" / "frames"
34
+ paths = write_pngs(steps, nested)
35
+ assert nested.is_dir()
36
+ assert len(paths) == len(steps)