corrscope/tests/test_renderer.py

605 wiersze
18 KiB
Python

from contextlib import ExitStack
from typing import Optional, TYPE_CHECKING, List
from unittest.mock import patch
import attr
import hypothesis.strategies as hs
import numpy as np
import pytest
from hypothesis import given
from corrscope.channel import ChannelConfig, Channel
from corrscope.corrscope import CorrScope, template_config, Arguments
from corrscope.layout import LayoutConfig
from corrscope.outputs import FFplayOutputConfig
from corrscope.renderer import (
RendererConfig,
Renderer,
LabelPosition,
Font,
calc_limits,
calc_xs,
calc_center,
AbstractMatplotlibRenderer,
RendererFrontend,
CustomLine,
RenderInput,
)
from corrscope.util import perr
from corrscope.wave import Flatten
if TYPE_CHECKING:
import pytest_mock
parametrize = pytest.mark.parametrize
color_to_bytes = Renderer.color_to_bytes
BYTES_PER_PIXEL = Renderer.bytes_per_pixel
WIDTH = 64
HEIGHT = 64
RENDER_Y_ZEROS = np.full((2, 1), 0.5)
RENDER_Y_STEREO = np.full((2, 2), 0.5)
OPACITY = 2 / 3
def behead(string: str, header: str) -> str:
if not string.startswith(header):
raise ValueError(f"{string} does not start with {header}")
return string[len(header) :]
my_dataclass = attr.dataclass(frozen=True, repr=False)
@my_dataclass
class NamedDebug:
name: str
def __init_subclass__(cls):
my_dataclass(cls)
def __repr__(self):
return self.name
if TYPE_CHECKING:
def __init__(self, *args, **kwargs):
pass
# "str" = HTML #FFFFFF color string.
class BG(NamedDebug):
color: str
class FG(NamedDebug):
color: str
draw_fg: bool = True
line_width: float = 2.0
class Grid(NamedDebug):
line_width: float
color: Optional[str]
class Debug(NamedDebug):
viewport_width: float = 1
bg_black = BG("bg_black", "#000000")
bg_white = BG("bg_white", "#ffffff")
bg_blue = BG("bg_blue", "#0000aa")
bg_yellow = BG("bg_yellow", "#aaaa00")
fg_white = FG("fg_white", "#ffffff")
fg_white_thick = FG("fg_white_thick", "#ffffff", line_width=10.0)
fg_black = FG("fg_black", "#000000")
fg_NONE = FG("fg_NONE", "#ffffff", draw_fg=False)
fg_yellow = FG("fg_yellow", "#aaaa00")
fg_blue = FG("fg_blue", "#0000aa")
grid_0 = Grid("grid_0", 0, "#ff00ff")
grid_1 = Grid("grid_1", 1, "#ff00ff")
grid_10 = Grid("grid_10", 10, "#ff00ff")
grid_NONE = Grid("grid_NONE", 10, None)
debug_NONE = Debug("debug_NONE")
debug_wide = Debug("debug_wide", viewport_width=2)
@attr.dataclass(frozen=True)
class Appearance:
bg: BG
fg: FG
grid: Grid
debug: Debug = debug_NONE
def all_colors_to_str(val) -> Optional[str]:
"""Called once for each `appear` and `data`."""
if isinstance(val, Appearance):
args_tuple = attr.astuple(val, recurse=False)
return f"appear=Appearance{args_tuple}"
if isinstance(val, np.ndarray):
data_type = "stereo" if val.shape[1] > 1 else "mono"
return "data=" + data_type
raise ValueError("Unrecognized all_colors parameter, not `appear` or `data`")
mono = RENDER_Y_ZEROS
stereo = RENDER_Y_STEREO
all_colors = pytest.mark.parametrize(
"appear, data",
[
# Test with foreground disabled
(Appearance(bg_black, fg_NONE, grid_NONE), mono),
(Appearance(bg_blue, fg_NONE, grid_1), mono),
# Test with grid disabled
(Appearance(bg_black, fg_white, grid_NONE), mono),
(Appearance(bg_white, fg_black, grid_NONE), mono),
(Appearance(bg_blue, fg_yellow, grid_NONE), mono),
(Appearance(bg_yellow, fg_blue, grid_NONE), mono),
# Test FG line thickness
(Appearance(bg_black, fg_white_thick, grid_NONE), mono),
# Test various grid thicknesses
(Appearance(bg_white, fg_black, grid_0), mono),
(Appearance(bg_blue, fg_yellow, grid_1), mono),
(Appearance(bg_blue, fg_yellow, grid_10), mono),
# Test with stereo
(Appearance(bg_black, fg_white, grid_NONE), stereo),
(Appearance(bg_blue, fg_yellow, grid_0), stereo),
(Appearance(bg_blue, fg_yellow, grid_10), stereo),
# Test debugging (viewport width)
(Appearance(bg_black, fg_white, grid_NONE, debug_wide), mono),
],
ids=all_colors_to_str,
)
def get_renderer_config(appear: Appearance) -> RendererConfig:
cfg = RendererConfig(
WIDTH,
HEIGHT,
# BG
bg_color=appear.bg.color,
# FG
init_line_color=appear.fg.color,
line_width=appear.fg.line_width,
viewport_width=appear.debug.viewport_width,
# Grid
grid_color=appear.grid.color,
grid_line_width=appear.grid.line_width,
stereo_grid_opacity=OPACITY,
antialiasing=False,
)
return cfg
NPLOTS = 2
ORIENTATION = "h"
GRID_NPIXEL = WIDTH
@all_colors
def test_default_colors(appear: Appearance, data):
"""Test the default background/foreground colors."""
cfg = get_renderer_config(appear)
lcfg = LayoutConfig(orientation=ORIENTATION)
datas = [data] * NPLOTS
r = Renderer.from_obj(cfg, lcfg, datas, None, None)
verify(r, appear, datas)
# Ensure default ChannelConfig(line_color=None) does not override line color
chan = ChannelConfig(wav_path="")
channels = [chan] * NPLOTS
r = Renderer.from_obj(cfg, lcfg, datas, channels, None)
verify(r, appear, datas)
@all_colors
def test_line_colors(appear: Appearance, data):
"""Test channel-specific line color overrides"""
cfg = get_renderer_config(appear)
lcfg = LayoutConfig(orientation=ORIENTATION)
datas = [data] * NPLOTS
# Move line color (appear.fg.color) from renderer cfg to individual channel.
chan = ChannelConfig(wav_path="", line_color=appear.fg.color)
channels = [chan] * NPLOTS
cfg.init_line_color = "#888888"
chan.line_color = appear.fg.color
r = Renderer.from_obj(cfg, lcfg, datas, channels, None)
verify(r, appear, datas)
TOLERANCE = 3
def verify(r: Renderer, appear: Appearance, datas: List[Optional[np.ndarray]]):
bg_str = appear.bg.color
fg_str = appear.fg.color
draw_fg = appear.fg.draw_fg
fg_line_width = appear.fg.line_width
grid_str = appear.grid.color
grid_line_width = appear.grid.line_width
viewport_width = appear.debug.viewport_width
if draw_fg:
r.update_main_lines(RenderInput.wrap_datas(datas), [0] * len(datas))
frame_colors: np.ndarray = np.frombuffer(r.get_frame(), dtype=np.uint8).reshape(
(-1, BYTES_PER_PIXEL)
)
bg_u8 = color_to_bytes(bg_str)
all_colors = [bg_u8]
fg_u8 = color_to_bytes(fg_str)
if draw_fg:
all_colors.append(fg_u8)
is_grid = bool(grid_str and grid_line_width >= 1)
if is_grid:
grid_u8 = color_to_bytes(grid_str)
all_colors.append(grid_u8)
else:
grid_u8 = np.array([1000] * BYTES_PER_PIXEL)
data = datas[0]
assert (data.shape[1] > 1) == (data is RENDER_Y_STEREO)
is_stereo = is_grid and data.shape[1] > 1
if is_stereo:
stereo_grid_u8 = (grid_u8 * OPACITY + bg_u8 * (1 - OPACITY)).astype(int)
all_colors.append(stereo_grid_u8)
# Ensure background is correct
bg_frame = frame_colors[0]
assert (
bg_frame == bg_u8
).all(), f"incorrect background, it might be grid_str={grid_str}"
# Ensure foreground is present
does_fg_appear_here = np.prod(frame_colors == fg_u8, axis=-1)
does_fg_appear = does_fg_appear_here.any()
# it might be 136 == #888888 == init_line_color
assert does_fg_appear == draw_fg, f"{does_fg_appear} != {draw_fg}"
if draw_fg:
expected_fg_pixels = NPLOTS * (WIDTH / viewport_width) * fg_line_width
assert does_fg_appear_here.sum() == pytest.approx(
expected_fg_pixels, abs=expected_fg_pixels * 0.1
)
# Ensure grid color is present
does_grid_appear_here = np.prod(frame_colors == grid_u8, axis=-1)
does_grid_appear = does_grid_appear_here.any()
assert does_grid_appear == is_grid, f"{does_grid_appear} != {is_grid}"
if is_grid:
assert np.sum(does_grid_appear_here) == pytest.approx(
GRID_NPIXEL * grid_line_width, abs=GRID_NPIXEL * 0.1
)
# Ensure stereo grid color is present
if is_stereo:
assert (
np.min(np.sum(np.abs(frame_colors - stereo_grid_u8), axis=-1)) < TOLERANCE
), "Missing stereo gridlines"
assert (np.amax(frame_colors, axis=0) == np.amax(all_colors, axis=0)).all()
assert (np.amin(frame_colors, axis=0) == np.amin(all_colors, axis=0)).all()
# Test label positioning and rendering
@parametrize("label_position", list(LabelPosition.__members__.values()))
@parametrize("data", [RENDER_Y_ZEROS, RENDER_Y_STEREO])
@parametrize("hide_lines", [True, False])
def test_label_render(label_position: LabelPosition, data, hide_lines):
"""Test that text labels are drawn:
- in the correct quadrant
- with the correct color (defaults to init_line_color)
- even if no lines are drawn at all
"""
font_str = "#FF00FF"
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,
cfg_kwargs = {}
if hide_lines:
cfg_kwargs.update(init_line_color=font_str)
cfg = RendererConfig(
WIDTH,
HEIGHT,
antialiasing=False,
label_font=Font(size=16, bold=True),
label_position=label_position,
label_color_override=font_str,
**cfg_kwargs,
)
lcfg = LayoutConfig()
nplots = 1
labels = ["#"] * nplots
datas = [data] * nplots
r = Renderer.from_obj(cfg, lcfg, datas, None, None)
r.add_labels(labels)
if not hide_lines:
r.update_main_lines(RenderInput.wrap_datas(datas), [0] * nplots)
frame_buffer: np.ndarray = np.frombuffer(r.get_frame(), dtype=np.uint8).reshape(
(r.h, r.w, BYTES_PER_PIXEL)
)
# Allow mutation
frame_buffer = frame_buffer.copy()
yslice = label_position.y.match(
top=slice(None, r.h // 2), bottom=slice(r.h // 2, None)
)
xslice = label_position.x.match(
left=slice(None, r.w // 2), right=slice(r.w // 2, None)
)
quadrant = frame_buffer[yslice, xslice]
assert np.prod(quadrant == font_u8, axis=-1).any(), "Missing text"
quadrant[:] = 0
assert not np.prod(
frame_buffer == font_u8, axis=-1
).any(), "Text appeared in wrong area of screen"
# Stereo *renderer* integration tests.
def test_stereo_render_integration(mocker: "pytest_mock.MockFixture"):
"""Ensure corrscope plays/renders in stereo, without crashing."""
# Stub out FFplay output.
mocker.patch.object(FFplayOutputConfig, "cls")
# Render in stereo.
cfg = template_config(
channels=[ChannelConfig("tests/stereo in-phase.wav")],
render_stereo=Flatten.Stereo,
end_time=0.5, # Reduce test duration
render=RendererConfig(WIDTH, HEIGHT),
)
# Make sure it doesn't crash.
corr = CorrScope(cfg, Arguments(".", [FFplayOutputConfig()]))
corr.play()
def test_stereo_bars_doesnt_crash(mocker: "pytest_mock.MockFixture"):
"""Currently Renderer gets its stereo volume data from CorrScope.
TODO ideally we'd build a custom output object which captures frames, so we can
check for the presence of pixel values."""
# Stub out FFplay output.
mocker.patch.object(FFplayOutputConfig, "cls")
# Render in stereo.
cfg = template_config(
channels=[ChannelConfig("tests/stereo in-phase.wav")],
render_stereo=Flatten.Stereo,
end_time=0.5, # Reduce test duration
render=RendererConfig(WIDTH, HEIGHT, global_stereo_bars=True),
)
# Make sure it doesn't crash.
corr = CorrScope(cfg, Arguments(".", [FFplayOutputConfig()]))
corr.play()
def test_stereo_bars_color():
"""Check that the correct stereo bar values produce the right color in render
output. Does not check that CorrScope passes the right input to Renderer for
stereo bars."""
corr_cfg = template_config()
cfg = corr_cfg.render
cfg.global_stereo_bars = True
cfg.stereo_bar_color = "#ff00ff"
stereo_bar_color = color_to_bytes(cfg.stereo_bar_color)
# Setup r = Renderer().
lcfg = LayoutConfig(orientation=ORIENTATION)
datas = [RENDER_Y_STEREO]
r = Renderer.from_obj(cfg, lcfg, datas, channel_cfgs=None, channels=None)
# Check that stereo bars are visible.
render_inputs = [RenderInput(data, (0.5, 0.5), None) for data in datas]
r.update_main_lines(render_inputs, [0] * len(datas))
frame_colors: np.ndarray = np.frombuffer(r.get_frame(), dtype=np.uint8).reshape(
(-1, BYTES_PER_PIXEL)
)
does_bar_appear_here = np.prod(frame_colors == stereo_bar_color, axis=-1)
does_bar_appear = does_bar_appear_here.any()
assert does_bar_appear, "color bar missing"
# Check that stereo bars are invisible when passed zero levels.
render_inputs = [RenderInput(data, (0.0, 0.0), None) for data in datas]
r.update_main_lines(render_inputs, [0] * len(datas))
frame_colors: np.ndarray = np.frombuffer(r.get_frame(), dtype=np.uint8).reshape(
(-1, BYTES_PER_PIXEL)
)
does_bar_appear_here = np.prod(frame_colors == stereo_bar_color, axis=-1)
does_bar_appear = does_bar_appear_here.any()
assert not does_bar_appear, "unexpected color bar"
# Image dimension tests
@pytest.mark.parametrize(
"target_int, res_divisor", [(50, 2.0), (51, 2.0), (100, 1.001)]
)
def test_res_divisor_rounding_fixed(target_int: int, res_divisor: float):
verify_res_divisor_rounding(target_int, res_divisor, speed_hack=False)
@given(target_int=hs.integers(1, 10000), res_divisor=hs.floats(1, 100))
def test_res_divisor_rounding_hypothesis(target_int: int, res_divisor: float):
verify_res_divisor_rounding(target_int, res_divisor, speed_hack=True)
def verify_res_divisor_rounding(
target_int: int,
res_divisor: float,
speed_hack: bool,
):
"""Ensure that pathological-case float rounding errors
don't cause inconsistent dimensions and assertion errors."""
target_dim = target_int + 0.5
undivided_dim = round(target_dim * res_divisor)
cfg = RendererConfig(undivided_dim, undivided_dim, res_divisor=res_divisor)
cfg.before_preview()
with ExitStack() as stack:
if speed_hack:
stack.enter_context(
patch.object(AbstractMatplotlibRenderer, "_save_background")
)
datas = []
else:
datas = [RENDER_Y_ZEROS]
try:
renderer = Renderer.from_obj(cfg, LayoutConfig(), datas, None, None)
if not speed_hack:
renderer.update_main_lines(
RenderInput.wrap_datas(datas), [0] * len(datas)
)
renderer.get_frame()
except Exception:
perr(cfg.divided_width)
raise
# X-axis stride tests
@given(N=hs.integers(2, 1000), stride=hs.integers(1, 1000))
def test_calc_limits_xs(N: int, stride: int):
"""Sanity check to ensure that data is drawn from 1 edge of screen to another,
that calc_limits() and calc_xs() match.
Test that calc_center() == "to the left of N//2".
"""
min, max = calc_limits(N, stride)
xs = calc_xs(N, stride)
assert xs[0] == min
assert xs[-1] == max
center = calc_center(stride)
assert np.mean([xs[N // 2 - 1], xs[N // 2]]) == pytest.approx(center)
# Debug plotting tests
@parametrize("integration", [False, True])
def test_renderer_knows_stride(mocker: "pytest_mock.MockFixture", integration: bool):
"""
If Renderer draws both "main line" and "custom mono lines" at once,
each line must have its x-coordinates multiplied by the stride.
Renderer uses "main line stride = 1" by default,
but this results in the main line appearing too narrow compared to debug lines.
Make sure CorrScope.play() gives Renderer the correct values.
"""
# Stub out FFplay output.
mocker.patch.object(FFplayOutputConfig, "cls")
subsampling = 2
width_mul = 3
chan_cfg = ChannelConfig("tests/sine440.wav", render_width=width_mul)
corr_cfg = template_config(
render_subsampling=subsampling, channels=[chan_cfg], end_time=0
)
if integration:
corr = CorrScope(corr_cfg, Arguments(".", [FFplayOutputConfig()]))
corr.play()
assert corr.renderer.render_strides == [subsampling * width_mul]
else:
channel = Channel(chan_cfg, corr_cfg, channel_idx=0)
data = channel.get_render_around(0)
renderer = Renderer.from_obj(
corr_cfg.render, corr_cfg.layout, [data], [chan_cfg], [channel]
)
assert renderer.render_strides == [subsampling * width_mul]
# Multiple inheritance tests
def test_frontend_overrides_backend(mocker: "pytest_mock.MockFixture"):
"""
class Renderer inherits from (RendererFrontend, backend).
RendererFrontend.get_frame() is a wrapper around backend.get_frame()
and should override it (RendererFrontend should come first in MRO).
Make sure RendererFrontend methods overshadow backend methods.
"""
# If RendererFrontend.get_frame() override is removed, delete this entire test.
frontend_get_frame = mocker.spy(RendererFrontend, "get_frame")
backend_get_frame = mocker.spy(AbstractMatplotlibRenderer, "get_frame")
corr_cfg = template_config()
chan_cfg = ChannelConfig("tests/sine440.wav")
channel = Channel(chan_cfg, corr_cfg, channel_idx=0)
data = channel.get_render_around(0)
renderer = Renderer.from_obj(
corr_cfg.render, corr_cfg.layout, [data], [chan_cfg], [channel]
)
renderer.update_main_lines([RenderInput.stub_new(data)], [0])
renderer.get_frame()
assert frontend_get_frame.call_count == 1
assert backend_get_frame.call_count == 1
def test_custom_line():
def verify(line: CustomLine, xdata: list):
line_xdata = line.xdata
assert isinstance(line_xdata, np.ndarray)
assert line_xdata.tolist() == xdata
stride = 1
noop = lambda x: None
line = CustomLine(stride, [3], noop, noop)
verify(line, [3])
line.xdata = [4, 4]
verify(line, [4, 4])