kopia lustrzana https://github.com/corrscope/corrscope
Add support for per-channel labels (#256)
Known issue: matplotlib's font weight handling is often wrong.pull/357/head
rodzic
ba4326fc4a
commit
8300ec3682
|
|
@ -1,10 +1,18 @@
|
|||
from enum import unique, auto
|
||||
from os.path import abspath
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional, Union, Dict, Any
|
||||
|
||||
import attr
|
||||
from ruamel.yaml.comments import CommentedMap
|
||||
|
||||
from corrscope.config import DumpableAttrs, Alias, CorrError, evolve_compat
|
||||
from corrscope.config import (
|
||||
DumpableAttrs,
|
||||
Alias,
|
||||
CorrError,
|
||||
evolve_compat,
|
||||
TypedEnumDump,
|
||||
)
|
||||
from corrscope.triggers import MainTriggerConfig
|
||||
from corrscope.util import coalesce
|
||||
from corrscope.wave import Wave, Flatten
|
||||
|
|
@ -15,6 +23,7 @@ if TYPE_CHECKING:
|
|||
|
||||
class ChannelConfig(DumpableAttrs):
|
||||
wav_path: str
|
||||
label: str = ""
|
||||
|
||||
# Supplying a dict inherits attributes from global trigger.
|
||||
# TODO test channel-specific triggers
|
||||
|
|
@ -39,6 +48,13 @@ class ChannelConfig(DumpableAttrs):
|
|||
# endregion
|
||||
|
||||
|
||||
@unique
|
||||
class DefaultLabel(TypedEnumDump):
|
||||
NoLabel = 0
|
||||
FileName = auto()
|
||||
Number = auto()
|
||||
|
||||
|
||||
class Channel:
|
||||
# trigger_samp is unneeded, since __init__ (not CorrScope) constructs triggers.
|
||||
_render_samp: int
|
||||
|
|
@ -47,9 +63,17 @@ class Channel:
|
|||
_trigger_stride: int
|
||||
_render_stride: int
|
||||
|
||||
def __init__(self, cfg: ChannelConfig, corr_cfg: "Config"):
|
||||
def __init__(self, cfg: ChannelConfig, corr_cfg: "Config", channel_idx: int = 0):
|
||||
"""channel_idx counts from 0."""
|
||||
self.cfg = cfg
|
||||
|
||||
self.label = cfg.label
|
||||
if not self.label:
|
||||
if corr_cfg.default_label is DefaultLabel.FileName:
|
||||
self.label = Path(cfg.wav_path).stem
|
||||
elif corr_cfg.default_label is DefaultLabel.Number:
|
||||
self.label = str(channel_idx + 1)
|
||||
|
||||
# Create a Wave object.
|
||||
wave = Wave(
|
||||
abspath(cfg.wav_path),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from typing import Optional, List, Callable
|
|||
import attr
|
||||
|
||||
from corrscope import outputs as outputs_
|
||||
from corrscope.channel import Channel, ChannelConfig
|
||||
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
|
||||
|
|
@ -85,6 +85,7 @@ class Config(
|
|||
|
||||
# Multiplies by trigger_width, render_width. Can override trigger.
|
||||
channels: List[ChannelConfig]
|
||||
default_label: DefaultLabel = DefaultLabel.NoLabel
|
||||
|
||||
layout: LayoutConfig
|
||||
render: RendererConfig
|
||||
|
|
@ -197,7 +198,10 @@ class CorrScope:
|
|||
raise CorrError(
|
||||
f'File not found: master_audio="{self.cfg.master_audio}"'
|
||||
)
|
||||
self.channels = [Channel(ccfg, self.cfg) for ccfg in self.cfg.channels]
|
||||
self.channels = [
|
||||
Channel(ccfg, self.cfg, idx)
|
||||
for idx, ccfg in enumerate(self.cfg.channels)
|
||||
]
|
||||
self.trigger_waves = [channel.trigger_wave for channel in self.channels]
|
||||
self.render_waves = [channel.render_wave for channel in self.channels]
|
||||
self.triggers = [channel.trigger for channel in self.channels]
|
||||
|
|
@ -240,6 +244,8 @@ class CorrScope:
|
|||
renderer = self._load_renderer()
|
||||
self.renderer = renderer # only used for unit tests
|
||||
|
||||
renderer.add_labels([channel.label for channel in self.channels])
|
||||
|
||||
if PRINT_TIMESTAMP:
|
||||
begin = time.perf_counter()
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from PyQt5.QtGui import QFont, QCloseEvent, QDesktopServices
|
|||
import corrscope
|
||||
import corrscope.settings.global_prefs as gp
|
||||
from corrscope import cli
|
||||
from corrscope.channel import ChannelConfig
|
||||
from corrscope.channel import ChannelConfig, DefaultLabel
|
||||
from corrscope.config import CorrError, copy_config, yaml
|
||||
from corrscope.corrscope import CorrScope, Config, Arguments, default_config
|
||||
from corrscope.gui.history_file_dlg import (
|
||||
|
|
@ -49,6 +49,7 @@ from corrscope.gui.util import color2hex, Locked, find_ranges, TracebackDialog
|
|||
from corrscope.gui.view_mainwindow import MainWindow as Ui_MainWindow
|
||||
from corrscope.layout import Orientation, StereoOrientation
|
||||
from corrscope.outputs import IOutputConfig, FFplayOutputConfig
|
||||
from corrscope.renderer import LabelPosition
|
||||
from corrscope.settings import paths
|
||||
from corrscope.triggers import (
|
||||
CorrelationTriggerConfig,
|
||||
|
|
@ -56,7 +57,7 @@ from corrscope.triggers import (
|
|||
SpectrumConfig,
|
||||
ZeroCrossingTriggerConfig,
|
||||
)
|
||||
from corrscope.util import obj_name, iround
|
||||
from corrscope.util import obj_name, iround, coalesce
|
||||
from corrscope.wave import Flatten
|
||||
|
||||
FILTER_WAV_FILES = ["WAV files (*.wav)"]
|
||||
|
|
@ -718,6 +719,54 @@ class ConfigModel(PresentationModel):
|
|||
|
||||
render__line_width = default_property("render__line_width", 1.5)
|
||||
|
||||
combo_symbol_text["default_label"] = [
|
||||
(DefaultLabel.NoLabel, MainWindow.tr("None", "Default Label")),
|
||||
(DefaultLabel.FileName, MainWindow.tr("File Name", "Default Label")),
|
||||
(DefaultLabel.Number, MainWindow.tr("Number", "Default Label")),
|
||||
]
|
||||
|
||||
combo_symbol_text["render.label_position"] = [
|
||||
(LabelPosition.LeftTop, "Top Left"),
|
||||
(LabelPosition.LeftBottom, "Bottom Left"),
|
||||
(LabelPosition.RightTop, "Top Right"),
|
||||
(LabelPosition.RightBottom, "Bottom Right"),
|
||||
]
|
||||
|
||||
@safe_property
|
||||
def render__label_qfont(self) -> QFont:
|
||||
qfont = QFont()
|
||||
qfont.setStyleHint(QFont.SansSerif) # no-op on X11
|
||||
|
||||
font = self.cfg.render.label_font
|
||||
if font.toString:
|
||||
qfont.fromString(font.toString)
|
||||
return qfont
|
||||
|
||||
# Passing None or "" to QFont(family) results in qfont.family() = "", and
|
||||
# wrong font being selected (Abyssinica SIL, which appears early in the list).
|
||||
family = coalesce(font.family, qfont.defaultFamily())
|
||||
# Font file selection
|
||||
qfont.setFamily(family)
|
||||
qfont.setBold(font.bold)
|
||||
qfont.setItalic(font.italic)
|
||||
# Font size
|
||||
qfont.setPointSizeF(font.size)
|
||||
return qfont
|
||||
|
||||
@render__label_qfont.setter
|
||||
def render__label_qfont(self, qfont: QFont):
|
||||
self.cfg.render.label_font = attr.evolve(
|
||||
self.cfg.render.label_font,
|
||||
# Font file selection
|
||||
family=qfont.family(),
|
||||
bold=qfont.bold(),
|
||||
italic=qfont.italic(),
|
||||
# Font size
|
||||
size=qfont.pointSizeF(),
|
||||
# QFont implementation details
|
||||
toString=qfont.toString(),
|
||||
)
|
||||
|
||||
# Layout
|
||||
layout__nrows = nrow_ncol_property("nrows", unaltered="ncols")
|
||||
layout__ncols = nrow_ncol_property("ncols", unaltered="nrows")
|
||||
|
|
@ -740,6 +789,9 @@ class ConfigModel(PresentationModel):
|
|||
class Column:
|
||||
key: str
|
||||
cls: Union[type, Callable[[str], Any]]
|
||||
|
||||
# `default` is written into config,
|
||||
# when users type "blank or whitespace" into table cell.
|
||||
default: Any
|
||||
|
||||
def _display_name(self) -> str:
|
||||
|
|
@ -793,6 +845,7 @@ class ChannelModel(qc.QAbstractTableModel):
|
|||
# columns
|
||||
col_data = [
|
||||
Column("wav_path", path_strip_quotes, "", "WAV Path"),
|
||||
Column("label", str, "", "Label"),
|
||||
Column("amplification", float, None, "Amplification\n(override)"),
|
||||
Column("line_color", str, None, "Line Color"),
|
||||
Column("render_stereo", str, None, "Render Stereo\nDownmix"),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from typing import *
|
|||
import attr
|
||||
from PyQt5 import QtWidgets as qw, QtCore as qc
|
||||
from PyQt5.QtCore import pyqtSlot
|
||||
from PyQt5.QtGui import QPalette, QColor
|
||||
from PyQt5.QtGui import QPalette, QColor, QFont
|
||||
from PyQt5.QtWidgets import QWidget
|
||||
|
||||
from corrscope.config import CorrError, DumpableAttrs, get_units
|
||||
|
|
@ -20,6 +20,7 @@ if TYPE_CHECKING:
|
|||
|
||||
assert Enum
|
||||
|
||||
# TODO include all BoundWidget subclasses into this?
|
||||
__all__ = [
|
||||
"PresentationModel",
|
||||
"map_gui",
|
||||
|
|
@ -367,6 +368,40 @@ class TypeComboBox(BoundComboBox):
|
|||
return obj_type()
|
||||
|
||||
|
||||
def _format_font_size(size: float) -> str:
|
||||
"""Strips away trailing .0 from 13.0.
|
||||
Basically unused, since QFontDialog will only allow integer font sizes."""
|
||||
return ("%f" % size).rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
class BoundFontButton(qw.QPushButton, BoundWidget):
|
||||
def __init__(self, parent: qw.QWidget):
|
||||
qw.QPushButton.__init__(self, parent)
|
||||
self.clicked.connect(self.on_clicked)
|
||||
|
||||
def set_gui(self, qfont: QFont):
|
||||
self.setText(qfont.family() + " " + _format_font_size(qfont.pointSizeF()))
|
||||
|
||||
preview_font = QFont(qfont)
|
||||
preview_font.setPointSizeF(self.font().pointSizeF())
|
||||
self.setFont(preview_font)
|
||||
|
||||
@pyqtSlot()
|
||||
def on_clicked(self):
|
||||
old_font: QFont = self.pmodel[self.path]
|
||||
|
||||
# https://doc.qt.io/qtforpython/PySide2/QtWidgets/QFontDialog.html#detailed-description
|
||||
# is wrong.
|
||||
(new_font, ok) = qw.QFontDialog.getFont(old_font, self.window())
|
||||
if ok:
|
||||
self.set_gui(new_font)
|
||||
self.gui_changed.emit(new_font)
|
||||
|
||||
gui_changed = qc.pyqtSignal(QFont)
|
||||
|
||||
set_model = model_setter(QFont)
|
||||
|
||||
|
||||
# Color-specific widgets
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
SOURCES += view_mainwindow.py
|
||||
SOURCES += __init__.py
|
||||
TRANSLATIONS += corrscope_xa.ts
|
||||
|
|
|
|||
|
|
@ -62,9 +62,9 @@ class MainWindow(QWidget):
|
|||
|
||||
# Left-hand config tabs
|
||||
with append_widget(s, TabWidget) as self.left_tabs:
|
||||
self.tabGeneral = self.add_general_tab(s)
|
||||
self.add_general_tab(s)
|
||||
self.add_appear_tab(s)
|
||||
self.tabTrigger = self.add_trigger_tab(s)
|
||||
self.add_trigger_tab(s)
|
||||
|
||||
# Right-hand channel list
|
||||
with append_widget(s, QVBoxLayout) as self.audioColumn:
|
||||
|
|
@ -141,9 +141,9 @@ class MainWindow(QWidget):
|
|||
tr = self.tr
|
||||
with self._add_tab(s, tr("&Appearance"), layout=QVBoxLayout) as tab:
|
||||
|
||||
with append_widget(s, QGroupBox) as self.optionAppearance:
|
||||
set_layout(s, QFormLayout)
|
||||
|
||||
with append_widget(
|
||||
s, QGroupBox, title=tr("Appearance"), layout=QFormLayout
|
||||
):
|
||||
with add_row(s, "", BoundLineEdit) as self.render_resolution:
|
||||
pass
|
||||
|
||||
|
|
@ -169,9 +169,38 @@ class MainWindow(QWidget):
|
|||
):
|
||||
pass
|
||||
|
||||
with append_widget(s, QGroupBox) as self.optionLayout:
|
||||
set_layout(s, QFormLayout)
|
||||
with append_widget(s, QGroupBox, title=tr("Labels"), layout=QFormLayout):
|
||||
with add_row(
|
||||
s, tr("Font"), BoundFontButton, name="render__label_qfont"
|
||||
):
|
||||
pass
|
||||
with add_row(
|
||||
s,
|
||||
tr("Font Color"),
|
||||
OptionalColorWidget,
|
||||
name="render.label_color_override",
|
||||
):
|
||||
pass
|
||||
|
||||
with add_row(
|
||||
s, tr("Label Position"), BoundComboBox, name="render.label_position"
|
||||
):
|
||||
pass
|
||||
with add_row(
|
||||
s,
|
||||
tr("Label Padding"),
|
||||
BoundDoubleSpinBox,
|
||||
name="render.label_padding_ratio",
|
||||
maximum=10,
|
||||
singleStep=0.25,
|
||||
):
|
||||
pass
|
||||
with add_row(
|
||||
s, tr("Default Label"), BoundComboBox, name="default_label"
|
||||
):
|
||||
pass
|
||||
|
||||
with append_widget(s, QGroupBox, title=tr("Layout"), layout=QFormLayout):
|
||||
with add_row(s, "", BoundComboBox) as self.layout__orientation:
|
||||
pass
|
||||
|
||||
|
|
@ -431,7 +460,6 @@ class MainWindow(QWidget):
|
|||
self.render_msL.setText(tr("Render Width"))
|
||||
self.amplificationL.setText(tr("Amplification"))
|
||||
self.begin_timeL.setText(tr("Begin Time"))
|
||||
self.optionAppearance.setTitle(tr("Appearance"))
|
||||
self.render_resolutionL.setText(tr("Resolution"))
|
||||
self.render_resolution.setText(tr("vs"))
|
||||
self.render__bg_colorL.setText(tr("Background"))
|
||||
|
|
@ -441,7 +469,6 @@ class MainWindow(QWidget):
|
|||
self.render__midline_colorL.setText(tr("Midline Color"))
|
||||
self.render__v_midline.setText(tr("Vertical"))
|
||||
self.render__h_midline.setText(tr("Horizontal Midline"))
|
||||
self.optionLayout.setTitle(tr("Layout"))
|
||||
self.layout__orientationL.setText(tr("Orientation"))
|
||||
self.layout__nrowsL.setText(tr("Rows"))
|
||||
|
||||
|
|
@ -484,6 +511,7 @@ from corrscope.gui.model_bind import (
|
|||
TypeComboBox,
|
||||
BoundColorWidget,
|
||||
OptionalColorWidget,
|
||||
BoundFontButton,
|
||||
)
|
||||
|
||||
# Delete unbound widgets, so they cannot accidentally be used.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import enum
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, List, TYPE_CHECKING, Any, Callable
|
||||
from typing import Optional, List, TYPE_CHECKING, Any, Callable, TypeVar
|
||||
|
||||
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
|
||||
from corrscope.config import DumpableAttrs, with_units, TypedEnumDump
|
||||
from corrscope.layout import (
|
||||
RendererLayout,
|
||||
LayoutConfig,
|
||||
|
|
@ -30,7 +31,7 @@ and font cache entries point to invalid paths.
|
|||
- https://github.com/pyinstaller/pyinstaller/issues/617
|
||||
- https://github.com/pyinstaller/pyinstaller/blob/c06d853c0c4df7480d3fa921851354d4ee11de56/PyInstaller/loader/rthooks/pyi_rth_mplconfig.py#L35-L37
|
||||
|
||||
corrscope uses one-folder mode, does not use fonts yet,
|
||||
corrscope uses one-folder mode
|
||||
and deletes all matplotlib-bundled fonts to save space. So reenable global font cache.
|
||||
"""
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ if TYPE_CHECKING:
|
|||
from matplotlib.artist import Artist
|
||||
from matplotlib.axes import Axes
|
||||
from matplotlib.lines import Line2D
|
||||
from matplotlib.text import Text, Annotation
|
||||
from corrscope.channel import ChannelConfig
|
||||
|
||||
|
||||
|
|
@ -59,6 +61,55 @@ def default_color() -> str:
|
|||
return "#ffffff"
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class LabelX(enum.Enum):
|
||||
Left = enum.auto()
|
||||
Right = enum.auto()
|
||||
|
||||
def match(self, *, left: T, right: T) -> T:
|
||||
if self is self.Left:
|
||||
return left
|
||||
if self is self.Right:
|
||||
return right
|
||||
raise ValueError("failed match")
|
||||
|
||||
|
||||
class LabelY(enum.Enum):
|
||||
Bottom = enum.auto()
|
||||
Top = enum.auto()
|
||||
|
||||
def match(self, *, bottom: T, top: T) -> T:
|
||||
if self is self.Bottom:
|
||||
return bottom
|
||||
if self is self.Top:
|
||||
return top
|
||||
raise ValueError("failed match")
|
||||
|
||||
|
||||
class LabelPosition(TypedEnumDump):
|
||||
def __init__(self, x: LabelX, y: LabelY):
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
LeftBottom = (LabelX.Left, LabelY.Bottom)
|
||||
LeftTop = (LabelX.Left, LabelY.Top)
|
||||
RightBottom = (LabelX.Right, LabelY.Bottom)
|
||||
RightTop = (LabelX.Right, LabelY.Top)
|
||||
|
||||
|
||||
class Font(DumpableAttrs, always_dump="*"):
|
||||
# Font file selection
|
||||
family: Optional[str] = None
|
||||
bold: bool = False
|
||||
italic: bool = False
|
||||
# Font size
|
||||
size: float = with_units("pt", default=20)
|
||||
# QFont implementation details
|
||||
toString: str = None
|
||||
|
||||
|
||||
class RendererConfig(DumpableAttrs, always_dump="*"):
|
||||
width: int
|
||||
height: int
|
||||
|
|
@ -74,6 +125,18 @@ class RendererConfig(DumpableAttrs, always_dump="*"):
|
|||
v_midline: bool = False
|
||||
h_midline: bool = False
|
||||
|
||||
# Label settings
|
||||
label_font: Font = attr.ib(factory=Font)
|
||||
|
||||
label_position: LabelPosition = LabelPosition.LeftTop
|
||||
# The text will be located (label_padding_ratio * label_font.size) from the corner.
|
||||
label_padding_ratio: float = with_units("px/pt", default=0.5)
|
||||
label_color_override: Optional[str] = None
|
||||
|
||||
@property
|
||||
def get_label_color(self):
|
||||
return coalesce(self.label_color_override, self.init_line_color)
|
||||
|
||||
antialiasing: bool = True
|
||||
|
||||
# Performance (skipped when recording to video)
|
||||
|
|
@ -113,6 +176,10 @@ class Renderer(ABC):
|
|||
):
|
||||
self.cfg = cfg
|
||||
self.lcfg = lcfg
|
||||
|
||||
self.w = cfg.width
|
||||
self.h = cfg.height
|
||||
|
||||
self.nplots = len(dummy_datas)
|
||||
|
||||
assert len(dummy_datas[0].shape) == 2, dummy_datas[0].shape
|
||||
|
|
@ -150,6 +217,10 @@ class Renderer(ABC):
|
|||
def get_frame(self) -> ByteBuffer:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def add_labels(self, labels: List[str]) -> Any:
|
||||
...
|
||||
|
||||
|
||||
Point = float
|
||||
px_inch = 96
|
||||
|
|
@ -400,6 +471,67 @@ class MatplotlibRenderer(Renderer):
|
|||
chan_line = wave_lines[chan_idx]
|
||||
chan_line.set_ydata(chan_data)
|
||||
|
||||
# Channel labels
|
||||
def add_labels(self, labels: List[str]) -> List["Text"]:
|
||||
"""
|
||||
Updates background, adds text.
|
||||
Do NOT call after calling self.add_lines().
|
||||
"""
|
||||
nlabel = len(labels)
|
||||
if nlabel != self.nplots:
|
||||
raise ValueError(
|
||||
f"incorrect labels: {self.nplots} plots but {nlabel} labels"
|
||||
)
|
||||
|
||||
cfg = self.cfg
|
||||
color = cfg.get_label_color
|
||||
|
||||
size_pt = cfg.label_font.size
|
||||
distance_px = cfg.label_padding_ratio * size_pt
|
||||
|
||||
@attr.dataclass
|
||||
class AxisPosition:
|
||||
pos_axes: float
|
||||
offset_px: float
|
||||
align: str
|
||||
|
||||
xpos = cfg.label_position.x.match(
|
||||
left=AxisPosition(0, distance_px, "left"),
|
||||
right=AxisPosition(1, -distance_px, "right"),
|
||||
)
|
||||
ypos = cfg.label_position.y.match(
|
||||
bottom=AxisPosition(0, distance_px, "bottom"),
|
||||
top=AxisPosition(1, -distance_px, "top"),
|
||||
)
|
||||
|
||||
pos_axes = (xpos.pos_axes, ypos.pos_axes)
|
||||
offset_px = (xpos.offset_px, ypos.offset_px)
|
||||
|
||||
out: List["Text"] = []
|
||||
for label_text, ax in zip(labels, self._axes_mono):
|
||||
# https://matplotlib.org/api/_as_gen/matplotlib.axes.Axes.annotate.html
|
||||
# Annotation subclasses Text.
|
||||
text: "Annotation" = ax.annotate(
|
||||
label_text,
|
||||
# Positioning
|
||||
xy=pos_axes,
|
||||
xycoords="axes fraction",
|
||||
xytext=offset_px,
|
||||
textcoords="offset pixels",
|
||||
horizontalalignment=xpos.align,
|
||||
verticalalignment=ypos.align,
|
||||
# Cosmetics
|
||||
color=color,
|
||||
fontsize=size_pt,
|
||||
fontfamily=cfg.label_font.family,
|
||||
fontweight=("bold" if cfg.label_font.bold else "normal"),
|
||||
fontstyle=("italic" if cfg.label_font.italic else "normal"),
|
||||
)
|
||||
out.append(text)
|
||||
|
||||
self._save_background()
|
||||
return out
|
||||
|
||||
# Output frames
|
||||
def get_frame(self) -> ByteBuffer:
|
||||
""" Returns ndarray of shape w,h,3. """
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from pytest_mock import MockFixture
|
|||
|
||||
import corrscope.channel
|
||||
import corrscope.corrscope
|
||||
from corrscope.channel import ChannelConfig, Channel
|
||||
from corrscope.channel import ChannelConfig, Channel, DefaultLabel
|
||||
from corrscope.corrscope import default_config, CorrScope, BenchmarkMode, Arguments
|
||||
from corrscope.triggers import NullTriggerConfig
|
||||
from corrscope.util import coalesce
|
||||
|
|
@ -18,6 +18,8 @@ from corrscope.wave import Flatten
|
|||
positive = hs.integers(min_value=1, max_value=100)
|
||||
real = hs.floats(min_value=0, max_value=100)
|
||||
maybe_real = hs.one_of(hs.none(), real)
|
||||
bools = hs.booleans()
|
||||
default_labels = hs.sampled_from(DefaultLabel)
|
||||
|
||||
|
||||
@given(
|
||||
|
|
@ -31,8 +33,10 @@ maybe_real = hs.one_of(hs.none(), real)
|
|||
render_ms=positive,
|
||||
tsub=positive,
|
||||
rsub=positive,
|
||||
default_label=hs.sampled_from(DefaultLabel),
|
||||
override_label=bools,
|
||||
)
|
||||
def test_config_channel_width_stride(
|
||||
def test_config_channel_integration(
|
||||
# Channel
|
||||
c_amplification: Optional[float],
|
||||
c_trigger_width: int,
|
||||
|
|
@ -43,6 +47,8 @@ def test_config_channel_width_stride(
|
|||
render_ms: int,
|
||||
tsub: int,
|
||||
rsub: int,
|
||||
default_label: DefaultLabel,
|
||||
override_label: bool,
|
||||
mocker: MockFixture,
|
||||
):
|
||||
""" (Tautologically) verify:
|
||||
|
|
@ -50,6 +56,7 @@ def test_config_channel_width_stride(
|
|||
- channel.t/r_stride (given cfg.*_subsampling/*_width)
|
||||
- trigger._tsamp, _stride
|
||||
- renderer's method calls(samp, stride)
|
||||
- rendered label (channel.label, given cfg, corr_cfg.default_label)
|
||||
"""
|
||||
|
||||
# region setup test variables
|
||||
|
|
@ -71,6 +78,7 @@ def test_config_channel_width_stride(
|
|||
trigger_width=c_trigger_width,
|
||||
render_width=c_render_width,
|
||||
amplification=c_amplification,
|
||||
label="label" if override_label else "",
|
||||
)
|
||||
|
||||
def get_cfg():
|
||||
|
|
@ -81,6 +89,7 @@ def test_config_channel_width_stride(
|
|||
render_subsampling=rsub,
|
||||
amplification=amplification,
|
||||
channels=[ccfg],
|
||||
default_label=default_label,
|
||||
trigger=NullTriggerConfig(),
|
||||
benchmark_mode=BenchmarkMode.OUTPUT,
|
||||
)
|
||||
|
|
@ -133,6 +142,19 @@ def test_config_channel_width_stride(
|
|||
render_data = datas[0]
|
||||
assert len(render_data) == channel._render_samp
|
||||
|
||||
# Inspect arguments to renderer.add_labels().
|
||||
(labels,), kwargs = renderer.add_labels.call_args
|
||||
label = labels[0]
|
||||
if override_label:
|
||||
assert label == "label"
|
||||
else:
|
||||
if default_label is DefaultLabel.FileName:
|
||||
assert label == "sine440"
|
||||
elif default_label is DefaultLabel.Number:
|
||||
assert label == "1"
|
||||
else:
|
||||
assert label == ""
|
||||
|
||||
|
||||
# line_color is tested in test_renderer.py
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ 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
|
||||
from corrscope.renderer import RendererConfig, MatplotlibRenderer, LabelPosition, Font
|
||||
from corrscope.wave import Flatten
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest_mock
|
||||
|
||||
parametrize = pytest.mark.parametrize
|
||||
|
||||
|
||||
WIDTH = 64
|
||||
HEIGHT = 64
|
||||
|
||||
|
|
@ -150,6 +153,68 @@ def to_rgb(c) -> np.ndarray:
|
|||
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])
|
||||
@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 = to_rgb(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 = MatplotlibRenderer(cfg, lcfg, datas, None)
|
||||
r.add_labels(labels)
|
||||
if not hide_lines:
|
||||
r.update_main_lines(datas)
|
||||
|
||||
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."""
|
||||
|
|
|
|||
Ładowanie…
Reference in New Issue