Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down Expand Up @@ -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" }
8 changes: 4 additions & 4 deletions src/phosphor/channel_plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
20 changes: 11 additions & 9 deletions src/phosphor/spectrum_buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def set_n_bins(self, n_bins: int) -> None:
self._allocate()

# ------------------------------------------------------------------
# Properties and LineStack data
# Properties and MultiLine data
# ------------------------------------------------------------------

@property
Expand All @@ -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)
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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),
Expand All @@ -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
24 changes: 12 additions & 12 deletions src/phosphor/spectrum_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
23 changes: 12 additions & 11 deletions src/phosphor/sweep_buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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``.
Expand All @@ -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),
Expand All @@ -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.
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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),
Expand Down
32 changes: 16 additions & 16 deletions src/phosphor/sweep_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
Loading