kopia lustrzana https://github.com/corrscope/corrscope
Add debug visualizations
rodzic
f187e53d55
commit
457e50c88a
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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"]:
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
|
|
Ładowanie…
Reference in New Issue