Make renderer backends pluggable, store constants inside (#267)

pull/357/head
nyanpasu64 2019-04-11 07:57:05 -07:00 zatwierdzone przez GitHub
rodzic 59965cc789
commit 9f68bff952
7 zmienionych plików z 140 dodań i 71 usunięć

Wyświetl plik

@ -14,7 +14,7 @@ from corrscope.channel import Channel, ChannelConfig, DefaultLabel
from corrscope.config import KeywordAttrs, DumpEnumAsStr, CorrError, with_units
from corrscope.layout import LayoutConfig
from corrscope.outputs import FFmpegOutputConfig
from corrscope.renderer import MatplotlibRenderer, RendererConfig, Renderer
from corrscope.renderer import Renderer, RendererConfig, BaseRenderer
from corrscope.triggers import CorrelationTriggerConfig, PerFrameCache, SpectrumConfig
from corrscope.util import pushd, coalesce
from corrscope.wave import Wave, Flatten
@ -217,9 +217,9 @@ class CorrScope:
]
yield
def _load_renderer(self) -> Renderer:
def _load_renderer(self) -> BaseRenderer:
dummy_datas = [channel.get_render_around(0) for channel in self.channels]
renderer = MatplotlibRenderer(
renderer = Renderer(
self.cfg.render, self.cfg.layout, dummy_datas, self.cfg.channels
)
return renderer

Wyświetl plik

@ -0,0 +1,14 @@
# Design Notes
## Renderer to Output
- `renderer.py` produces a frame-buffer format which is passed to `outputs.py`. This is a cross-cutting concern, and the 2 modules must agree on bytes per pixel and pixel format.
- Each renderer returns a different pixel format, and I switch renderers more often than outputs.
- `tests/test_renderer.py` must convert hexadecimal colors to the right pixel format, for comparison.
### Solution
- Each `BaseRenderer` subclass exposes classvars `bytes_per_pixel` and `ffmpeg_pixel_format`.
- `renderer.py` exposes `Renderer = preferred subclass`.
- `outputs.py` imports `renderer.Renderer` and uses the format stored within.
- Additionally, `tests/test_renderer.py` uses `Renderer.color_to_bytes()`

Wyświetl plik

@ -5,19 +5,14 @@ from abc import ABC, abstractmethod
from os.path import abspath
from typing import TYPE_CHECKING, Type, List, Union, Optional, ClassVar, Callable
import numpy as np
from corrscope.config import DumpableAttrs
from corrscope.renderer import ByteBuffer, Renderer
from corrscope.settings.paths import MissingFFmpegError
if TYPE_CHECKING:
from corrscope.corrscope import Config
ByteBuffer = Union[bytes, np.ndarray]
BYTES_PER_PIXEL = 3
PIXEL_FORMAT = "rgb24"
FRAMES_TO_BUFFER = 2
FFMPEG_QUIET = "-nostats -hide_banner -loglevel error".split()
@ -44,7 +39,9 @@ class Output(ABC):
rcfg = corr_cfg.render
frame_bytes = rcfg.divided_height * rcfg.divided_width * BYTES_PER_PIXEL
frame_bytes = (
rcfg.divided_height * rcfg.divided_width * Renderer.bytes_per_pixel
)
self.bufsize = frame_bytes * FRAMES_TO_BUFFER
def __enter__(self):
@ -120,8 +117,8 @@ def ffmpeg_input_video(cfg: "Config") -> List[str]:
height = cfg.render.divided_height
return [
f"-f rawvideo -pixel_format {PIXEL_FORMAT} -video_size {width}x{height}",
f"-framerate {fps}",
f"-f rawvideo -pixel_format {Renderer.ffmpeg_pixel_format}",
f"-video_size {width}x{height} -framerate {fps}",
*FFMPEG_QUIET,
"-i -",
]

Wyświetl plik

@ -1,11 +1,19 @@
import enum
import os
from abc import ABC, abstractmethod
from typing import Optional, List, TYPE_CHECKING, Any, Callable, TypeVar
from typing import (
Optional,
List,
TYPE_CHECKING,
Any,
Callable,
TypeVar,
Sequence,
Type,
Union,
)
import attr
import matplotlib # do NOT import anything else until we call matplotlib.use().
import matplotlib.colors
import numpy as np
from corrscope.config import DumpableAttrs, with_units, TypedEnumDump
@ -16,8 +24,7 @@ from corrscope.layout import (
RegionSpec,
Edges,
)
from corrscope.outputs import BYTES_PER_PIXEL, ByteBuffer
from corrscope.util import coalesce
from corrscope.util import coalesce, obj_name
"""
On first import, matplotlib.font_manager spends nearly 10 seconds
@ -39,19 +46,26 @@ mpl_config_dir = "MPLCONFIGDIR"
if mpl_config_dir in os.environ:
del os.environ[mpl_config_dir]
matplotlib.use("agg")
# matplotlib.use() only affects pyplot. We don't use pyplot.
import matplotlib
import matplotlib.colors
from matplotlib.backends.backend_agg import FigureCanvasAgg
from matplotlib.figure import Figure
if TYPE_CHECKING:
from matplotlib.artist import Artist
from matplotlib.axes import Axes
from matplotlib.backend_bases import FigureCanvasBase
from matplotlib.lines import Line2D
from matplotlib.spines import Spine
from matplotlib.text import Text, Annotation
from corrscope.channel import ChannelConfig
# Used by outputs.py.
ByteBuffer = Union[bytes, np.ndarray]
def default_color() -> str:
# import matplotlib.colors
# colors = np.array([int(x, 16) for x in '1f 77 b4'.split()], dtype=float)
@ -173,8 +187,35 @@ class LineParam:
UpdateLines = Callable[[List[np.ndarray]], None]
# TODO rename to Plotter
class Renderer(ABC):
@property
@abstractmethod
def abstract_classvar(self) -> Any:
"""A ClassVar to be overriden by a subclass."""
class BaseRenderer(ABC):
"""
Renderer backend which takes data and produces images.
Does not touch Wave or Channel.
"""
# Class attributes and methods
bytes_per_pixel: int = abstract_classvar
ffmpeg_pixel_format: str = abstract_classvar
@staticmethod
@abstractmethod
def color_to_bytes(c: str) -> np.ndarray:
"""
Returns integer ndarray of length RGB_DEPTH.
This must return ndarray (not bytes or list),
since the caller performs arithmetic on the return value.
Only used for tests/test_renderer.py.
"""
# Instance initializer
def __init__(
self,
cfg: RendererConfig,
@ -210,6 +251,8 @@ class Renderer(ABC):
for color in line_colors
]
# Instance functionality
_update_main_lines: Optional[UpdateLines] = None
def update_main_lines(self, datas: List[np.ndarray]) -> None:
@ -247,29 +290,20 @@ def px_from_points(pt: Point) -> Pixel:
return pt * PIXELS_PER_PT
class MatplotlibRenderer(Renderer):
class AbstractMatplotlibRenderer(BaseRenderer, ABC):
"""Matplotlib renderer which can use any backend (agg, mplcairo).
To pick a backend, subclass and set canvas_type at the class level.
"""
Renderer backend which takes data and produces images.
Does not touch Wave or Channel.
If __init__ reads cfg, cfg cannot be hotswapped.
_canvas_type: Type["FigureCanvasBase"] = abstract_classvar
Reasons to hotswap cfg: RendererCfg:
- GUI preview size
- Changing layout
- Changing #smp drawn (samples_visible)
(see RendererCfg)
Original OVGen does not support hotswapping.
It disables changing options during rendering.
Reasons to hotswap trigger algorithms:
- changing scan_nsamp (cannot be hotswapped, since correlation buffer is incompatible)
So don't.
"""
@staticmethod
@abstractmethod
def _canvas_to_bytes(canvas: "FigureCanvasBase") -> ByteBuffer:
pass
def __init__(self, *args, **kwargs):
Renderer.__init__(self, *args, **kwargs)
BaseRenderer.__init__(self, *args, **kwargs)
dict.__setitem__(
matplotlib.rcParams, "lines.antialiased", self.cfg.antialiasing
@ -305,7 +339,7 @@ class MatplotlibRenderer(Renderer):
cfg = self.cfg
self._fig = Figure()
FigureCanvasAgg(self._fig)
self._canvas_type(self._fig)
px_inch = PX_INCH / cfg.res_divisor
self._fig.set_dpi(px_inch)
@ -571,20 +605,22 @@ class MatplotlibRenderer(Renderer):
# Output frames
def get_frame(self) -> ByteBuffer:
""" Returns ndarray of shape w,h,3. """
"""Returns bytes with shape (h, w, self.bytes_per_pixel).
The actual return value's shape may be flat.
"""
self._redraw_over_background()
canvas = self._fig.canvas
# Agg is the default noninteractive backend except on OSX.
# https://matplotlib.org/faq/usage_faq.html
if not isinstance(canvas, FigureCanvasAgg):
if not isinstance(canvas, self._canvas_type):
raise RuntimeError(
f"oh shit, cannot read data from {type(canvas)} != FigureCanvasAgg"
f"oh shit, cannot read data from {obj_name(canvas)} != {self._canvas_type.__name__}"
)
buffer_rgb = canvas.tostring_rgb()
assert len(buffer_rgb) == self.w * self.h * BYTES_PER_PIXEL
buffer_rgb = self._canvas_to_bytes(canvas)
assert len(buffer_rgb) == self.w * self.h * self.bytes_per_pixel
return buffer_rgb
@ -603,6 +639,8 @@ class MatplotlibRenderer(Renderer):
def _redraw_over_background(self) -> None:
""" Redraw animated elements of the image. """
# Both FigureCanvasAgg and FigureCanvasCairo, but not FigureCanvasBase,
# support restore_region().
canvas: FigureCanvasAgg = self._fig.canvas
canvas.restore_region(self.bg_cache)
@ -610,3 +648,24 @@ class MatplotlibRenderer(Renderer):
artist.axes.draw_artist(artist)
# canvas.blit(self._fig.bbox) is unnecessary when drawing off-screen.
class MatplotlibAggRenderer(AbstractMatplotlibRenderer):
# implements AbstractMatplotlibRenderer
_canvas_type = FigureCanvasAgg
@staticmethod
def _canvas_to_bytes(canvas: FigureCanvasAgg) -> ByteBuffer:
return canvas.tostring_rgb()
# implements BaseRenderer
bytes_per_pixel = 3
ffmpeg_pixel_format = "rgb24"
@staticmethod
def color_to_bytes(c: str) -> np.ndarray:
to_rgb = matplotlib.colors.to_rgb
return np.array([round(c * 255) for c in to_rgb(c)], dtype=int)
Renderer = MatplotlibAggRenderer

Wyświetl plik

@ -14,7 +14,7 @@ from corrscope.layout import (
Orientation,
StereoOrientation,
)
from corrscope.renderer import RendererConfig, MatplotlibRenderer
from corrscope.renderer import RendererConfig, Renderer
from corrscope.util import ceildiv
from tests.test_renderer import WIDTH, HEIGHT, RENDER_Y_ZEROS
@ -222,7 +222,7 @@ def test_renderer_layout():
nplots = 15
datas = [RENDER_Y_ZEROS] * nplots
r = MatplotlibRenderer(cfg, lcfg, datas, None)
r = Renderer(cfg, lcfg, datas, None)
r.update_main_lines(datas)
layout = r.layout

Wyświetl plik

@ -15,14 +15,13 @@ import pytest
from corrscope.channel import ChannelConfig
from corrscope.corrscope import default_config, Config, CorrScope, Arguments
from corrscope.outputs import (
BYTES_PER_PIXEL,
FFmpegOutput,
FFmpegOutputConfig,
FFplayOutput,
FFplayOutputConfig,
Stop,
)
from corrscope.renderer import RendererConfig, MatplotlibRenderer
from corrscope.renderer import RendererConfig, Renderer
from tests.test_renderer import RENDER_Y_ZEROS, WIDTH, HEIGHT
@ -31,6 +30,9 @@ if TYPE_CHECKING:
from unittest.mock import MagicMock
BYTES_PER_PIXEL = Renderer.bytes_per_pixel
# Global setup
if not shutil.which("ffmpeg"):
pytestmark = pytest.mark.xfail(
@ -86,7 +88,7 @@ def test_render_output():
""" Ensure rendering to output does not raise exceptions. """
datas = [RENDER_Y_ZEROS]
renderer = MatplotlibRenderer(CFG.render, CFG.layout, datas, channel_cfgs=None)
renderer = Renderer(CFG.render, CFG.layout, datas, channel_cfgs=None)
out: FFmpegOutput = NULL_FFMPEG_OUTPUT(CFG)
renderer.update_main_lines(datas)
@ -168,7 +170,7 @@ def test_corr_terminate_ffplay(Popen, mocker: "pytest_mock.MockFixture"):
cfg = sine440_config()
corr = CorrScope(cfg, Arguments(".", [FFplayOutputConfig()]))
update_main_lines = mocker.patch.object(MatplotlibRenderer, "update_main_lines")
update_main_lines = mocker.patch.object(Renderer, "update_main_lines")
update_main_lines.side_effect = DummyException()
with pytest.raises(DummyException):
corr.play()
@ -334,7 +336,7 @@ NO_FFMPEG = [[], [FFplayOutputConfig()]]
def test_preview_performance(Popen, mocker: "pytest_mock.MockFixture", outputs):
""" Ensure performance optimizations enabled
if all outputs are FFplay or others. """
get_frame = mocker.spy(MatplotlibRenderer, "get_frame")
get_frame = mocker.spy(Renderer, "get_frame")
previews, records = previews_records(mocker)
cfg = cfg_192x108()
@ -368,7 +370,7 @@ YES_FFMPEG = [l + [FFmpegOutputConfig(None)] for l in NO_FFMPEG]
def test_record_performance(Popen, mocker: "pytest_mock.MockFixture", outputs):
""" Ensure performance optimizations disabled
if any FFmpegOutputConfig is found. """
get_frame = mocker.spy(MatplotlibRenderer, "get_frame")
get_frame = mocker.spy(Renderer, "get_frame")
previews, records = previews_records(mocker)
cfg = cfg_192x108()

Wyświetl plik

@ -2,7 +2,6 @@ from typing import Optional, TYPE_CHECKING, List
import attr
import hypothesis.strategies as hs
import matplotlib.colors
import numpy as np
import pytest
from hypothesis import given
@ -10,8 +9,8 @@ from hypothesis import given
from corrscope.channel import ChannelConfig
from corrscope.corrscope import CorrScope, default_config, Arguments
from corrscope.layout import LayoutConfig
from corrscope.outputs import BYTES_PER_PIXEL, FFplayOutputConfig
from corrscope.renderer import RendererConfig, MatplotlibRenderer, LabelPosition, Font
from corrscope.outputs import FFplayOutputConfig
from corrscope.renderer import RendererConfig, Renderer, LabelPosition, Font
from corrscope.util import perr
from corrscope.wave import Flatten
@ -21,6 +20,9 @@ if TYPE_CHECKING:
parametrize = pytest.mark.parametrize
color_to_bytes = Renderer.color_to_bytes
BYTES_PER_PIXEL = Renderer.bytes_per_pixel
WIDTH = 64
HEIGHT = 64
@ -97,13 +99,13 @@ def test_default_colors(appear: Appearance, data):
lcfg = LayoutConfig(orientation=ORIENTATION)
datas = [data] * NPLOTS
r = MatplotlibRenderer(cfg, lcfg, datas, None)
r = Renderer(cfg, lcfg, datas, None)
verify(r, appear, datas)
# Ensure default ChannelConfig(line_color=None) does not override line color
chan = ChannelConfig(wav_path="")
channels = [chan] * NPLOTS
r = MatplotlibRenderer(cfg, lcfg, datas, channels)
r = Renderer(cfg, lcfg, datas, channels)
verify(r, appear, datas)
@ -120,14 +122,14 @@ def test_line_colors(appear: Appearance, data):
cfg.init_line_color = "#888888"
chan.line_color = appear.fg_str
r = MatplotlibRenderer(cfg, lcfg, datas, channels)
r = Renderer(cfg, lcfg, datas, channels)
verify(r, appear, datas)
TOLERANCE = 3
def verify(r: MatplotlibRenderer, appear: Appearance, datas: List[np.ndarray]):
def verify(r: Renderer, appear: Appearance, datas: List[np.ndarray]):
bg_str = appear.bg_str
fg_str = appear.fg_str
grid_str = appear.grid_str
@ -138,14 +140,14 @@ def verify(r: MatplotlibRenderer, appear: Appearance, datas: List[np.ndarray]):
(-1, BYTES_PER_PIXEL)
)
bg_u8 = to_rgb(bg_str)
fg_u8 = to_rgb(fg_str)
bg_u8 = color_to_bytes(bg_str)
fg_u8 = color_to_bytes(fg_str)
all_colors = [bg_u8, fg_u8]
is_grid = bool(grid_str and grid_line_width >= 1)
if is_grid:
grid_u8 = to_rgb(grid_str)
grid_u8 = color_to_bytes(grid_str)
all_colors.append(grid_u8)
else:
grid_u8 = np.array([1000] * BYTES_PER_PIXEL)
@ -188,11 +190,6 @@ def verify(r: MatplotlibRenderer, appear: Appearance, datas: List[np.ndarray]):
assert (np.amin(frame_colors, axis=0) == np.amin(all_colors, axis=0)).all()
def to_rgb(c) -> np.ndarray:
to_rgb = matplotlib.colors.to_rgb
return np.array([round(c * 255) for c in to_rgb(c)], dtype=int)
# Test label positioning and rendering
@parametrize("label_position", LabelPosition.__members__.values())
@parametrize("data", [RENDER_Y_ZEROS, RENDER_Y_STEREO])
@ -204,7 +201,7 @@ def test_label_render(label_position: LabelPosition, data, hide_lines):
- even if no lines are drawn at all
"""
font_str = "#FF00FF"
font_u8 = to_rgb(font_str)
font_u8 = color_to_bytes(font_str)
# If hide_lines: set line color to purple, draw text using the line color.
# Otherwise: draw lines white, draw text purple,
@ -228,7 +225,7 @@ def test_label_render(label_position: LabelPosition, data, hide_lines):
labels = ["#"] * nplots
datas = [data] * nplots
r = MatplotlibRenderer(cfg, lcfg, datas, None)
r = Renderer(cfg, lcfg, datas, None)
r.add_labels(labels)
if not hide_lines:
r.update_main_lines(datas)
@ -302,13 +299,13 @@ def verify_res_divisor_rounding(
cfg.before_preview()
if speed_hack:
mocker.patch.object(MatplotlibRenderer, "_save_background")
mocker.patch.object(Renderer, "_save_background")
datas = []
else:
datas = [RENDER_Y_ZEROS]
try:
renderer = MatplotlibRenderer(cfg, LayoutConfig(), datas, channel_cfgs=None)
renderer = Renderer(cfg, LayoutConfig(), datas, channel_cfgs=None)
if not speed_hack:
renderer.update_main_lines(datas)
renderer.get_frame()