Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
78e9da6
fix: update size policy for auto clim button in LUT widget
tlambert03 Mar 28, 2026
931d021
fix: disable ROI button in ArrayViewerModelroi
tlambert03 Mar 28, 2026
cafa850
fix: adjust spacing for button layout in _UpCollapsible
tlambert03 Mar 28, 2026
bf10341
fix: adjust axis properties and margins in PlotWidget for better layout
tlambert03 Mar 28, 2026
ff7c835
fix: adjust count-axis range for log-transformed mesh data in VispyHi…
tlambert03 Mar 28, 2026
2d51603
fix: enhance log-scale axis handling in VispyHistogramCanvas and add …
tlambert03 Mar 28, 2026
b4e2229
fix: enhance mouse event handling for zoom and pan in PanZoom1DCamera
tlambert03 Mar 28, 2026
a70b1f4
fix: enhance zoom functionality and improve y-axis ruler updates in P…
tlambert03 Mar 28, 2026
07a7786
Merge branch 'main' into fix-histogram
tlambert03 Mar 28, 2026
b479d6f
fix: update ROI button visibility handling across array views
tlambert03 Mar 28, 2026
7d1938e
fix: correct ROI button display assertion in test_array_options
tlambert03 Mar 28, 2026
fb22d62
smarter y label widths for vispy
tlambert03 Mar 28, 2026
911a63d
fix: adjust margins and enhance y-axis label display in histogram canvas
tlambert03 Mar 28, 2026
b174478
fix typing
tlambert03 Mar 28, 2026
7e7826d
feat: enhance ArrayViewer and ChannelController with ImageStats integ…
tlambert03 Mar 28, 2026
cd01fd3
refactor: clean up numpy_arr.py and remove unused channel_stats metho…
tlambert03 Mar 28, 2026
02bcda0
Merge branch 'main' into histogram-signal
tlambert03 Mar 28, 2026
4a1cc68
feat: integrate compute_image_stats in ArrayViewer for improved stati…
tlambert03 Mar 28, 2026
d4f9c18
Merge branch 'main' into histogram-signal
tlambert03 Mar 28, 2026
4a4e4cf
test: ensure histogram data is set only once on first draw
tlambert03 Mar 29, 2026
cf618b9
add test
tlambert03 Mar 29, 2026
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
116 changes: 116 additions & 0 deletions examples/custom_histogram.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# /// script
# dependencies = [
# "ndv[pyqt,vispy]",
# "imageio[tifffile]",
# "pyqtgraph",
# ]
#
# [tool.uv.sources]
# ndv = { path = "../", editable = true }
# ///
"""Example: custom histogram widget using ndv's stats_updated signal."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

import numpy as np
import pyqtgraph as pg
from qtpy.QtWidgets import QHBoxLayout, QWidget

import ndv

if TYPE_CHECKING:
from ndv.models import LUTModel


class CustomHistogramWidget(QWidget):
"""A pyqtgraph histogram that updates from ndv's stats signal."""

def __init__(self, viewer: ndv.ArrayViewer) -> None:
super().__init__()
self._viewer = viewer
self._curves: dict[object, pg.PlotDataItem] = {}
self._plot = pg.PlotWidget(title="Custom Histogram")

layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self._plot)

# Track colormap changes on existing and future LUT models
for key, lut in viewer.display_model.luts.items():
self._watch_lut(key, lut)
viewer.display_model.luts.item_added.connect(self._watch_lut)

def _channel_color(self, key: object) -> tuple:
"""Get the RGBA color for a channel from the viewer's LUT model."""
luts = self._viewer.display_model.luts
lut = luts.get(key, self._viewer.display_model.default_lut)
return lut.cmap.to_pyqtgraph().getColors()[-1]

def _update_curve_color(self, key: object) -> None:
if curve := self._curves.get(key):
color = self._channel_color(key)
curve.setPen(pg.mkPen(color, width=2))
curve.setFillBrush(pg.mkBrush(*color[:3], 64))

