Merge pull request #22 from nyanpasu64/renderer-layout

Add renderer layout orientation field, add class LayoutConfig
pull/357/head
nyanpasu64 2018-08-14 21:31:27 -07:00 zatwierdzone przez GitHub
commit 596e9812c7
3 zmienionych plików z 150 dodań i 72 usunięć

Wyświetl plik

@ -5,7 +5,7 @@ from typing import Optional, List
from ovgenpy import outputs
from ovgenpy.channel import Channel, ChannelConfig
from ovgenpy.config import register_config
from ovgenpy.renderer import MatplotlibRenderer, RendererConfig
from ovgenpy.renderer import MatplotlibRenderer, RendererConfig, LayoutConfig
from ovgenpy.triggers import ITriggerConfig, CorrelationTriggerConfig
from ovgenpy.utils import keyword_dataclasses as dc
from ovgenpy.utils.keyword_dataclasses import field
@ -30,10 +30,10 @@ class Config:
trigger: ITriggerConfig # Maybe overriden per Wave
amplification: float
layout: LayoutConfig
render: RendererConfig
outputs: List[outputs.IOutputConfig]
create_window: bool = False
@property
def render_width_s(self) -> float:
@ -61,10 +61,8 @@ def default_config(**kwargs):
),
amplification=1,
render=RendererConfig(
1280, 720,
ncols=1
),
layout=LayoutConfig(ncols=1),
render=RendererConfig(1280, 720),
outputs=[
# outputs.FFmpegOutputConfig(output),
@ -97,12 +95,11 @@ class Ovgen:
# Calculate number of frames (TODO master file?)
render_width_s = self.cfg.render_width_s
fps = self.cfg.fps
create_window = self.cfg.create_window
nframes = fps * self.waves[0].get_s()
nframes = int(nframes) + 1
renderer = MatplotlibRenderer(self.cfg.render, self.nchan, create_window)
renderer = MatplotlibRenderer(self.cfg.render, self.cfg.layout, self.nchan)
if RENDER_PROFILING:
begin = time.perf_counter()

Wyświetl plik

@ -1,4 +1,4 @@
from typing import Optional, List, Tuple, TYPE_CHECKING
from typing import Optional, List, TYPE_CHECKING, TypeVar, Callable
import matplotlib
import numpy as np
@ -22,20 +22,7 @@ class RendererConfig:
width: int
height: int
nrows: Optional[int] = None
ncols: Optional[int] = None
def __post_init__(self):
if not self.nrows:
self.nrows = None
if not self.ncols:
self.ncols = None
if self.nrows and self.ncols:
raise ValueError('cannot manually assign both nrows and ncols')
if not self.nrows and not self.ncols:
self.ncols = 1
create_window: bool = False
class MatplotlibRenderer:
@ -61,17 +48,10 @@ class MatplotlibRenderer:
DPI = 96
def __init__(self, cfg: RendererConfig, nplots: int, create_window: bool):
def __init__(self, cfg: RendererConfig, lcfg: 'LayoutConfig', nplots: int):
self.cfg = cfg
self.nplots = nplots
self.create_window = create_window
# Setup layout
# "ncols=1" is good for vertical layouts.
# But "nrows=X" is good for left-to-right grids.
self.nrows = 0
self.ncols = 0
self.layout = RendererLayout(lcfg, nplots)
# Flat array of nrows*ncols elements, ordered by cfg.rows_first.
self.fig: 'Figure' = None
@ -89,8 +69,6 @@ class MatplotlibRenderer:
Outputs: self.nrows, self.ncols, self.axes
"""
self.nrows, self.ncols = self._calc_layout()
# Create Axes
# https://matplotlib.org/api/_as_gen/matplotlib.pyplot.subplots.html
if self.fig:
@ -99,7 +77,7 @@ class MatplotlibRenderer:
axes2d: np.ndarray['Axes']
self.fig, axes2d = plt.subplots(
self.nrows, self.ncols,
self.layout.nrows, self.layout.ncols,
squeeze=False,
# Remove gaps between Axes
gridspec_kw=dict(left=0, bottom=0, right=1, top=1, wspace=0, hspace=0)
@ -109,11 +87,8 @@ class MatplotlibRenderer:
for ax in axes2d.flatten():
ax.set_axis_off()
# if column major:
if self.cfg.ncols:
axes2d = axes2d.T
self.axes: List['Axes'] = axes2d.flatten().tolist()[:self.nplots]
# Generate arrangement (using nplots, cfg.orientation)
self.axes = self.layout.arrange(lambda row, col: axes2d[row, col])
# Setup figure geometry
self.fig.set_dpi(self.DPI)
@ -121,29 +96,9 @@ class MatplotlibRenderer:
self.cfg.width / self.DPI,
self.cfg.height / self.DPI
)
if self.create_window:
if self.cfg.create_window:
plt.show(block=False)
def _calc_layout(self) -> Tuple[int, int]:
"""
Inputs: self.cfg, self.waves
:return: (nrows, ncols)
"""
cfg = self.cfg
if cfg.nrows:
nrows = cfg.nrows
if nrows is None:
raise ValueError('invalid cfg: rows_first is True and nrows is None')
ncols = ceildiv(self.nplots, nrows)
else:
ncols = cfg.ncols
if ncols is None:
raise ValueError('invalid cfg: rows_first is False and ncols is None')
nrows = ceildiv(self.nplots, ncols)
return nrows, ncols
def render_frame(self, datas: List[np.ndarray]) -> None:
ndata = len(datas)
if self.nplots != ndata:
@ -189,3 +144,82 @@ class MatplotlibRenderer:
assert buffer_rgb.size == w * h * RGB_DEPTH
return buffer_rgb
@register_config
class LayoutConfig:
nrows: Optional[int] = None
ncols: Optional[int] = None
orientation: str = 'h'
def __post_init__(self):
if not self.nrows:
self.nrows = None
if not self.ncols:
self.ncols = None
if self.nrows and self.ncols:
raise ValueError('cannot manually assign both nrows and ncols')
if not self.nrows and not self.ncols:
self.ncols = 1
Region = TypeVar('Region')
RegionFactory = Callable[[int, int], Region] # f(row, column) -> Region
class RendererLayout:
VALID_ORIENTATIONS = ['h', 'v']
def __init__(self, cfg: LayoutConfig, nplots: int):
self.cfg = cfg
self.nplots = nplots
# Setup layout
self.nrows, self.ncols = self._calc_layout()
self.orientation = cfg.orientation
if self.orientation not in self.VALID_ORIENTATIONS:
raise ValueError(f'Invalid orientation {self.orientation} not in '
f'{self.VALID_ORIENTATIONS}')
def _calc_layout(self):
"""
Inputs: self.cfg, self.waves
:return: (nrows, ncols)
"""
cfg = self.cfg
if cfg.nrows:
nrows = cfg.nrows
if nrows is None:
raise ValueError('invalid cfg: rows_first is True and nrows is None')
ncols = ceildiv(self.nplots, nrows)
else:
ncols = cfg.ncols
if ncols is None:
raise ValueError('invalid cfg: rows_first is False and ncols is None')
nrows = ceildiv(self.nplots, ncols)
return nrows, ncols
def arrange(self, region_factory: RegionFactory) -> List[Region]:
""" Generates an array of regions.
index, row, column are fed into region_factory in a row-major order [row][col].
The results are possibly reshaped into column-major order [col][row].
"""
nspaces = self.nrows * self.ncols
inds = np.arange(nspaces)
rows, cols = np.unravel_index(inds, (self.nrows, self.ncols))
row_col = np.array([rows, cols]).T
regions = np.array([region_factory(*rc) for rc in row_col]) # type: np.ndarray[Region]
regions2d = regions.reshape((self.nrows, self.ncols)) # type: np.ndarray[Region]
# if column major:
if self.orientation == 'v':
regions2d = regions2d.T
return regions2d.flatten()[:self.nplots].tolist()

Wyświetl plik

@ -2,25 +2,28 @@ from unittest.mock import patch
import pytest
from ovgenpy.renderer import RendererConfig, MatplotlibRenderer
from ovgenpy.renderer import RendererConfig, MatplotlibRenderer, LayoutConfig, \
RendererLayout
WIDTH = 640
HEIGHT = 360
def test_config():
with pytest.raises(ValueError):
RendererConfig(WIDTH, HEIGHT, nrows=1, ncols=1)
LayoutConfig(nrows=1, ncols=1)
one_col = RendererConfig(WIDTH, HEIGHT, ncols=1)
one_col = LayoutConfig(ncols=1)
assert one_col
one_row = RendererConfig(WIDTH, HEIGHT, nrows=1)
one_row = LayoutConfig(nrows=1)
assert one_row
default = RendererConfig(WIDTH, HEIGHT)
assert default.ncols == 1 # Should default to single-column layout
default = LayoutConfig()
assert default.ncols == 1 # Should default to single-column layout
assert default.nrows is None
assert default.orientation == 'h'
def test_renderer():
@ -33,15 +36,59 @@ def test_renderer():
"""
# 2 columns
cfg = RendererConfig(WIDTH, HEIGHT, ncols=2)
nplots = 16
cfg = RendererConfig(WIDTH, HEIGHT)
lcfg = LayoutConfig(ncols=2)
nplots = 15
r = MatplotlibRenderer(cfg, nplots, create_window=False)
r = MatplotlibRenderer(cfg, lcfg, nplots)
# 2 columns, 8 rows
assert r.ncols == 2
assert r.nrows == 8
assert r.layout.ncols == 2
assert r.layout.nrows == 8
@pytest.mark.parametrize('lcfg', [
LayoutConfig(ncols=2),
LayoutConfig(nrows=8),
])
def test_layout(lcfg):
nplots = 15
layout = RendererLayout(lcfg, nplots)
assert layout.ncols == 2
assert layout.nrows == 8
# holy shit, passing tuples into a numpy array breaks things spectacularly, and it's
# painfully difficult to stuff tuples into 1D array.
# http://wesmckinney.com/blog/performance-quirk-making-a-1d-object-ndarray-of-tuples/
regions = layout.arrange(lambda row, col: str((row, col)))
assert len(regions) == nplots
assert regions[0] == '(0, 0)'
assert regions[1] == '(0, 1)'
assert regions[2] == '(1, 0)'
m = nplots - 1
assert regions[m] == str((m // 2, m % 2))
@pytest.mark.parametrize('lcfg', [
LayoutConfig(ncols=3, orientation='v'),
LayoutConfig(nrows=3, orientation='v'),
])
def test_layout(lcfg):
nplots = 7
layout = RendererLayout(lcfg, nplots)
assert layout.ncols == 3
assert layout.nrows == 3
regions = layout.arrange(lambda row, col: str((row, col)))
assert len(regions) == nplots
assert regions[0] == '(0, 0)'
assert regions[2] == '(2, 0)'
assert regions[3] == '(0, 1)'
assert regions[6] == '(0, 2)'
# TODO: test get_frame()
# (integration test) ensure rendering to output works