diff --git a/pyproject.toml b/pyproject.toml index d6313f5..be81637 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.11" dynamic = ["version"] dependencies = [ - "fastplotlib>=0.6.1", + "fastplotlib", "numpy>=2.4.2", "pyside6>=6.10.2", ] @@ -63,3 +63,6 @@ select = ["E", "F", "I", "W"] [tool.ruff.lint.isort] known-first-party = ["ezmsg.sigproc"] known-third-party = ["ezmsg", "ezmsg.baseproc"] + +[tool.uv.sources] +fastplotlib = { git = "https://github.com/pperanich/fastplotlib.git", rev = "feature/multi-line-graphic" } diff --git a/src/phosphor/channel_plot.py b/src/phosphor/channel_plot.py index 4e08c28..6ac3bd3 100644 --- a/src/phosphor/channel_plot.py +++ b/src/phosphor/channel_plot.py @@ -99,7 +99,7 @@ def _init_rendering(self) -> None: # ------------------------------------------------------------------ def _update_graphics(self) -> None: - """Update LineStack data each frame. Called from animation callback.""" + """Update MultiLine data each frame. Called from animation callback.""" raise NotImplementedError def _on_ctrl_scroll(self, delta: float) -> None: @@ -186,7 +186,7 @@ def _zoom_amplitude(self, factor: float) -> None: # ------------------------------------------------------------------ def _handle_mouse_move(self, event) -> None: - if self._line_stack is None: + if self._multi_line is None: return buf = self._buffer @@ -196,11 +196,11 @@ def _handle_mouse_move(self, event) -> None: return wy = float(world[1]) - # Find the nearest line by comparing world Y positions + # Find the nearest line by comparing computed Y positions best_idx = 0 best_dist = float("inf") for i in range(buf.n_visible): - line_y = float(self._line_stack[i].world_object.world.position[1]) + line_y = i * self._z_offset_scale dist = abs(wy - line_y) if dist < best_dist: best_dist = dist diff --git a/src/phosphor/spectrum_buffer.py b/src/phosphor/spectrum_buffer.py index 18cd7cb..1863bb3 100644 --- a/src/phosphor/spectrum_buffer.py +++ b/src/phosphor/spectrum_buffer.py @@ -115,7 +115,7 @@ def set_n_bins(self, n_bins: int) -> None: self._allocate() # ------------------------------------------------------------------ - # Properties and LineStack data + # Properties and MultiLine data # ------------------------------------------------------------------ @property @@ -127,7 +127,7 @@ def _compute_y_scale(self) -> float: max_abs = max(float(np.abs(self.display_mins).max()), float(np.abs(self.display_maxs).max())) return 0.5 / max(max_abs, 1e-12) - def _build_linestack_array(self, mins, maxs, bin_indices, freq_max, scale) -> np.ndarray: + def _build_multiline_array(self, mins, maxs, bin_indices, freq_max, scale) -> np.ndarray: """Build a ``[n_visible, 2*n_bins, 3]`` array. Must hold ``_lock``.""" n_bins = mins.shape[0] out = np.zeros((self.n_visible, 2 * n_bins, 3), dtype=np.float32) @@ -136,23 +136,25 @@ def _build_linestack_array(self, mins, maxs, bin_indices, freq_max, scale) -> np out[:, 1::2, 0] = bin_x[np.newaxis, :] out[:, 0::2, 1] = mins.T * scale out[:, 1::2, 1] = maxs.T * scale + out[:, :, 2] = np.arange(self.n_visible)[:, np.newaxis] return out - def get_linestack_data(self, freq_max: float) -> np.ndarray: - """Full data shaped ``[n_visible, 2*n_bins, 3]`` for fastplotlib LineStack. + def get_multiline_data(self, freq_max: float) -> np.ndarray: + """Full data shaped ``[n_visible, 2*n_bins, 3]`` for fastplotlib MultiLineGraphic. Y-coordinates are normalized so the max absolute value maps to ±0.5. + Z-coordinates encode channel index for z_offset_scale separation. """ with self._lock: self._y_scale = self._compute_y_scale() - out = self._build_linestack_array( + out = self._build_multiline_array( self.display_mins, self.display_maxs, np.arange(self.n_bins), freq_max, self._y_scale ) self._dirty_start = None self._dirty_end = None return out - def get_dirty_linestack_range(self, freq_max: float) -> tuple[np.ndarray, int, int] | None: + def get_dirty_multiline_range(self, freq_max: float) -> tuple[np.ndarray, int, int] | None: """Incremental update for dirty bin range. Returns ``(data_slice, bin_start, n_bins)`` or ``None`` if clean. @@ -167,7 +169,7 @@ def get_dirty_linestack_range(self, freq_max: float) -> tuple[np.ndarray, int, i if old_scale > 0 and abs(new_scale - old_scale) / old_scale > 0.2: self._y_scale = new_scale - out = self._build_linestack_array( + out = self._build_multiline_array( self.display_mins, self.display_maxs, np.arange(self.n_bins), freq_max, self._y_scale ) self._dirty_start = None @@ -181,7 +183,7 @@ def get_dirty_linestack_range(self, freq_max: float) -> tuple[np.ndarray, int, i if end >= start: n_bins = end - start + 1 - out = self._build_linestack_array( + out = self._build_multiline_array( self.display_mins[start : end + 1], self.display_maxs[start : end + 1], np.arange(start, end + 1), @@ -191,7 +193,7 @@ def get_dirty_linestack_range(self, freq_max: float) -> tuple[np.ndarray, int, i return out, start, n_bins else: self._y_scale = new_scale - out = self._build_linestack_array( + out = self._build_multiline_array( self.display_mins, self.display_maxs, np.arange(self.n_bins), freq_max, self._y_scale ) return out, 0, self.n_bins diff --git a/src/phosphor/spectrum_widget.py b/src/phosphor/spectrum_widget.py index 737b0cc..433a35e 100644 --- a/src/phosphor/spectrum_widget.py +++ b/src/phosphor/spectrum_widget.py @@ -62,7 +62,8 @@ def __init__(self, config: SpectrumConfig, parent: QWidget | None = None): # Create initial graphics self._cached_version = -1 - self._line_stack = None + self._multi_line = None + self._z_offset_scale = 1.0 self._setup_graphics() # Start rendering @@ -107,24 +108,24 @@ def update_config(self, config: SpectrumConfig) -> None: # ------------------------------------------------------------------ def _setup_graphics(self) -> None: - """Create or recreate LineStack.""" + """Create or recreate MultiLineGraphic.""" subplot = self._subplot - if self._line_stack is not None: - subplot.delete_graphic(self._line_stack) - self._line_stack = None + if self._multi_line is not None: + subplot.delete_graphic(self._multi_line) + self._multi_line = None buf = self.spectrum_buffer - data = buf.get_linestack_data(self._display_freq_max) + data = buf.get_multiline_data(self._display_freq_max) n_vis = buf.n_visible colors = [CHANNEL_COLORS[i % len(CHANNEL_COLORS)][:3] for i in range(n_vis)] - self._line_stack = subplot.add_line_stack( + self._multi_line = subplot.add_multi_line( data, colors=colors, - separation=1.0, - separation_axis="y", + z_offset_scale=self._z_offset_scale, + thickness=1.5, ) self._cached_version = buf.version @@ -141,13 +142,12 @@ def _update_graphics(self) -> None: return # Incremental update - result = buf.get_dirty_linestack_range(self._display_freq_max) + result = buf.get_dirty_multiline_range(self._display_freq_max) if result is not None: data_slice, bin_start, n_bins = result idx_start = bin_start * 2 idx_end = (bin_start + n_bins) * 2 - for ch in range(buf.n_visible): - self._line_stack[ch].data[idx_start:idx_end] = data_slice[ch] + self._multi_line.data[:, idx_start:idx_end] = data_slice # ------------------------------------------------------------------ # Keyboard controls diff --git a/src/phosphor/sweep_buffer.py b/src/phosphor/sweep_buffer.py index 2f8bedb..d049dae 100644 --- a/src/phosphor/sweep_buffer.py +++ b/src/phosphor/sweep_buffer.py @@ -150,7 +150,7 @@ def set_srate(self, srate: float) -> None: self._allocate() # ------------------------------------------------------------------ - # Properties and LineStack data (called from render/UI thread) + # Properties and MultiLine data (called from render/UI thread) # ------------------------------------------------------------------ @property @@ -161,13 +161,13 @@ def _compute_y_scale(self) -> float: """Compute normalization scale from current buffer data. Uses the max absolute value across all display columns/channels so - that normalized data fits within ±0.5, matching LineStack separation. + that normalized data fits within ±0.5, matching MultiLine z_offset_scale separation. Must be called while holding ``_lock``. """ max_abs = max(float(np.abs(self.display_mins).max()), float(np.abs(self.display_maxs).max())) return 0.5 / max(max_abs, 1e-12) - def _build_linestack_array(self, mins, maxs, col_indices, scale) -> np.ndarray: + def _build_multiline_array(self, mins, maxs, col_indices, scale) -> np.ndarray: """Build a ``[n_visible, 2*n_cols, 3]`` array from min/max slices. Must be called while holding ``_lock``. @@ -179,17 +179,18 @@ def _build_linestack_array(self, mins, maxs, col_indices, scale) -> np.ndarray: out[:, 1::2, 0] = col_x[np.newaxis, :] out[:, 0::2, 1] = mins.T * scale out[:, 1::2, 1] = maxs.T * scale + out[:, :, 2] = np.arange(self.n_visible)[:, np.newaxis] return out - def get_linestack_data(self) -> np.ndarray: - """Full data shaped ``[n_visible, 2*n_columns, 3]`` for fastplotlib LineStack. + def get_multiline_data(self) -> np.ndarray: + """Full data shaped ``[n_visible, 2*n_columns, 3]`` for fastplotlib MultiLineGraphic. Y-coordinates are normalized so the max absolute value maps to ±0.5, - matching LineStack separation=1.0 and preventing channel overlap. + and Z-coordinates encode channel index for z_offset_scale separation. """ with self._lock: self._y_scale = self._compute_y_scale() - out = self._build_linestack_array( + out = self._build_multiline_array( self.display_mins, self.display_maxs, np.arange(self.n_columns), @@ -199,7 +200,7 @@ def get_linestack_data(self) -> np.ndarray: self._dirty_end = None return out - def get_dirty_linestack_range(self) -> tuple[np.ndarray, int, int] | None: + def get_dirty_multiline_range(self) -> tuple[np.ndarray, int, int] | None: """Incremental update for dirty column range. Returns ``(data_slice, col_start, n_cols)`` or ``None`` if clean. @@ -215,7 +216,7 @@ def get_dirty_linestack_range(self) -> tuple[np.ndarray, int, int] | None: if old_scale > 0 and abs(new_scale - old_scale) / old_scale > 0.2: # Full update with new scale self._y_scale = new_scale - out = self._build_linestack_array( + out = self._build_multiline_array( self.display_mins, self.display_maxs, np.arange(self.n_columns), @@ -232,7 +233,7 @@ def get_dirty_linestack_range(self) -> tuple[np.ndarray, int, int] | None: if end >= start: n_cols = end - start + 1 - out = self._build_linestack_array( + out = self._build_multiline_array( self.display_mins[start : end + 1], self.display_maxs[start : end + 1], np.arange(start, end + 1), @@ -242,7 +243,7 @@ def get_dirty_linestack_range(self) -> tuple[np.ndarray, int, int] | None: else: # Wrapped — full update self._y_scale = new_scale - out = self._build_linestack_array( + out = self._build_multiline_array( self.display_mins, self.display_maxs, np.arange(self.n_columns), diff --git a/src/phosphor/sweep_widget.py b/src/phosphor/sweep_widget.py index d4b321d..ad321c3 100644 --- a/src/phosphor/sweep_widget.py +++ b/src/phosphor/sweep_widget.py @@ -67,7 +67,8 @@ def __init__(self, config: SweepConfig, parent: QWidget | None = None): # Create initial graphics self._cached_version = -1 - self._line_stack = None + self._multi_line = None + self._z_offset_scale = 1.0 self._cursor_line = None self._setup_graphics() @@ -104,38 +105,38 @@ def update_config(self, config: SweepConfig) -> None: # ------------------------------------------------------------------ def _setup_graphics(self) -> None: - """Create or recreate LineStack and cursor line.""" + """Create or recreate MultiLineGraphic and cursor line.""" subplot = self._subplot # Delete old graphics - if self._line_stack is not None: - subplot.delete_graphic(self._line_stack) - self._line_stack = None + if self._multi_line is not None: + subplot.delete_graphic(self._multi_line) + self._multi_line = None if self._cursor_line is not None: subplot.delete_graphic(self._cursor_line) self._cursor_line = None buf = self.sweep_buffer - data = buf.get_linestack_data() + data = buf.get_multiline_data() # Build per-channel colors (cycling through CHANNEL_COLORS) n_vis = buf.n_visible colors = [CHANNEL_COLORS[i % len(CHANNEL_COLORS)][:3] for i in range(n_vis)] - self._line_stack = subplot.add_line_stack( + self._multi_line = subplot.add_multi_line( data, colors=colors, - separation=1.0, - separation_axis="y", + z_offset_scale=self._z_offset_scale, + thickness=1.5, ) # Cursor: vertical line at the sweep position. - # Span the LineStack's y-extent with small margin. + # Span the MultiLine's y-extent with small margin. sweep_x = buf.sweep_col / max(buf.n_columns - 1, 1) * buf.display_dur cursor_color = CURSOR_COLOR[:3] gap_w = CURSOR_GAP_COLUMNS / max(buf.n_columns - 1, 1) * buf.display_dur - y_bottom = self._line_stack[0].world_object.world.position[1] - y_top = self._line_stack[-1].world_object.world.position[1] + y_bottom = 0.0 + y_top = (n_vis - 1) * self._z_offset_scale margin = max((y_top - y_bottom) * 0.05, 0.5) self._cursor_y_min = y_bottom - margin self._cursor_y_max = y_top + margin @@ -163,15 +164,14 @@ def _update_graphics(self) -> None: return # Incremental update from dirty columns - result = buf.get_dirty_linestack_range() + result = buf.get_dirty_multiline_range() if result is not None: data_slice, col_start, n_cols = result idx_start = col_start * 2 idx_end = (col_start + n_cols) * 2 - for ch in range(buf.n_visible): - self._line_stack[ch].data[idx_start:idx_end] = data_slice[ch] + self._multi_line.data[:, idx_start:idx_end] = data_slice - # Update cursor x-position; y spans the LineStack extent (not camera, + # Update cursor x-position; y spans the MultiLine extent (not camera, # which would create a feedback loop with auto_scale). sweep_x = buf.sweep_col / max(buf.n_columns - 1, 1) * buf.display_dur self._cursor_line.data[0] = [sweep_x, self._cursor_y_min, 0]