def _watch_lut(self, key: Any, lut: LUTModel) -> None:
lut.events.cmap.connect(lambda *_: self._update_curve_color(key))

def on_stats(self, key: object, stats: ndv.ImageStats) -> None:
if stats.counts is None or stats.bin_edges is None:
return

counts = stats.counts
edges = stats.bin_edges
# Downsample to ~256 bins for fast rendering
n = len(counts)
if n > 256:
factor = n // 256
trim = n - (n % factor)
counts = counts[:trim].reshape(-1, factor).sum(axis=1)
edges = np.concatenate([edges[:trim:factor], edges[trim : trim + 1]])

centers = (edges[:-1] + edges[1:]) / 2
color = self._channel_color(key)

if key in self._curves:
self._curves[key].setData(centers, counts)
else:
self._curves[key] = self._plot.plot(
centers,
counts,
pen=pg.mkPen(color, width=2),
fillLevel=0.5,
fillBrush=pg.mkBrush(*color[:3], 64),
name=f"ch {key}",
)


# --- Setup ---

try:
img = ndv.data.cells3d()
except Exception:
img = ndv.data.nd_sine_wave((10, 3, 8, 512, 512))

viewer = ndv.ArrayViewer(
img,
current_index={0: 30},
channel_mode="composite",
luts={0: {"name": "FITC"}, 1: {"name": "DAPI", "cmap": "magenta"}},
scales={0: 0.4, -1: 0.2, -2: 0.2},
)

hist_widget = CustomHistogramWidget(viewer)
viewer.stats_updated.connect(hist_widget.on_stats)
viewer.refresh_stats()

container = QWidget()
layout = QHBoxLayout(container)
layout.addWidget(viewer.widget())
layout.addWidget(hist_widget)
container.resize(1200, 600)
container.show()

ndv.run_app()
3 changes: 2 additions & 1 deletion src/ndv/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
__version__ = "uninstalled"

