import collections
import contextlib
import math
import os
import time
import numpy as np
import quadrants as qd
from PIL import Image
import genesis as gs
from genesis.utils.misc import get_entry_point_name
[docs]def animate(imgs, filename=None, fps=60):
"""
Create a video from a list of images.
Args:
imgs (list): List of input images.
filename (str, optional): Name of the output video file. If not provided, the name will default to the name
of the script the process was launched from, with a timestamp and '.mp4' extension.
"""
assert isinstance(imgs, list)
if len(imgs) == 0:
gs.logger.warning("No image to save.")
return
if filename is None:
filename = f"{get_entry_point_name()}_{time.strftime('%Y%m%d_%H%M%S')}.mp4"
os.makedirs(os.path.abspath(os.path.dirname(filename)), exist_ok=True)
gs.logger.info(f'Saving video to ~<"{filename}">~...')
from moviepy import ImageSequenceClip
imgs = ImageSequenceClip(imgs, fps=fps)
imgs.write_videofile(
filename,
fps=fps,
logger=None,
codec="libx264",
preset="ultrafast",
# ffmpeg_params=["-crf", "0"],
)
gs.logger.info("Video saved.")
[docs]def save_img_arr(arr, filename="img.png"):
assert isinstance(arr, np.ndarray)
os.makedirs(os.path.abspath(os.path.dirname(filename)), exist_ok=True)
img = Image.fromarray(arr)
img.save(filename)
gs.logger.info(f"Image saved to ~<{filename}>~.")
[docs]class Timer:
def __init__(self, skip=False, level=0, qd_sync=False):
self.accu_log = dict()
self.skip = skip
self.level = level
self.qd_sync = qd_sync
self.msg_width = 0
self.reset()
def reset(self):
self.just_reset = True
if self.level == 0 and not self.skip:
try:
column, _lines = os.get_terminal_size()
except OSError:
column = 80
print("─" * column)
if self.qd_sync and not self.skip:
qd.sync()
self.prev_time = self.init_time = time.perf_counter()
def _stamp(self, msg="", _ratio=1.0):
if self.skip:
return
if self.qd_sync:
qd.sync()
self.cur_time = time.perf_counter()
self.msg_width = max(self.msg_width, len(msg))
step_time = 1000 * (self.cur_time - self.prev_time) * _ratio
accu_time = 1000 * (self.cur_time - self.init_time) * _ratio
if msg not in self.accu_log:
self.accu_log[msg] = [1, step_time, accu_time]
else:
self.accu_log[msg][0] += 1
self.accu_log[msg][1] += step_time
self.accu_log[msg][2] += accu_time
if self.level > 0:
prefix = " │ " * (self.level - 1)
if self.just_reset:
prefix += " ╭──"
else:
prefix += " ├──"
else:
prefix = ""
print(
f"{prefix}[{msg.ljust(self.msg_width)}] step: {step_time:5.3f}ms | accu: {accu_time:5.3f}ms | step_avg: {self.accu_log[msg][1] / self.accu_log[msg][0]:5.3f}ms | accu_avg: {self.accu_log[msg][2] / self.accu_log[msg][0]:5.3f}ms"
)
self.prev_time = time.perf_counter()
self.just_reset = False
def stamp(self, msg="", _ratio=1.0):
return
if self.skip:
return
if self.qd_sync:
qd.sync()
self.cur_time = time.perf_counter()
self.msg_width = max(self.msg_width, len(msg))
step_time = 1000 * (self.cur_time - self.prev_time) * _ratio
accu_time = 1000 * (self.cur_time - self.init_time) * _ratio
if msg not in self.accu_log:
self.accu_log[msg] = [1, step_time, accu_time]
else:
self.accu_log[msg][0] += 1
self.accu_log[msg][1] += step_time
self.accu_log[msg][2] += accu_time
if self.level > 0:
prefix = " │ " * (self.level - 1)
if self.just_reset:
prefix += " ╭──"
else:
prefix += " ├──"
else:
prefix = ""
print(
f"{prefix}[{msg.ljust(self.msg_width)}] step: {step_time:5.3f}ms | accu: {accu_time:5.3f}ms | step_avg: {self.accu_log[msg][1] / self.accu_log[msg][0]:5.3f}ms | accu_avg: {self.accu_log[msg][2] / self.accu_log[msg][0]:5.3f}ms"
)
self.prev_time = time.perf_counter()
self.just_reset = False
timers = dict()
[docs]def create_timer(name=None, new=False, level=0, qd_sync=False, skip_first_call=False):
if name is None:
return Timer()
else:
if name in timers and not new:
timer = timers[name]
timer.skip = False
timer.reset()
return timer
else:
timer = Timer(skip=skip_first_call, level=level, qd_sync=qd_sync)
timers[name] = timer
return timer
[docs]class Rate:
"""Fixed-frequency loop limiter: call ``sleep`` once per iteration to hold the loop at ``rate`` Hz.
Each wake-up is scheduled against an ideal clock advanced by exactly the periods slept per tick, rather than from
the actual (over-slept) wake time, so ``time.sleep`` overshoot does not accumulate and the average rate stays on
target. When an iteration runs longer than the periods it sleeps, the limiter does not sleep and resets its schedule
from the current time, so a loop that cannot keep up simply runs as fast as it can without building a sleep debt to
burn off afterwards. ``period`` may be changed between two ticks, the next one following the new value.
"""
def __init__(self, rate):
self.period = 1.0 / rate
self.last_time = time.perf_counter()
@property
def rate(self):
"""The frequency in Hz the loop is held at."""
return 1.0 / self.period
@rate.setter
def rate(self, rate):
self.period = 1.0 / rate
[docs] def advance(self, n_periods=1):
"""Schedule the next tick 'n_periods' periods after the previous one, and return the time left until it.
The time left is 0 when the loop is behind, and the schedule then starts anew from the current time. A caller
that waits the time left in a way of its own, such as keeping a window responsive (see 'Viewer.sleep'), uses it
in place of 'sleep'.
"""
now = time.perf_counter()
next_time = self.last_time + n_periods * self.period
if next_time > now:
self.last_time = next_time
return next_time - now
self.last_time = now
return 0.0
[docs] def sleep(self, n_periods=1):
"""Sleep until 'n_periods' periods have passed since the previous tick, or not at all if they already have."""
duration = self.advance(n_periods)
if duration > 0.0:
time.sleep(duration)
[docs]class RunningMean:
"""Mean of a scalar fed one sample at a time, over the latest samples or exponentially weighted.
Exactly one of 'window' and 'alpha' is given. With 'window', 'value' is the mean of the latest 'window' samples,
NaN until that many have arrived, and it follows a change fully after that many samples. With 'alpha', it is the
exponential moving average, the value so far weighing 'alpha' and each new sample '1 - alpha', starting from the
first sample and NaN before it: it never forgets a sample entirely, and costs nothing per sample whatever the
smoothing.
"""
def __init__(self, window: int | None = None, alpha: float | None = None):
if (window is None) == (alpha is None):
gs.raise_exception("Exactly one of 'window' and 'alpha' must be given.")
if window is not None and window < 1:
gs.raise_exception(f"The window of a mean holds one sample at least, got {window}.")
self._alpha = alpha
self._samples: collections.deque[float] = collections.deque(maxlen=window)
self._value = math.nan
[docs] def update(self, sample: float):
"""Add a sample."""
if self._alpha is not None:
self._value = (
sample if math.isnan(self._value) else self._alpha * self._value + (1.0 - self._alpha) * sample
)
else:
self._samples.append(sample)
if len(self._samples) == self._samples.maxlen:
self._value = sum(self._samples) / len(self._samples)
@property
def value(self) -> float:
"""The mean of the samples so far, NaN until there are enough of them (see the class)."""
return self._value
[docs]class StepTimer:
"""Times the phases of the steps of a stepped process.
A step opens with `start`, its phases run under `phase`, named freely, or between `start_phase` and the next
`start_phase` or `stop_phase` where a `with` block would indent too much, and `step` closes it. A closed step leaves
in `timings`, until the next one, the wall time of every phase it timed, of the whole step under `step`, and of the
period from the start of the previous step to its own under `period`. The period covers the time spent between the
two steps as well, so its inverse is the rate the loop achieves. A step that `start` opens and `step` never closes
leaves no trace, as the next `start` discards it. The first step has no period. The code timing a phase declares it
with `add_phases` before the first step, so that a log laid out once lists every phase a step may time.
"""
def __init__(self):
self._step_start: float | None = None
self._previous_step_start: float | None = None
self._phase_open: tuple[str, float] | None = None
self._phases_time: dict[str, float] = {}
self._timings: dict[str, float] = {}
self._phases: list[str] = ["step", "period"]
@property
def phases(self) -> tuple[str, ...]:
"""Every phase a step may time, `step` and `period` first, in declaration order (see `add_phases`)."""
return tuple(self._phases)
[docs] def add_phases(self, *phases: str):
"""Declare phases the steps time, ahead of the first step."""
self._phases.extend(phase for phase in phases if phase not in self._phases)
@property
def timings(self) -> dict[str, float]:
"""Wall time in seconds of every phase the latest recorded step timed, by phase, empty before any."""
return self._timings
[docs] def start(self):
"""Open a step: the phases timed from here on belong to it."""
self._step_start = time.perf_counter()
self._phase_open = None
self._phases_time.clear()
[docs] def start_phase(self, phase: str):
"""Time the code that follows as part of a phase, until the next `start_phase` or `stop_phase`.
It lets a loop time each of its iterations under its own phase without a `with` block. A phase running from a
previous `start_phase` stops here.
"""
self.stop_phase()
self._phase_open = (phase, time.perf_counter())
[docs] def stop_phase(self):
"""Stop the phase `start_phase` opened, adding its time to what the phase already took in this step."""
if self._phase_open is not None:
phase, tic = self._phase_open
self._phases_time[phase] = self._phases_time.get(phase, 0.0) + time.perf_counter() - tic
self._phase_open = None
[docs] @contextlib.contextmanager
def phase(self, phase: str):
"""Time the enclosed code as part of a phase of the current step, adding to what the phase already took.
Blocks nest, each adding to its own phase.
"""
tic = time.perf_counter()
try:
yield
finally:
self._phases_time[phase] = self._phases_time.get(phase, 0.0) + time.perf_counter() - tic
[docs] def step(self):
"""Close the step, keeping its timings in `timings` until the next one."""
self.stop_phase()
self._phases_time["step"] = time.perf_counter() - self._step_start
if self._previous_step_start is not None:
self._phases_time["period"] = self._step_start - self._previous_step_start
self._timings = dict(self._phases_time)
self._previous_step_start = self._step_start