sekai environments have no render() method and no render_mode. Rendering is done by a
separate renderer object that reads an environment’s state and returns a frame. Training
code never pays for rendering it does not use, one env can be drawn by several renderers, and
recording is a wrapper you add or remove without touching the environment.
These GIFs were produced by the renderers on this page, with RecordFrames and
save_gif().
A renderer has two methods:
| Method | Returns |
|---|---|
render(env) |
an RGB frame (np.ndarray, shape (H, W, 3), uint8) or a text frame (str) |
close() |
releases resources (windows, figures, file handles) |
sekai.rendering.Renderer is a runtime-checkable typing.Protocol, so any object with those
two methods works (and passes isinstance(obj, Renderer)). Subclass
sekai.rendering.BaseRenderer to get a no-op close() and with renderer: support, which
calls close() on exit; the built-in renderers do.
The classic-control environments come with RGB renderers that mirror gymnasium’s
rgb_array output, drawn with sekai’s own Canvas (no pygame needed):
| Renderer | Draws | Frame size |
|---|---|---|
CartPoleRenderer(width=600, height=400) |
CartPole-v1 |
(height, width, 3) |
MountainCarRenderer(width=600, height=400) |
MountainCar-v0, MountainCarContinuous-v0 |
(height, width, 3) |
PendulumRenderer(size=500) |
Pendulum-v1 |
(size, size, 3) |
AcrobotRenderer(size=500) |
Acrobot-v1 |
(size, size, 3) |
They live in sekai.envs.classic_control, accept a wrapped or unwrapped env, raise
ResetRequired before the first reset() and TypeError for the wrong kind of env.
import numpy as np
import sekai
from sekai.envs.classic_control import CartPoleRenderer
env = sekai.make("CartPole-v1")
renderer = CartPoleRenderer()
env.reset(seed=0)
frame = renderer.render(env)
print(frame.shape, frame.dtype) # (400, 600, 3) uint8
RecordFrames (in sekai.rendering) calls a renderer after every reset() and step()
and keeps the frames in env.frames:
from sekai.envs.classic_control import PendulumRenderer
from sekai.rendering import RecordFrames
env = RecordFrames(sekai.make("Pendulum-v1"), PendulumRenderer(size=200))
env.action_space.seed(0)
result = env.reset(seed=0)
while not result.done:
result = env.step(env.action_space.sample())
print(len(env.frames), env.frames[0].shape) # 201 (200, 200, 3)
path = env.save("pendulum.gif") # fps defaults to env.metadata["render_fps"]
print(path) # pendulum.gif
env.close() # closes the renderer too
| Option / method | Effect |
|---|---|
enabled=True |
set env.enabled = False to pause recording |
max_frames=None |
stop capturing after this many frames |
clear() |
drop the captured frames, e.g. between episodes |
save(path, fps=None) |
.gif via Pillow, anything else (e.g. .mp4) via imageio |
save() needs RGB array frames: frames recorded from a text ("ansi") renderer raise
TypeError.
save_gif(frames, path, fps=30) and save_video(frames, path, fps=30) write a list of RGB
frames of shape (H, W, 3), or one stacked (T, H, W, 3) array; greyscale (H, W) and RGBA
(H, W, 4) frames work too. fps must be positive. Anything else raises before a file is
written: ValueError for an empty list, a single frame passed instead of a list, a bad shape
or fps, and TypeError for text frames. GIFs need
Pillow; videos need imageio with its ffmpeg plugin. Both come with
pip install "sekai[render]".
from sekai.rendering import save_gif
env = sekai.make("CartPole-v1")
renderer = CartPoleRenderer(width=300, height=200)
env.reset(seed=0)
frames = [renderer.render(env)]
for _ in range(20):
env.step(1) # keep pushing right
frames.append(renderer.render(env))
print(save_gif(frames, "push_right.gif", fps=25)) # push_right.gif
from sekai.rendering import save_video
save_video(frames, "cartpole.mp4", fps=50) # needs imageio + imageio-ffmpeg
sekai.rendering.Canvas is a tiny, dependency-free RGB raster. The origin is at the
bottom-left with y pointing up (convenient for physics), shapes are filled by testing
pixel centres, and to_array() returns a copy of the (H, W, 3) uint8 image.
| Method | Draws |
|---|---|
clear(color=(255, 255, 255)) |
fills the canvas |
rect(left, bottom, right, top, color) |
an axis-aligned rectangle |
polygon(points, color) |
a filled polygon from (x, y) vertices |
circle(cx, cy, radius, color) |
a filled disc |
line(start, end, color, width=1.0) |
a segment |
polyline(points, color, width=1.0) |
connected segments |
sekai.rendering.rotate(points, angle, origin=(0, 0)) rotates points counter-clockwise, for
drawing rotated bodies.
from sekai.rendering import Canvas
canvas = Canvas(8, 4)
canvas.rect(0, 0, 4, 2, (0, 0, 0)) # bottom-left 4x2 block
print((canvas.to_array()[:, :, 0] == 0).astype(int))
[[0 0 0 0 0 0 0 0]
[0 0 0 0 0 0 0 0]
[1 1 1 1 0 0 0 0]
[1 1 1 1 0 0 0 0]]
A renderer reads whatever public state the env exposes. Two renderers for
MarketMaking-v0: an RGB gauge of inventory and
session progress, and a text ticker.
from sekai.rendering import Renderer
class InventoryGauge(Renderer):
"""Green/red bar: inventory relative to the limit. Blue strip: session elapsed."""
def __init__(self, width=300, height=60):
self.width, self.height = width, height
def render(self, env):
mm = env.unwrapped
canvas = Canvas(self.width, self.height)
centre = self.width / 2
end = centre + mm.inventory / mm.max_inventory * centre
color = (46, 125, 50) if end >= centre else (198, 40, 40)
canvas.rect(min(centre, end), 20, max(centre, end), 50, color)
canvas.line((centre, 15), (centre, 55), (0, 0, 0))
elapsed = 1 - mm.time_remaining / mm.total_time
canvas.rect(0, 0, elapsed * self.width, 8, (63, 81, 181))
return canvas.to_array()
def close(self):
pass
class Ticker(Renderer):
def render(self, env):
mm = env.unwrapped
return f"t={mm.total_time - mm.time_remaining:.2f} mid={mm.mid:.2f} inv={mm.inventory:+d}"
def close(self):
pass
env = RecordFrames(sekai.make("MarketMaking-v0"), InventoryGauge())
ticker = Ticker()
result = env.reset(seed=0)
while not result.done:
result = env.step(env.unwrapped.avellaneda_stoikov_action())
print(ticker.render(env)) # t=1.00 mid=99.93 inv=-1
print(len(env.frames), env.frames[-1].shape) # 201 (60, 300, 3)
to_gymnasium(env, renderer=...) turns a renderer into a
gymnasium render_mode="rgb_array" (pass render_mode="ansi" for a text renderer), so
gymnasium’s RecordVideo and similar tools work. "human" is not accepted, since a renderer
returns frames rather than drawing to a window; wrap the converted env in gymnasium’s
HumanRendering to watch it. In the other direction, GymnasiumRenderer renders an
env brought in with from_gymnasium() by calling the gymnasium env’s own render().