from . import data
from .controllers import ArrayViewer
from .controllers import ArrayViewer, ImageStats
from .models import DataWrapper
from .util import imshow
from .views import (
Expand All @@ -22,6 +22,7 @@
__all__ = [
"ArrayViewer",
"DataWrapper",
"ImageStats",
"call_later",
"data",
"imshow",
Expand Down
3 changes: 2 additions & 1 deletion src/ndv/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Controllers are the primary public interfaces that wrap models & views."""

from ._array_viewer import ArrayViewer
from ._image_stats import ImageStats

__all__ = ["ArrayViewer"]
__all__ = ["ArrayViewer", "ImageStats"]
71 changes: 44 additions & 27 deletions src/ndv/controllers/_array_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
from typing import TYPE_CHECKING, Any, Literal, cast

import numpy as np
from psygnal import Signal

from ndv._keybindings import handle_key_press
from ndv.controllers._channel_controller import ChannelController
from ndv.controllers._image_stats import compute_image_stats
from ndv.controllers._image_stats import ImageStats, compute_image_stats
from ndv.models import ArrayDisplayModel, ChannelMode, DataWrapper, LUTModel
from ndv.models._resolve import (
EMPTY_STATE,
Expand Down Expand Up @@ -67,6 +68,8 @@ class ArrayViewer:
`display_model` is provided, these will be ignored.
"""

stats_updated = Signal(object, ImageStats)

def __init__(
self,
data: Any | DataWrapper = None,
Expand Down Expand Up @@ -242,6 +245,25 @@ def clone(self) -> ArrayViewer:
# TODO: provide deep_copy option
return ArrayViewer(self._data_wrapper, display_model=self.display_model)

def refresh_stats(self) -> None:
"""Force re-emit stats for all channels with existing data.

This will mostly be used by external listeners that want the initial data,
before any interaction has occurred.
"""
if not len(self.stats_updated):
return
sig_bits = wrp.significant_bits if (wrp := self._data_wrapper) else None
for key, ctrl in self._lut_controllers.items():
if ctrl.handles:
stats = compute_image_stats(
ctrl.handles[0].data(),
ctrl.lut_model.clims,
need_histogram=True,
significant_bits=sig_bits,
)
self.stats_updated.emit(key, stats)

# --------------------- PRIVATE ------------------------------------------

def _connect_datawrapper(
Expand Down Expand Up @@ -280,29 +302,28 @@ def _default_display_model(
def _add_histogram(self, channel: ChannelKey = None) -> None:
histogram_cls = _app.get_histogram_canvas_class() # will raise if not supported
hist = histogram_cls()
self._histograms[channel] = hist

if ctrl := self._lut_controllers.get(channel, None):
# Add histogram to ArrayView for display
self._view.add_histogram(channel, hist)
# Add histogram to channel controller for updates
ctrl.add_lut_view(hist)
# Compute histogram from the (first) image handle.
# TODO: Compute histogram from all image handles
if handles := ctrl.handles:
data = handles[0].data()
self._connect_histogram(ctrl, hist)

sig_bits = wrp.significant_bits if (wrp := self._data_wrapper) else None
stats = compute_image_stats(
data,
ctrl.lut_model.clims,
need_histogram=True,
significant_bits=sig_bits,
)
if stats.counts is not None and stats.bin_edges is not None:
hist.set_data(stats.counts, stats.bin_edges)
# Reset camera view (accounting for data)
hist.set_range()
def _connect_histogram(
self, ctrl: ChannelController, hist: HistogramCanvas
) -> None:
"""Connect a histogram to a channel controller's stats signal."""

self._histograms[channel] = hist
def _on_stats(stats: ImageStats) -> None:
if stats.counts is not None and stats.bin_edges is not None:
hist.set_data(stats.counts, stats.bin_edges)

ctrl.stats_updated.connect(_on_stats)
# Trigger initial data from existing handle
if handles := ctrl.handles:
sig_bits = wrp.significant_bits if (wrp := self._data_wrapper) else None
ctrl.update_texture_data(handles[0].data(), significant_bits=sig_bits)
hist.set_range()

def _update_channel_dtype(
self, channel: ChannelKey, dtype: npt.DTypeLike | None = None
Expand Down Expand Up @@ -707,18 +728,14 @@ def _on_data_response_ready(self, future: Future[DataResponse]) -> None:
self._canvas.set_scales(self._resolved.visible_scales)

sig_bits = wrp.significant_bits if (wrp := self._data_wrapper) else None
has_broadcast = len(self.stats_updated) > 0
stats = lut_ctrl.update_texture_data(
data,
need_histogram=key in self._histograms,
need_histogram=has_broadcast,
significant_bits=sig_bits,
)
if (
stats is not None
and stats.counts is not None
and stats.bin_edges is not None
and (hist := self._histograms.get(key))
):
hist.set_data(stats.counts, stats.bin_edges)
if has_broadcast and stats is not None:
self.stats_updated.emit(key, stats)

self._canvas.refresh()
# update highlight display
Expand Down
15 changes: 13 additions & 2 deletions src/ndv/controllers/_channel_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
from contextlib import suppress
from typing import TYPE_CHECKING

from ndv.controllers._image_stats import compute_image_stats
from psygnal import Signal

from ndv.controllers._image_stats import ImageStats, compute_image_stats

if TYPE_CHECKING:
from collections.abc import Iterable, Sequence

import numpy as np

from ndv._types import ChannelKey
from ndv.controllers._image_stats import ImageStats
from ndv.models._lut_model import LUTModel
from ndv.views.bases import LUTView
from ndv.views.bases._graphics._canvas_elements import ImageHandle
Expand All @@ -26,6 +27,13 @@ class ChannelController:
that displays the data, all for a single "channel" extracted from the data.
"""

stats_updated = Signal(ImageStats)

@property
def needs_histogram(self) -> bool:
"""Whether any listener needs histogram data."""
return len(self.stats_updated) > 0

def __init__(
self, key: ChannelKey, lut_model: LUTModel, views: Sequence[LUTView]
) -> None:
Expand Down Expand Up @@ -66,13 +74,16 @@ def update_texture_data(
if not (handles := self.handles):
return None
handles[0].set_data(data)
need_histogram = need_histogram or self.needs_histogram
stats = compute_image_stats(
data,
self.lut_model.clims,
need_histogram=need_histogram,
significant_bits=significant_bits,
)
self._set_clims(stats.clims)
if self.needs_histogram:
self.stats_updated.emit(stats)
return stats

def add_handle(self, handle: ImageHandle) -> None:
Expand Down
58 changes: 56 additions & 2 deletions tests/test_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,14 @@ def test_histogram_updates_on_first_draw() -> None:
"""Histogram should update even when the first response creates the handle."""
ctrl = ArrayViewer()

ctrl._histograms[None] = hist = MagicMock(spec=HistogramCanvas)
ctrl._lut_controllers[None] = ChannelController(
hist = MagicMock(spec=HistogramCanvas)
ctrl._histograms[None] = hist
lut_ctrl = ChannelController(
key=None, lut_model=LUTModel(), views=[MagicMock(spec=LUTView), hist]
)
ctrl._lut_controllers[None] = lut_ctrl
# Connect histogram to stats signal (as _add_histogram would)
ctrl._connect_histogram(lut_ctrl, hist)

response = DataResponse(
n_visible_axes=2,
Expand Down Expand Up @@ -833,6 +837,56 @@ def press(key: str, mods: KeyMod = KeyMod.NONE) -> None:
ctrl._canvas.zoom.assert_called_once_with(factor=1.5, center=(5.0, 5.0))


@no_type_check
@_patch_views
def test_stats_signals() -> None:
"""Test that stats_updated signals fire on data updates and refresh_stats."""
from ndv.controllers._image_stats import ImageStats

ctrl = ArrayViewer()
ctrl._async = False
ctrl.data = np.random.randint(0, 255, (10, 10), dtype=np.uint8)

# -- ArrayViewer.stats_updated emits on data changes when connected --
viewer_mock = Mock()
ctrl.stats_updated.connect(viewer_mock)

ctrl.data = np.random.randint(0, 255, (10, 10), dtype=np.uint8)
viewer_mock.assert_called_once()
key, stats = viewer_mock.call_args[0]
assert key is None # grayscale default channel
assert isinstance(stats, ImageStats)
assert stats.counts is not None
assert stats.bin_edges is not None

# -- ChannelController.stats_updated emits on update_texture_data --
ch_ctrl = ctrl._lut_controllers[None]
ch_mock = Mock()
ch_ctrl.stats_updated.connect(ch_mock)

new_data = np.random.randint(0, 255, (10, 10), dtype=np.uint8)
ch_ctrl.update_texture_data(new_data)
ch_mock.assert_called_once()
assert ch_mock.call_args[0][0].counts is not None

# -- refresh_stats re-emits for all channels --
viewer_mock.reset_mock()
ctrl.refresh_stats()
viewer_mock.assert_called_once()
assert viewer_mock.call_args[0][0] is None # channel key

# -- stats_updated does NOT fire when no listeners are connected --
ctrl.stats_updated.disconnect()
ch_ctrl.stats_updated.disconnect()
viewer_mock.reset_mock()
ctrl.data = np.random.randint(0, 255, (10, 10), dtype=np.uint8)
viewer_mock.assert_not_called()

# refresh_stats is a no-op when no listeners
ctrl.refresh_stats()
viewer_mock.assert_not_called()


@no_type_check
@pytest.mark.usefixtures("any_app")
def test_handle_gc_on_data_reassign() -> None:
Expand Down
Loading