diff --git a/corrscope/gui/view_mainwindow.py b/corrscope/gui/view_mainwindow.py index b5fae08..fdaa51e 100644 --- a/corrscope/gui/view_mainwindow.py +++ b/corrscope/gui/view_mainwindow.py @@ -169,6 +169,16 @@ class MainWindow(QWidget): ): pass + with add_row( + s, + tr("Grid Line Width"), + BoundDoubleSpinBox, + name="render.grid_line_width", + minimum=0.5, + singleStep=0.5, + ): + pass + with append_widget(s, QGroupBox, title=tr("Labels"), layout=QFormLayout): with add_row( s, tr("Font"), BoundFontButton, name="render__label_qfont" diff --git a/corrscope/renderer.py b/corrscope/renderer.py index 771a8a4..2aa0805 100644 --- a/corrscope/renderer.py +++ b/corrscope/renderer.py @@ -47,6 +47,7 @@ if TYPE_CHECKING: from matplotlib.artist import Artist from matplotlib.axes import Axes from matplotlib.lines import Line2D + from matplotlib.spines import Spine from matplotlib.text import Text, Annotation from corrscope.channel import ChannelConfig @@ -114,6 +115,7 @@ class RendererConfig(DumpableAttrs, always_dump="*"): width: int height: int line_width: float = with_units("px", default=1.5) + grid_line_width: float = with_units("px", default=1.0) @property def divided_width(self): @@ -230,12 +232,19 @@ class Renderer(ABC): Point = float -PX_INCH = 96 -POINT_INCH = 72 +Pixel = float + +# Matplotlib multiplies all widths by (inch/72 units) (AKA "matplotlib points"). +# To simplify code, render output at (72 px/inch), so 1 unit = 1 px. +# For font sizes, convert from font-pt to pixels. +# (Font sizes are used far less than pixel measurements.) + +PX_INCH = 72 +PIXELS_PER_PT = 96 / 72 -def pixels(px: float) -> Point: - return px / PX_INCH * POINT_INCH +def px_from_points(pt: Point) -> Pixel: + return pt * PIXELS_PER_PT class MatplotlibRenderer(Renderer): @@ -360,7 +369,7 @@ class MatplotlibRenderer(Renderer): # Setup midlines (depends on max_x and wave_data) midline_color = cfg.midline_color - midline_width = pixels(1) + midline_width = cfg.grid_line_width # Not quite sure if midlines or gridlines draw on top kw = dict(color=midline_color, linewidth=midline_width) @@ -377,7 +386,7 @@ class MatplotlibRenderer(Renderer): # satisfies RegionFactory def _axes_factory(self, r: RegionSpec, label: str = "") -> "Axes": - grid_color = self.cfg.grid_color + cfg = self.cfg width = 1 / r.ncol left = r.col / r.ncol @@ -392,6 +401,7 @@ class MatplotlibRenderer(Renderer): [left, bottom, width, height], xticks=[], yticks=[], label=label ) + grid_color = cfg.grid_color if grid_color: # Initialize borders # Hide Axises @@ -406,7 +416,8 @@ class MatplotlibRenderer(Renderer): ax.set_facecolor(self.transparent) # Set border colors - for spine in ax.spines.values(): + for spine in ax.spines.values(): # type: Spine + spine.set_linewidth(cfg.grid_line_width) spine.set_color(grid_color) def hide(key: str): @@ -423,9 +434,9 @@ class MatplotlibRenderer(Renderer): hide("right") # Dim stereo gridlines - if self.cfg.stereo_grid_opacity > 0: + if cfg.stereo_grid_opacity > 0: dim_color = matplotlib.colors.to_rgba_array(grid_color)[0] - dim_color[-1] = self.cfg.stereo_grid_opacity + dim_color[-1] = cfg.stereo_grid_opacity def dim(key: str): ax.spines[key].set_color(dim_color) @@ -449,7 +460,7 @@ class MatplotlibRenderer(Renderer): cfg = self.cfg # Plot lines over background - line_width = pixels(cfg.line_width) + line_width = cfg.line_width # Foreach wave, plot dummy data. lines2d = [] @@ -531,7 +542,7 @@ class MatplotlibRenderer(Renderer): ) pos_axes = (xpos.pos_axes, ypos.pos_axes) - offset_pt = (pixels(xpos.offset_px), pixels(ypos.offset_px)) + offset_pt = (xpos.offset_px, ypos.offset_px) out: List["Text"] = [] for label_text, ax in zip(labels, self._axes_mono): @@ -548,7 +559,7 @@ class MatplotlibRenderer(Renderer): verticalalignment=ypos.align, # Cosmetics color=color, - fontsize=size_pt, + fontsize=px_from_points(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"), diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 618887a..186733b 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -1,5 +1,6 @@ from typing import Optional, TYPE_CHECKING, List +import attr import hypothesis.strategies as hs import matplotlib.colors import numpy as np @@ -23,87 +24,115 @@ parametrize = pytest.mark.parametrize WIDTH = 64 HEIGHT = 64 -RENDER_Y_ZEROS = np.zeros((2, 1)) -RENDER_Y_STEREO = np.zeros((2, 2)) +RENDER_Y_ZEROS = np.full((2, 1), 0.5) +RENDER_Y_STEREO = np.full((2, 2), 0.5) OPACITY = 2 / 3 + +def behead(string: str, header: str) -> str: + if not string.startswith(header): + raise ValueError(f"{string} does not start with {header}") + return string[len(header) :] + + +def appearance_to_str(val): + """Called once for each `appear` and `data`.""" + if isinstance(val, Appearance): + # Remove class name. + return behead(str(val), Appearance.__name__) + if isinstance(val, np.ndarray): + return "stereo" if val.shape[1] > 1 else "mono" + return None + + +@attr.dataclass +class Appearance: + bg_str: str + fg_str: str + grid_str: Optional[str] + grid_line_width: float + + all_colors = pytest.mark.parametrize( - "bg_str,fg_str,grid_str,data", + "appear, data", [ - ("#000000", "#ffffff", None, RENDER_Y_ZEROS), - ("#ffffff", "#000000", None, RENDER_Y_ZEROS), - ("#0000aa", "#aaaa00", None, RENDER_Y_ZEROS), - ("#aaaa00", "#0000aa", None, RENDER_Y_ZEROS), - # Enabling ~~beautiful magenta~~ gridlines enables Axes rectangles. - # Make sure bg is disabled, so they don't overwrite global figure background. - ("#0000aa", "#aaaa00", "#ff00ff", RENDER_Y_ZEROS), - ("#aaaa00", "#0000aa", "#ff00ff", RENDER_Y_ZEROS), - ("#0000aa", "#aaaa00", "#ff00ff", RENDER_Y_STEREO), - ("#aaaa00", "#0000aa", "#ff00ff", RENDER_Y_STEREO), + (Appearance("#000000", "#ffffff", None, 1), RENDER_Y_ZEROS), + (Appearance("#ffffff", "#000000", None, 2), RENDER_Y_ZEROS), + (Appearance("#0000aa", "#aaaa00", None, 1), RENDER_Y_ZEROS), + (Appearance("#aaaa00", "#0000aa", None, 2), RENDER_Y_ZEROS), + (Appearance("#0000aa", "#aaaa00", "#ff00ff", 1), RENDER_Y_ZEROS), + (Appearance("#aaaa00", "#0000aa", "#ff00ff", 1), RENDER_Y_ZEROS), + (Appearance("#aaaa00", "#0000aa", "#ff00ff", 0), RENDER_Y_ZEROS), + (Appearance("#0000aa", "#aaaa00", "#ff00ff", 1), RENDER_Y_STEREO), + (Appearance("#aaaa00", "#0000aa", "#ff00ff", 1), RENDER_Y_STEREO), ], + ids=appearance_to_str, ) -NPLOTS = 2 - -@all_colors -def test_default_colors(bg_str, fg_str, grid_str, data): - """ Test the default background/foreground colors. """ +def get_renderer_config(appear: Appearance) -> RendererConfig: cfg = RendererConfig( WIDTH, HEIGHT, - bg_color=bg_str, - init_line_color=fg_str, - grid_color=grid_str, + bg_color=appear.bg_str, + init_line_color=appear.fg_str, + grid_color=appear.grid_str, + grid_line_width=appear.grid_line_width, stereo_grid_opacity=OPACITY, line_width=2.0, antialiasing=False, ) - lcfg = LayoutConfig() + return cfg + + +NPLOTS = 2 +ORIENTATION = "h" +GRID_NPIXEL = WIDTH + + +@all_colors +def test_default_colors(appear: Appearance, data): + """ Test the default background/foreground colors. """ + cfg = get_renderer_config(appear) + lcfg = LayoutConfig(orientation=ORIENTATION) datas = [data] * NPLOTS r = MatplotlibRenderer(cfg, lcfg, datas, None) - verify(r, bg_str, fg_str, grid_str, datas) + verify(r, appear, datas) # Ensure default ChannelConfig(line_color=None) does not override line color chan = ChannelConfig(wav_path="") channels = [chan] * NPLOTS r = MatplotlibRenderer(cfg, lcfg, datas, channels) - verify(r, bg_str, fg_str, grid_str, datas) + verify(r, appear, datas) @all_colors -def test_line_colors(bg_str, fg_str, grid_str, data): +def test_line_colors(appear: Appearance, data): """ Test channel-specific line color overrides """ - cfg = RendererConfig( - WIDTH, - HEIGHT, - bg_color=bg_str, - init_line_color="#888888", - grid_color=grid_str, - stereo_grid_opacity=OPACITY, - line_width=2.0, - antialiasing=False, - ) - lcfg = LayoutConfig() + cfg = get_renderer_config(appear) + lcfg = LayoutConfig(orientation=ORIENTATION) datas = [data] * NPLOTS - chan = ChannelConfig(wav_path="", line_color=fg_str) + # Move line color (appear.fg_str) from renderer cfg to individual channel. + chan = ChannelConfig(wav_path="", line_color=appear.fg_str) channels = [chan] * NPLOTS + cfg.init_line_color = "#888888" + chan.line_color = appear.fg_str + r = MatplotlibRenderer(cfg, lcfg, datas, channels) - verify(r, bg_str, fg_str, grid_str, datas) + verify(r, appear, datas) TOLERANCE = 3 -def verify( - r: MatplotlibRenderer, - bg_str, - fg_str, - grid_str: Optional[str], - datas: List[np.ndarray], -): +def verify(r: MatplotlibRenderer, appear: Appearance, datas: List[np.ndarray]): + bg_str = appear.bg_str + fg_str = appear.fg_str + grid_str = appear.grid_str + grid_line_width = appear.grid_line_width + r.update_main_lines(datas) frame_colors: np.ndarray = np.frombuffer(r.get_frame(), dtype=np.uint8).reshape( (-1, BYTES_PER_PIXEL) @@ -113,15 +142,17 @@ def verify( fg_u8 = to_rgb(fg_str) all_colors = [bg_u8, fg_u8] - if grid_str: + is_grid = bool(grid_str and grid_line_width >= 1) + + if is_grid: grid_u8 = to_rgb(grid_str) all_colors.append(grid_u8) else: - grid_u8 = bg_u8 + grid_u8 = np.array([1000] * BYTES_PER_PIXEL) data = datas[0] assert (data.shape[1] > 1) == (data is RENDER_Y_STEREO) - is_stereo = data.shape[1] > 1 + is_stereo = is_grid and data.shape[1] > 1 if is_stereo: stereo_grid_u8 = (grid_u8 * OPACITY + bg_u8 * (1 - OPACITY)).astype(int) all_colors.append(stereo_grid_u8) @@ -138,8 +169,14 @@ def verify( ).any(), "incorrect foreground, it might be 136 = #888888" # Ensure grid color is present - if grid_str: - assert np.prod(frame_colors == grid_u8, axis=-1).any(), "Missing grid_str" + does_grid_appear_here = np.prod(frame_colors == grid_u8, axis=-1) + does_grid_appear = does_grid_appear_here.any() + assert does_grid_appear == is_grid, f"{does_grid_appear} != {is_grid}" + + if is_grid: + assert np.sum(does_grid_appear_here) == pytest.approx( + GRID_NPIXEL * grid_line_width, abs=GRID_NPIXEL * 0.1 + ) # Ensure stereo grid color is present if is_stereo: