diff --git a/corrscope/channel.py b/corrscope/channel.py index 63cfd33..2918693 100644 --- a/corrscope/channel.py +++ b/corrscope/channel.py @@ -61,7 +61,7 @@ class Channel: # Product of corr_cfg.trigger/render_subsampling and trigger/render_width. _trigger_stride: int - _render_stride: int + render_stride: int def __init__(self, cfg: ChannelConfig, corr_cfg: "Config", channel_idx: int = 0): """channel_idx counts from 0.""" @@ -105,7 +105,7 @@ class Channel: self._render_samp = calculate_nsamp(corr_cfg.render_ms, rsub) self._trigger_stride = tsub * tw - self._render_stride = rsub * rw + self.render_stride = rsub * rw # Create a Trigger object. if isinstance(cfg.trigger, MainTriggerConfig): @@ -130,9 +130,10 @@ class Channel: tsamp=trigger_samp, stride=self._trigger_stride, fps=corr_cfg.fps, + wave_idx=channel_idx, ) def get_render_around(self, trigger_sample: int): return self.render_wave.get_around( - trigger_sample, self._render_samp, self._render_stride + trigger_sample, self._render_samp, self.render_stride ) diff --git a/corrscope/corrscope.py b/corrscope/corrscope.py index cb073d7..f58306b 100644 --- a/corrscope/corrscope.py +++ b/corrscope/corrscope.py @@ -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 Renderer, RendererConfig, BaseRenderer +from corrscope.renderer import Renderer, RendererConfig, RendererFrontend from corrscope.triggers import CorrelationTriggerConfig, PerFrameCache, SpectrumConfig from corrscope.util import pushd, coalesce from corrscope.wave import Wave, Flatten @@ -217,10 +217,14 @@ class CorrScope: ] yield - def _load_renderer(self) -> BaseRenderer: + def _load_renderer(self) -> RendererFrontend: dummy_datas = [channel.get_render_around(0) for channel in self.channels] renderer = Renderer( - self.cfg.render, self.cfg.layout, dummy_datas, self.cfg.channels + self.cfg.render, + self.cfg.layout, + dummy_datas, + self.cfg.channels, + self.channels, ) return renderer @@ -246,6 +250,10 @@ class CorrScope: renderer.add_labels([channel.label for channel in self.channels]) + # For debugging only + # for trigger in self.triggers: + # trigger.set_renderer(renderer) + if PRINT_TIMESTAMP: begin = time.perf_counter() diff --git a/corrscope/renderer.py b/corrscope/renderer.py index 079e8ea..45b2a31 100644 --- a/corrscope/renderer.py +++ b/corrscope/renderer.py @@ -1,6 +1,7 @@ import enum import os from abc import ABC, abstractmethod +from collections import defaultdict from typing import ( Optional, List, @@ -11,10 +12,15 @@ from typing import ( Sequence, Type, Union, + Tuple, + Dict, + DefaultDict, + MutableSequence, ) import attr import numpy as np +from matplotlib.cm import get_cmap from corrscope.config import DumpableAttrs, with_units, TypedEnumDump from corrscope.layout import ( @@ -56,10 +62,11 @@ if TYPE_CHECKING: from matplotlib.artist import Artist from matplotlib.axes import Axes from matplotlib.backend_bases import FigureCanvasBase + from matplotlib.colors import ListedColormap from matplotlib.lines import Line2D from matplotlib.spines import Spine from matplotlib.text import Text, Annotation - from corrscope.channel import ChannelConfig + from corrscope.channel import ChannelConfig, Channel # Used by outputs.py. @@ -166,6 +173,10 @@ class RendererConfig(DumpableAttrs, always_dump="*"): # Performance (skipped when recording to video) res_divisor: float = 1.0 + # Debugging only + viewport_width: float = 1 + viewport_height: float = 1 + def __attrs_post_init__(self) -> None: # round(np.int32 / float) == np.float32, but we want int. assert isinstance(self.width, (int, float)) @@ -185,7 +196,16 @@ class LineParam: color: str -UpdateLines = Callable[[List[np.ndarray]], None] +UpdateLines = Callable[[List[np.ndarray]], Any] +UpdateOneLine = Callable[[np.ndarray], Any] + + +@attr.dataclass +class CustomLine: + stride: int + xdata: np.ndarray + set_xdata: UpdateOneLine + set_ydata: UpdateOneLine @property @@ -194,7 +214,7 @@ def abstract_classvar(self) -> Any: """A ClassVar to be overriden by a subclass.""" -class BaseRenderer(ABC): +class _RendererBackend(ABC): """ Renderer backend which takes data and produces images. Does not touch Wave or Channel. @@ -222,6 +242,7 @@ class BaseRenderer(ABC): lcfg: "LayoutConfig", dummy_datas: List[np.ndarray], channel_cfgs: Optional[List["ChannelConfig"]], + channels: List["Channel"], ): self.cfg = cfg self.lcfg = lcfg @@ -251,18 +272,22 @@ class BaseRenderer(ABC): for color in line_colors ] + # Load channel strides. + if channels is not None: + if len(channels) != self.nplots: + raise ValueError( + f"cannot assign {len(channels)} channels to {self.nplots} plots" + ) + self.render_strides = [channel.render_stride for channel in channels] + else: + self.render_strides = [1] * self.nplots + # Instance functionality - _update_main_lines: Optional[UpdateLines] = None - - def update_main_lines(self, datas: List[np.ndarray]) -> None: - if self._update_main_lines is None: - self._update_main_lines = self.add_lines(datas) - - self._update_main_lines(datas) - @abstractmethod - def add_lines(self, dummy_datas: List[np.ndarray]) -> UpdateLines: + def add_lines_stereo( + self, dummy_datas: List[np.ndarray], strides: List[int] + ) -> UpdateLines: ... @abstractmethod @@ -273,6 +298,102 @@ class BaseRenderer(ABC): def add_labels(self, labels: List[str]) -> Any: ... + @abstractmethod + def add_xy_line_mono( + self, wave_idx: int, xs: Sequence[float], ys: Sequence[float], stride: int + ) -> CustomLine: + ... + + +class RendererFrontend(_RendererBackend, ABC): + def __init__(self, *args, **kwargs): + _RendererBackend.__init__(self, *args, **kwargs) + self._update_main_lines = None + self._custom_lines = {} + self._offsetable = defaultdict(list) + + _update_main_lines: Optional[UpdateLines] + + def update_main_lines(self, datas: List[np.ndarray]) -> None: + if self._update_main_lines is None: + self._update_main_lines = self.add_lines_stereo(datas, self.render_strides) + + self._update_main_lines(datas) + + _custom_lines: Dict[Any, CustomLine] + _offsetable: DefaultDict[int, MutableSequence[CustomLine]] + + def update_custom_line( + self, + name: str, + wave_idx: int, + stride: int, + data: np.ndarray, + *, + offset: bool = True, + ): + data = data.copy() + key = (name, wave_idx) + + if key not in self._custom_lines: + line = self._add_line_mono(wave_idx, stride, data) + self._custom_lines[key] = line + if offset: + self._offsetable[wave_idx].append(line) + else: + line = self._custom_lines[key] + + line.set_ydata(data) + + def update_vline( + self, name: str, wave_idx: int, stride: int, x: int, *, offset: bool = True + ): + key = (name, wave_idx) + if key not in self._custom_lines: + line = self._add_vline_mono(wave_idx, stride) + self._custom_lines[key] = line + if offset: + self._offsetable[wave_idx].append(line) + else: + line = self._custom_lines[key] + + line.xdata = [x * stride] * 2 + self._custom_lines[key].set_xdata(line.xdata) + + def offset_viewport(self, wave_idx: int, viewport_offset: float): + line_offset = -viewport_offset + + for line in self._offsetable[wave_idx]: + line.set_xdata(line.xdata + line_offset * line.stride) + + def _add_line_mono( + self, wave_idx: int, stride: int, dummy_data: np.ndarray + ) -> CustomLine: + ys = np.zeros_like(dummy_data) + xs = calc_xs(len(ys), stride) + return self.add_xy_line_mono(wave_idx, xs, ys, stride) + + def _add_vline_mono(self, wave_idx: int, stride: int) -> CustomLine: + return self.add_xy_line_mono(wave_idx, [0, 0], [-1, 1], stride) + + +# See Wave.get_around() and designNotes.md. +# Viewport functions +def calc_limits(N: int, viewport_stride: float) -> Tuple[float, float]: + halfN = N // 2 + max_x = N - 1 + return np.array([-halfN, -halfN + max_x]) * viewport_stride + + +def calc_center(viewport_stride: float) -> float: + return -0.5 * viewport_stride + + +# Line functions +def calc_xs(N: int, stride: int) -> Sequence[float]: + halfN = N // 2 + return (np.arange(N) - halfN) * stride + Point = float Pixel = float @@ -290,7 +411,7 @@ def px_from_points(pt: Point) -> Pixel: return pt * PIXELS_PER_PT -class AbstractMatplotlibRenderer(BaseRenderer, ABC): +class AbstractMatplotlibRenderer(RendererFrontend, ABC): """Matplotlib renderer which can use any backend (agg, mplcairo). To pick a backend, subclass and set canvas_type at the class level. """ @@ -303,7 +424,7 @@ class AbstractMatplotlibRenderer(BaseRenderer, ABC): pass def __init__(self, *args, **kwargs): - BaseRenderer.__init__(self, *args, **kwargs) + RendererFrontend.__init__(self, *args, **kwargs) dict.__setitem__( matplotlib.rcParams, "lines.antialiased", self.cfg.antialiasing @@ -385,17 +506,28 @@ class AbstractMatplotlibRenderer(BaseRenderer, ABC): # Returns 2D list of [self.nplots][1]Axes. axes_mono_2d = self.layout_mono.arrange(self._axes_factory, label="mono") for axes_list in axes_mono_2d: - assert len(axes_list) == 1 - self._axes_mono.extend(axes_list) + (axes,) = axes_list # type: Axes + + # List of colors at + # https://matplotlib.org/gallery/color/colormap_reference.html + # Discussion at https://github.com/matplotlib/matplotlib/issues/10840 + cmap: ListedColormap = get_cmap("Accent") + colors = cmap.colors + axes.set_prop_cycle(color=colors) + + self._axes_mono.append(axes) # Setup axes for idx, N in enumerate(self.wave_nsamps): wave_axes = self._axes2d[idx] - max_x = N - 1 + + viewport_stride = self.render_strides[idx] * cfg.viewport_width + ylim = cfg.viewport_height def scale_axes(ax: "Axes"): - ax.set_xlim(0, max_x) - ax.set_ylim(-1, 1) + xlim = calc_limits(N, viewport_stride) + ax.set_xlim(*xlim) + ax.set_ylim(-ylim, ylim) scale_axes(self._axes_mono[idx]) for ax in unique_by_id(wave_axes): @@ -408,9 +540,7 @@ class AbstractMatplotlibRenderer(BaseRenderer, ABC): # Not quite sure if midlines or gridlines draw on top kw = dict(color=midline_color, linewidth=midline_width) if cfg.v_midline: - # See Wave.get_around() docstring. - # wave_data[N//2] == self[sample], usually > 0. - ax.axvline(x=N // 2 - 0.5, **kw) + ax.axvline(x=calc_center(viewport_stride), **kw) if cfg.h_midline: ax.axhline(y=0, **kw) @@ -490,7 +620,9 @@ class AbstractMatplotlibRenderer(BaseRenderer, ABC): return ax # Public API - def add_lines(self, dummy_datas: List[np.ndarray]) -> UpdateLines: + def add_lines_stereo( + self, dummy_datas: List[np.ndarray], strides: List[int] + ) -> UpdateLines: cfg = self.cfg # Plot lines over background @@ -504,22 +636,26 @@ class AbstractMatplotlibRenderer(BaseRenderer, ABC): wave_axes = self._axes2d[wave_idx] wave_lines = [] + xs = calc_xs(len(wave_zeros), strides[wave_idx]) + # Foreach chan for chan_idx, chan_zeros in enumerate(wave_zeros.T): ax = wave_axes[chan_idx] line_color = self._line_params[wave_idx].color chan_line: Line2D = ax.plot( - chan_zeros, color=line_color, linewidth=line_width + xs, chan_zeros, color=line_color, linewidth=line_width )[0] wave_lines.append(chan_line) lines2d.append(wave_lines) self._artists.extend(wave_lines) - return lambda datas: self._update_lines(lines2d, datas) + return lambda datas: self._update_lines_stereo(lines2d, datas) @staticmethod - def _update_lines(lines2d: "List[List[Line2D]]", datas: List[np.ndarray]) -> None: + def _update_lines_stereo( + lines2d: "List[List[Line2D]]", datas: List[np.ndarray] + ) -> None: """ Preconditions: - lines2d[wave][chan] = Line2D @@ -542,6 +678,22 @@ class AbstractMatplotlibRenderer(BaseRenderer, ABC): chan_line = wave_lines[chan_idx] chan_line.set_ydata(chan_data) + def add_xy_line_mono( + self, wave_idx: int, xs: Sequence[float], ys: Sequence[float], stride: int + ) -> CustomLine: + cfg = self.cfg + + # Plot lines over background + line_width = cfg.line_width + + ax = self._axes_mono[wave_idx] + mono_line: Line2D = ax.plot(xs, ys, linewidth=line_width)[0] + + self._artists.append(mono_line) + + # noinspection PyTypeChecker + return CustomLine(stride, xs, mono_line.set_xdata, mono_line.set_ydata) + # Channel labels def add_labels(self, labels: List[str]) -> List["Text"]: """ diff --git a/corrscope/triggers.py b/corrscope/triggers.py index 5517295..23e190b 100644 --- a/corrscope/triggers.py +++ b/corrscope/triggers.py @@ -23,8 +23,8 @@ if TYPE_CHECKING: class _TriggerConfig: cls: ClassVar[Type["_Trigger"]] - def __call__(self, wave: "Wave", tsamp: int, stride: int, fps: float) -> "_Trigger": - return self.cls(wave, cfg=self, tsamp=tsamp, stride=stride, fps=fps) + def __call__(self, wave: "Wave", *args, **kwargs) -> "_Trigger": + return self.cls(wave, self, *args, **kwargs) class MainTriggerConfig( @@ -71,7 +71,14 @@ def register_trigger(config_t: Type[_TriggerConfig]): class _Trigger(ABC): def __init__( - self, wave: "Wave", cfg: _TriggerConfig, tsamp: int, stride: int, fps: float + self, + wave: "Wave", + cfg: _TriggerConfig, + tsamp: int, + stride: int, + fps: float, + renderer: Optional["RendererFrontend"] = None, + wave_idx: int = 0, ): self.cfg = cfg self._wave = wave @@ -81,6 +88,10 @@ class _Trigger(ABC): self._stride = stride self._fps = fps + # Only used for debug plots + self._renderer = renderer + self._wave_idx = wave_idx + frame_dur = 1 / fps # Subsamples per frame self._tsamp_frame = self.time2tsamp(frame_dur) @@ -88,6 +99,23 @@ class _Trigger(ABC): def time2tsamp(self, time: float) -> int: return round(time * self._wave.smp_s / self._stride) + def custom_line(self, name: str, data: np.ndarray, **kwargs): + if self._renderer is None: + return + self._renderer.update_custom_line( + name, self._wave_idx, self._stride, data, **kwargs + ) + + def custom_vline(self, name: str, x: int): + if self._renderer is None: + return + self._renderer.update_vline(name, self._wave_idx, self._stride, x) + + def offset_viewport(self, offset: int): + if self._renderer is None: + return + self._renderer.offset_viewport(self._wave_idx, offset) + @abstractmethod def get_trigger(self, index: int, cache: "PerFrameCache") -> int: """ @@ -115,10 +143,22 @@ class MainTrigger(_Trigger, ABC): if cfg.post_trigger: # Create a post-processing trigger, with narrow nsamp and stride=1. # This improves speed and precision. - self.post = cfg.post_trigger(self._wave, cfg.post_radius, 1, self._fps) + self.post = cfg.post_trigger( + self._wave, + cfg.post_radius, + 1, + self._fps, + self._renderer, + self._wave_idx, + ) else: self.post = None + def set_renderer(self, renderer: "RendererFrontend"): + self._renderer = renderer + if self.post: + self.post._renderer = renderer + def do_not_inherit__Trigger_directly(self): pass diff --git a/tests/test_channel.py b/tests/test_channel.py index 1934554..f168bf5 100644 --- a/tests/test_channel.py +++ b/tests/test_channel.py @@ -115,7 +115,7 @@ def test_config_channel_integration( assert channel._render_samp == ideal_rsamp assert channel._trigger_stride == tsub * c_trigger_width - assert channel._render_stride == rsub * c_render_width + assert channel.render_stride == rsub * c_render_width # Ensure amplification override works args, kwargs = Wave.call_args @@ -134,7 +134,7 @@ def test_config_channel_integration( # Only Channel.get_render_around() (not NullTrigger) calls wave.get_around(). (_sample, _return_nsamp, _subsampling), kwargs = wave.get_around.call_args assert _return_nsamp == channel._render_samp - assert _subsampling == channel._render_stride + assert _subsampling == channel.render_stride # Inspect arguments to renderer.update_main_lines() # datas: List[np.ndarray] diff --git a/tests/test_layout.py b/tests/test_layout.py index d96317c..1940587 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -222,7 +222,7 @@ def test_renderer_layout(): nplots = 15 datas = [RENDER_Y_ZEROS] * nplots - r = Renderer(cfg, lcfg, datas, None) + r = Renderer(cfg, lcfg, datas, None, None) r.update_main_lines(datas) layout = r.layout diff --git a/tests/test_output.py b/tests/test_output.py index 7b48025..0df252c 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -88,7 +88,7 @@ def test_render_output(): """ Ensure rendering to output does not raise exceptions. """ datas = [RENDER_Y_ZEROS] - renderer = Renderer(CFG.render, CFG.layout, datas, channel_cfgs=None) + renderer = Renderer(CFG.render, CFG.layout, datas, None, None) out: FFmpegOutput = NULL_FFMPEG_OUTPUT(CFG) renderer.update_main_lines(datas) diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 8dcc034..3bc0545 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -6,11 +6,19 @@ import numpy as np import pytest from hypothesis import given -from corrscope.channel import ChannelConfig +from corrscope.channel import ChannelConfig, Channel from corrscope.corrscope import CorrScope, default_config, Arguments from corrscope.layout import LayoutConfig from corrscope.outputs import FFplayOutputConfig -from corrscope.renderer import RendererConfig, Renderer, LabelPosition, Font +from corrscope.renderer import ( + RendererConfig, + Renderer, + LabelPosition, + Font, + calc_limits, + calc_xs, + calc_center, +) from corrscope.util import perr from corrscope.wave import Flatten @@ -51,6 +59,7 @@ def appearance_to_str(val) -> Optional[str]: # "str" = HTML #FFFFFF color string. + @attr.dataclass(frozen=True) class BG: color: str @@ -60,6 +69,7 @@ class BG: class FG: color: str draw_fg: bool = True + line_width: float = 2.0 @attr.dataclass(frozen=True) @@ -68,12 +78,18 @@ class Grid: color: Optional[str] +@attr.dataclass(frozen=True) +class Debug: + viewport_width: float = 1 + + bg_black = BG("#000000") bg_white = BG("#ffffff") bg_blue = BG("#0000aa") bg_yellow = BG("#aaaa00") fg_white = FG("#ffffff") +fg_white_thick = FG("#ffffff", line_width=10.0) fg_black = FG("#000000") fg_NONE = FG("#ffffff", draw_fg=False) fg_yellow = FG("#aaaa00") @@ -86,11 +102,16 @@ grid_10 = Grid(10, "#ff00ff") grid_NONE = Grid(10, None) +debug_NONE = Debug() +debug_wide = Debug(viewport_width=2) + + @attr.dataclass(frozen=True) class Appearance: bg: BG fg: FG grid: Grid + debug: Debug = debug_NONE all_colors = pytest.mark.parametrize( @@ -104,6 +125,8 @@ all_colors = pytest.mark.parametrize( (Appearance(bg_white, fg_black, grid_NONE), RENDER_Y_ZEROS), (Appearance(bg_blue, fg_yellow, grid_NONE), RENDER_Y_ZEROS), (Appearance(bg_yellow, fg_blue, grid_NONE), RENDER_Y_ZEROS), + # Test FG line thickness + (Appearance(bg_black, fg_white_thick, grid_NONE), RENDER_Y_ZEROS), # Test various grid thicknesses (Appearance(bg_white, fg_black, grid_0), RENDER_Y_ZEROS), (Appearance(bg_blue, fg_yellow, grid_1), RENDER_Y_ZEROS), @@ -112,6 +135,8 @@ all_colors = pytest.mark.parametrize( (Appearance(bg_black, fg_white, grid_NONE), RENDER_Y_ZEROS), (Appearance(bg_blue, fg_yellow, grid_0), RENDER_Y_STEREO), (Appearance(bg_blue, fg_yellow, grid_10), RENDER_Y_STEREO), + # Test debugging (viewport width) + (Appearance(bg_black, fg_white, grid_NONE, debug_wide), RENDER_Y_ZEROS), ], ids=appearance_to_str, ) @@ -121,12 +146,16 @@ 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, - line_width=2.0, antialiasing=False, ) return cfg @@ -144,13 +173,13 @@ def test_default_colors(appear: Appearance, data): lcfg = LayoutConfig(orientation=ORIENTATION) datas = [data] * NPLOTS - r = Renderer(cfg, lcfg, datas, None) + r = Renderer(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(cfg, lcfg, datas, channels) + r = Renderer(cfg, lcfg, datas, channels, None) verify(r, appear, datas) @@ -167,7 +196,7 @@ def test_line_colors(appear: Appearance, data): cfg.init_line_color = "#888888" chan.line_color = appear.fg.color - r = Renderer(cfg, lcfg, datas, channels) + r = Renderer(cfg, lcfg, datas, channels, None) verify(r, appear, datas) @@ -179,10 +208,13 @@ def verify(r: Renderer, appear: Appearance, datas: List[Optional[np.ndarray]]): 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(datas) @@ -216,14 +248,20 @@ def verify(r: Renderer, appear: Appearance, datas: List[Optional[np.ndarray]]): bg_frame = frame_colors[0] assert ( bg_frame == bg_u8 - ).all(), f"incorrect background, it might be grid_str={grid.color}" + ).all(), f"incorrect background, it might be grid_str={grid_str}" # Ensure foreground is present - does_fg_appear = np.prod(frame_colors == fg_u8, axis=-1).any() - + 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() @@ -279,7 +317,7 @@ def test_label_render(label_position: LabelPosition, data, hide_lines): labels = ["#"] * nplots datas = [data] * nplots - r = Renderer(cfg, lcfg, datas, None) + r = Renderer(cfg, lcfg, datas, None, None) r.add_labels(labels) if not hide_lines: r.update_main_lines(datas) @@ -326,6 +364,7 @@ def test_stereo_render_integration(mocker: "pytest_mock.MockFixture"): corr.play() +# Image dimension tests @pytest.mark.parametrize( "target_int, res_divisor", [(50, 2.0), (51, 2.0), (100, 1.001)] ) @@ -359,10 +398,63 @@ def verify_res_divisor_rounding( datas = [RENDER_Y_ZEROS] try: - renderer = Renderer(cfg, LayoutConfig(), datas, channel_cfgs=None) + renderer = Renderer(cfg, LayoutConfig(), datas, None, None) if not speed_hack: renderer.update_main_lines(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 = default_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( + corr_cfg.render, corr_cfg.layout, [data], [chan_cfg], [channel] + ) + assert renderer.render_strides == [subsampling * width_mul]