Add support for per-channel labels (#256)

Known issue: matplotlib's font weight handling is often wrong.
pull/357/head
nyanpasu64 2019-04-08 03:40:31 -07:00
rodzic ba4326fc4a
commit 8300ec3682
9 zmienionych plików z 388 dodań i 22 usunięć

Wyświetl plik

@ -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),

Wyświetl plik

@ -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()

Wyświetl plik

@ -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"),

Wyświetl plik

@ -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

Wyświetl plik

@ -1,2 +1,3 @@
SOURCES += view_mainwindow.py
SOURCES += __init__.py
TRANSLATIONS += corrscope_xa.ts

Wyświetl plik

@ -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.

Wyświetl plik

@ -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. """

Wyświetl plik

@ -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

Wyświetl plik

@ -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."""