kopia lustrzana https://github.com/corrscope/corrscope
Add configurable grid line width (#265)
commit
452bf29d5e
|
@ -169,6 +169,16 @@ class MainWindow(QWidget):
|
||||||
):
|
):
|
||||||
pass
|
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 append_widget(s, QGroupBox, title=tr("Labels"), layout=QFormLayout):
|
||||||
with add_row(
|
with add_row(
|
||||||
s, tr("Font"), BoundFontButton, name="render__label_qfont"
|
s, tr("Font"), BoundFontButton, name="render__label_qfont"
|
||||||
|
|
|
@ -47,6 +47,7 @@ if TYPE_CHECKING:
|
||||||
from matplotlib.artist import Artist
|
from matplotlib.artist import Artist
|
||||||
from matplotlib.axes import Axes
|
from matplotlib.axes import Axes
|
||||||
from matplotlib.lines import Line2D
|
from matplotlib.lines import Line2D
|
||||||
|
from matplotlib.spines import Spine
|
||||||
from matplotlib.text import Text, Annotation
|
from matplotlib.text import Text, Annotation
|
||||||
from corrscope.channel import ChannelConfig
|
from corrscope.channel import ChannelConfig
|
||||||
|
|
||||||
|
@ -114,6 +115,7 @@ class RendererConfig(DumpableAttrs, always_dump="*"):
|
||||||
width: int
|
width: int
|
||||||
height: int
|
height: int
|
||||||
line_width: float = with_units("px", default=1.5)
|
line_width: float = with_units("px", default=1.5)
|
||||||
|
grid_line_width: float = with_units("px", default=1.0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def divided_width(self):
|
def divided_width(self):
|
||||||
|
@ -230,12 +232,19 @@ class Renderer(ABC):
|
||||||
|
|
||||||
|
|
||||||
Point = float
|
Point = float
|
||||||
PX_INCH = 96
|
Pixel = float
|
||||||
POINT_INCH = 72
|
|
||||||
|
# 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:
|
def px_from_points(pt: Point) -> Pixel:
|
||||||
return px / PX_INCH * POINT_INCH
|
return pt * PIXELS_PER_PT
|
||||||
|
|
||||||
|
|
||||||
class MatplotlibRenderer(Renderer):
|
class MatplotlibRenderer(Renderer):
|
||||||
|
@ -360,7 +369,7 @@ class MatplotlibRenderer(Renderer):
|
||||||
|
|
||||||
# Setup midlines (depends on max_x and wave_data)
|
# Setup midlines (depends on max_x and wave_data)
|
||||||
midline_color = cfg.midline_color
|
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
|
# Not quite sure if midlines or gridlines draw on top
|
||||||
kw = dict(color=midline_color, linewidth=midline_width)
|
kw = dict(color=midline_color, linewidth=midline_width)
|
||||||
|
@ -377,7 +386,7 @@ class MatplotlibRenderer(Renderer):
|
||||||
|
|
||||||
# satisfies RegionFactory
|
# satisfies RegionFactory
|
||||||
def _axes_factory(self, r: RegionSpec, label: str = "") -> "Axes":
|
def _axes_factory(self, r: RegionSpec, label: str = "") -> "Axes":
|
||||||
grid_color = self.cfg.grid_color
|
cfg = self.cfg
|
||||||
|
|
||||||
width = 1 / r.ncol
|
width = 1 / r.ncol
|
||||||
left = r.col / r.ncol
|
left = r.col / r.ncol
|
||||||
|
@ -392,6 +401,7 @@ class MatplotlibRenderer(Renderer):
|
||||||
[left, bottom, width, height], xticks=[], yticks=[], label=label
|
[left, bottom, width, height], xticks=[], yticks=[], label=label
|
||||||
)
|
)
|
||||||
|
|
||||||
|
grid_color = cfg.grid_color
|
||||||
if grid_color:
|
if grid_color:
|
||||||
# Initialize borders
|
# Initialize borders
|
||||||
# Hide Axises
|
# Hide Axises
|
||||||
|
@ -406,7 +416,8 @@ class MatplotlibRenderer(Renderer):
|
||||||
ax.set_facecolor(self.transparent)
|
ax.set_facecolor(self.transparent)
|
||||||
|
|
||||||
# Set border colors
|
# 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)
|
spine.set_color(grid_color)
|
||||||
|
|
||||||
def hide(key: str):
|
def hide(key: str):
|
||||||
|
@ -423,9 +434,9 @@ class MatplotlibRenderer(Renderer):
|
||||||
hide("right")
|
hide("right")
|
||||||
|
|
||||||
# Dim stereo gridlines
|
# 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 = 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):
|
def dim(key: str):
|
||||||
ax.spines[key].set_color(dim_color)
|
ax.spines[key].set_color(dim_color)
|
||||||
|
@ -449,7 +460,7 @@ class MatplotlibRenderer(Renderer):
|
||||||
cfg = self.cfg
|
cfg = self.cfg
|
||||||
|
|
||||||
# Plot lines over background
|
# Plot lines over background
|
||||||
line_width = pixels(cfg.line_width)
|
line_width = cfg.line_width
|
||||||
|
|
||||||
# Foreach wave, plot dummy data.
|
# Foreach wave, plot dummy data.
|
||||||
lines2d = []
|
lines2d = []
|
||||||
|
@ -531,7 +542,7 @@ class MatplotlibRenderer(Renderer):
|
||||||
)
|
)
|
||||||
|
|
||||||
pos_axes = (xpos.pos_axes, ypos.pos_axes)
|
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"] = []
|
out: List["Text"] = []
|
||||||
for label_text, ax in zip(labels, self._axes_mono):
|
for label_text, ax in zip(labels, self._axes_mono):
|
||||||
|
@ -548,7 +559,7 @@ class MatplotlibRenderer(Renderer):
|
||||||
verticalalignment=ypos.align,
|
verticalalignment=ypos.align,
|
||||||
# Cosmetics
|
# Cosmetics
|
||||||
color=color,
|
color=color,
|
||||||
fontsize=size_pt,
|
fontsize=px_from_points(size_pt),
|
||||||
fontfamily=cfg.label_font.family,
|
fontfamily=cfg.label_font.family,
|
||||||
fontweight=("bold" if cfg.label_font.bold else "normal"),
|
fontweight=("bold" if cfg.label_font.bold else "normal"),
|
||||||
fontstyle=("italic" if cfg.label_font.italic else "normal"),
|
fontstyle=("italic" if cfg.label_font.italic else "normal"),
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from typing import Optional, TYPE_CHECKING, List
|
from typing import Optional, TYPE_CHECKING, List
|
||||||
|
|
||||||
|
import attr
|
||||||
import hypothesis.strategies as hs
|
import hypothesis.strategies as hs
|
||||||
import matplotlib.colors
|
import matplotlib.colors
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
@ -23,87 +24,115 @@ parametrize = pytest.mark.parametrize
|
||||||
WIDTH = 64
|
WIDTH = 64
|
||||||
HEIGHT = 64
|
HEIGHT = 64
|
||||||
|
|
||||||
RENDER_Y_ZEROS = np.zeros((2, 1))
|
RENDER_Y_ZEROS = np.full((2, 1), 0.5)
|
||||||
RENDER_Y_STEREO = np.zeros((2, 2))
|
RENDER_Y_STEREO = np.full((2, 2), 0.5)
|
||||||
OPACITY = 2 / 3
|
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(
|
all_colors = pytest.mark.parametrize(
|
||||||
"bg_str,fg_str,grid_str,data",
|
"appear, data",
|
||||||
[
|
[
|
||||||
("#000000", "#ffffff", None, RENDER_Y_ZEROS),
|
(Appearance("#000000", "#ffffff", None, 1), RENDER_Y_ZEROS),
|
||||||
("#ffffff", "#000000", None, RENDER_Y_ZEROS),
|
(Appearance("#ffffff", "#000000", None, 2), RENDER_Y_ZEROS),
|
||||||
("#0000aa", "#aaaa00", None, RENDER_Y_ZEROS),
|
(Appearance("#0000aa", "#aaaa00", None, 1), RENDER_Y_ZEROS),
|
||||||
("#aaaa00", "#0000aa", None, RENDER_Y_ZEROS),
|
(Appearance("#aaaa00", "#0000aa", None, 2), RENDER_Y_ZEROS),
|
||||||
# Enabling ~~beautiful magenta~~ gridlines enables Axes rectangles.
|
(Appearance("#0000aa", "#aaaa00", "#ff00ff", 1), RENDER_Y_ZEROS),
|
||||||
# Make sure bg is disabled, so they don't overwrite global figure background.
|
(Appearance("#aaaa00", "#0000aa", "#ff00ff", 1), RENDER_Y_ZEROS),
|
||||||
("#0000aa", "#aaaa00", "#ff00ff", RENDER_Y_ZEROS),
|
(Appearance("#aaaa00", "#0000aa", "#ff00ff", 0), RENDER_Y_ZEROS),
|
||||||
("#aaaa00", "#0000aa", "#ff00ff", RENDER_Y_ZEROS),
|
(Appearance("#0000aa", "#aaaa00", "#ff00ff", 1), RENDER_Y_STEREO),
|
||||||
("#0000aa", "#aaaa00", "#ff00ff", RENDER_Y_STEREO),
|
(Appearance("#aaaa00", "#0000aa", "#ff00ff", 1), RENDER_Y_STEREO),
|
||||||
("#aaaa00", "#0000aa", "#ff00ff", RENDER_Y_STEREO),
|
|
||||||
],
|
],
|
||||||
|
ids=appearance_to_str,
|
||||||
)
|
)
|
||||||
|
|
||||||
NPLOTS = 2
|
|
||||||
|
|
||||||
|
def get_renderer_config(appear: Appearance) -> RendererConfig:
|
||||||
@all_colors
|
|
||||||
def test_default_colors(bg_str, fg_str, grid_str, data):
|
|
||||||
""" Test the default background/foreground colors. """
|
|
||||||
cfg = RendererConfig(
|
cfg = RendererConfig(
|
||||||
WIDTH,
|
WIDTH,
|
||||||
HEIGHT,
|
HEIGHT,
|
||||||
bg_color=bg_str,
|
bg_color=appear.bg_str,
|
||||||
init_line_color=fg_str,
|
init_line_color=appear.fg_str,
|
||||||
grid_color=grid_str,
|
grid_color=appear.grid_str,
|
||||||
|
grid_line_width=appear.grid_line_width,
|
||||||
stereo_grid_opacity=OPACITY,
|
stereo_grid_opacity=OPACITY,
|
||||||
line_width=2.0,
|
line_width=2.0,
|
||||||
antialiasing=False,
|
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
|
datas = [data] * NPLOTS
|
||||||
|
|
||||||
r = MatplotlibRenderer(cfg, lcfg, datas, None)
|
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
|
# Ensure default ChannelConfig(line_color=None) does not override line color
|
||||||
chan = ChannelConfig(wav_path="")
|
chan = ChannelConfig(wav_path="")
|
||||||
channels = [chan] * NPLOTS
|
channels = [chan] * NPLOTS
|
||||||
r = MatplotlibRenderer(cfg, lcfg, datas, channels)
|
r = MatplotlibRenderer(cfg, lcfg, datas, channels)
|
||||||
verify(r, bg_str, fg_str, grid_str, datas)
|
verify(r, appear, datas)
|
||||||
|
|
||||||
|
|
||||||
@all_colors
|
@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 """
|
""" Test channel-specific line color overrides """
|
||||||
cfg = RendererConfig(
|
cfg = get_renderer_config(appear)
|
||||||
WIDTH,
|
lcfg = LayoutConfig(orientation=ORIENTATION)
|
||||||
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()
|
|
||||||
datas = [data] * NPLOTS
|
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
|
channels = [chan] * NPLOTS
|
||||||
|
cfg.init_line_color = "#888888"
|
||||||
|
chan.line_color = appear.fg_str
|
||||||
|
|
||||||
r = MatplotlibRenderer(cfg, lcfg, datas, channels)
|
r = MatplotlibRenderer(cfg, lcfg, datas, channels)
|
||||||
verify(r, bg_str, fg_str, grid_str, datas)
|
verify(r, appear, datas)
|
||||||
|
|
||||||
|
|
||||||
TOLERANCE = 3
|
TOLERANCE = 3
|
||||||
|
|
||||||
|
|
||||||
def verify(
|
def verify(r: MatplotlibRenderer, appear: Appearance, datas: List[np.ndarray]):
|
||||||
r: MatplotlibRenderer,
|
bg_str = appear.bg_str
|
||||||
bg_str,
|
fg_str = appear.fg_str
|
||||||
fg_str,
|
grid_str = appear.grid_str
|
||||||
grid_str: Optional[str],
|
grid_line_width = appear.grid_line_width
|
||||||
datas: List[np.ndarray],
|
|
||||||
):
|
|
||||||
r.update_main_lines(datas)
|
r.update_main_lines(datas)
|
||||||
frame_colors: np.ndarray = np.frombuffer(r.get_frame(), dtype=np.uint8).reshape(
|
frame_colors: np.ndarray = np.frombuffer(r.get_frame(), dtype=np.uint8).reshape(
|
||||||
(-1, BYTES_PER_PIXEL)
|
(-1, BYTES_PER_PIXEL)
|
||||||
|
@ -113,15 +142,17 @@ def verify(
|
||||||
fg_u8 = to_rgb(fg_str)
|
fg_u8 = to_rgb(fg_str)
|
||||||
all_colors = [bg_u8, fg_u8]
|
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)
|
grid_u8 = to_rgb(grid_str)
|
||||||
all_colors.append(grid_u8)
|
all_colors.append(grid_u8)
|
||||||
else:
|
else:
|
||||||
grid_u8 = bg_u8
|
grid_u8 = np.array([1000] * BYTES_PER_PIXEL)
|
||||||
|
|
||||||
data = datas[0]
|
data = datas[0]
|
||||||
assert (data.shape[1] > 1) == (data is RENDER_Y_STEREO)
|
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:
|
if is_stereo:
|
||||||
stereo_grid_u8 = (grid_u8 * OPACITY + bg_u8 * (1 - OPACITY)).astype(int)
|
stereo_grid_u8 = (grid_u8 * OPACITY + bg_u8 * (1 - OPACITY)).astype(int)
|
||||||
all_colors.append(stereo_grid_u8)
|
all_colors.append(stereo_grid_u8)
|
||||||
|
@ -138,8 +169,14 @@ def verify(
|
||||||
).any(), "incorrect foreground, it might be 136 = #888888"
|
).any(), "incorrect foreground, it might be 136 = #888888"
|
||||||
|
|
||||||
# Ensure grid color is present
|
# Ensure grid color is present
|
||||||
if grid_str:
|
does_grid_appear_here = np.prod(frame_colors == grid_u8, axis=-1)
|
||||||
assert np.prod(frame_colors == grid_u8, axis=-1).any(), "Missing grid_str"
|
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
|
# Ensure stereo grid color is present
|
||||||
if is_stereo:
|
if is_stereo:
|
||||||
|
|
Ładowanie…
Reference in New Issue