Skip to content

feat: Better histogram control (plus pygfx histogram overhaul) #146

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 45 commits into from
Apr 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
e0f2688
BIG WIP: Add slider
gselzer Feb 22, 2025
d881a3f
WIP: Log slider
gselzer Feb 25, 2025
99cdc33
Add double click to reset gamma
gselzer Feb 25, 2025
cd68153
WIP: Log button instead
gselzer Feb 26, 2025
eaaab42
WIP: More histogram-related niceness
gselzer Feb 26, 2025
38797ec
Rework pygfx histogram
gselzer Feb 27, 2025
a9d887e
Add Histogram controls to wx
gselzer Feb 28, 2025
8e1f08d
Add buttons for jupyter
gselzer Mar 1, 2025
aafd074
qt: remove auto thresholding ignoring tails
gselzer Mar 1, 2025
5bf0f1e
GraphicsCanvas: add set_extent
gselzer Mar 5, 2025
e14b6b8
JupyterLutView: remove button value
gselzer Mar 5, 2025
ded4f7a
Fix _jupyter/test_array_options
gselzer Mar 5, 2025
3daf7d7
pygfx histogram: Prevent dbz
gselzer Mar 5, 2025
fef4ab1
Remove FIXME
gselzer Mar 12, 2025
4f4d191
Compute histogram range when updating dtype
gselzer Mar 12, 2025
f9500d1
Jupyter histogram: resolve small fixmes
gselzer Mar 12, 2025
acedb6e
Add LutModel.set_clim_bounds
gselzer Mar 13, 2025
55b47f6
Use Optionals for Python 3.9
gselzer Mar 13, 2025
3d1506a
Add double-click events to jupyter, wx
gselzer Mar 13, 2025
e30cdb4
wx: Minor fixes
gselzer Mar 13, 2025
e149b96
Clean up double clicking
gselzer Mar 14, 2025
7c75db8
Clean up qt changes
gselzer Mar 14, 2025
7afd5ad
vispy histogram: Remove log cruft
gselzer Mar 14, 2025
57aef10
plotwidget: remove commented method
gselzer Mar 14, 2025
5ac6598
Clean up camera rect bounding logic
gselzer Mar 14, 2025
5139aba
Clean up wx LutView
gselzer Mar 14, 2025
51fdab5
pygfx: margin_top <-> margin_bottom
gselzer Mar 14, 2025
0a5b047
pygfx: clean up histogram cruft
gselzer Mar 14, 2025
f9fab4e
Merge remote-tracking branch 'upstream/main' into histogram-log-slider
gselzer Mar 14, 2025
28fb2a2
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Mar 14, 2025
24ea16c
Merge remote-tracking branch 'upstream/main' into histogram-log-slider
gselzer Mar 18, 2025
6506c6f
Merge remote-tracking branch 'upstream/main' into histogram-log-slider
gselzer Mar 19, 2025
e6fd8fe
Write UI unit tests
gselzer Mar 19, 2025
b0d3990
Retain reference to histogram controls
gselzer Mar 20, 2025
48f8c90
WIP: Find that segfault
gselzer Mar 20, 2025
50e6d89
Use CallAfter to delete parent
gselzer Mar 20, 2025
03ccc62
Test Vispy histogram interaction: first steps
gselzer Mar 21, 2025
dea767e
Use any_app
gselzer Mar 21, 2025
1525be3
Flesh out vispy histogram tests
gselzer Mar 21, 2025
cf5f19c
Add pygfx test
gselzer Mar 21, 2025
d142963
pygfx: Allow leaks
gselzer Mar 21, 2025
926edbf
Remove unnecessary resize
gselzer Mar 22, 2025
3da9f3a
Try fixing widget leaks
gselzer Apr 1, 2025
3932041
Set size policy
gselzer Apr 1, 2025
b735349
Merge remote-tracking branch 'upstream/main' into histogram-log-slider
gselzer Apr 1, 2025
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
25 changes: 12 additions & 13 deletions src/ndv/controllers/_array_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,22 +247,25 @@ def _add_histogram(self, channel: ChannelKey = None) -> None:
histogram_cls = _app.get_histogram_canvas_class() # will raise if not supported
hist = histogram_cls()
if ctrl := self._lut_controllers.get(channel, None):
self._view.add_histogram(channel, hist.frontend_widget())
# Add histogram to ArrayView for display
self._view.add_histogram(channel, hist)
# Add histogram to channel controller for updates
ctrl.add_lut_view(hist)
# FIXME: hack
# Compute histogram from the (first) image handle.
# TODO: Compute histogram from all image handles
if handles := ctrl.handles:
data = handles[0].data()
counts, edges = _calc_hist_bins(data)
hist.set_data(counts, edges)
# Reset camera view (accounting for data)
hist.set_range()

self._histograms[channel] = hist
if self.data is not None:
self._update_hist_domain_for_dtype()

def _update_hist_domain_for_dtype(
self, dtype: np.typing.DTypeLike | None = None
def _update_channel_dtype(
self, channel: ChannelKey, dtype: np.typing.DTypeLike | None = None
) -> None:
if len(self._histograms) == 0:
if not (ctrl := self._lut_controllers.get(channel, None)):
return
if dtype is None:
if (wrapper := self._data_model.data_wrapper) is None:
Expand All @@ -272,8 +275,7 @@ def _update_hist_domain_for_dtype(
dtype = np.dtype(dtype)
if dtype.kind in "iu":
iinfo = np.iinfo(dtype)
for hist in self._histograms.values():
hist.set_range(x=(iinfo.min, iinfo.max))
ctrl.lut_model.clim_bounds = (iinfo.min, iinfo.max)

def _set_model_connected(
self, model: ArrayDisplayModel, connect: bool = True
Expand Down Expand Up @@ -340,7 +342,6 @@ def _fully_synchronize_view(self) -> None:
self._request_data()
for lut_ctr in self._lut_controllers.values():
lut_ctr.synchronize()
self._update_hist_domain_for_dtype()
self._synchronize_roi()

def _synchronize_roi(self) -> None:
Expand Down Expand Up @@ -564,6 +565,7 @@ def _on_data_response_ready(self, future: Future[DataResponse]) -> None:
lut_model=model,
views=lut_views,
)
self._update_channel_dtype(key)

if not lut_ctrl.handles:
# we don't yet have any handles for this channel
Expand All @@ -581,10 +583,7 @@ def _on_data_response_ready(self, future: Future[DataResponse]) -> None:
# TODO: once data comes in in chunks, we'll need a proper stateful
# stats object that calculates the histogram incrementally
counts, bin_edges = _calc_hist_bins(data)
# FIXME: currently this is updating the histogram on *any*
# channel index... so it doesn't work with composite mode
hist.set_data(counts, bin_edges)
hist.set_range()

self._canvas.refresh()

Expand Down
7 changes: 5 additions & 2 deletions src/ndv/models/_lut_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ def get_limits(self, data: npt.NDArray) -> tuple[float, float]:
def __eq__(self, other: object) -> bool:
return (
isinstance(other, ClimsManual)
and self.min == other.min
and self.max == other.max
and abs(self.min - other.min) < 1e-6
and abs(self.max - other.max) < 1e-6
)


Expand Down Expand Up @@ -169,13 +169,16 @@ class LUTModel(NDVModel):
clims : Union[ClimsManual, ClimsPercentile, ClimsStdDev, ClimsMinMax]
Method for determining the contrast limits for this channel. If a 2-element
`tuple` or `list` is provided, it is interpreted as a manual contrast limit.
bounds : tuple[float | None, float | None]
Optional extrema for limiting the range of the contrast limits
gamma : float
Gamma applied to the data before applying the colormap. By default, `1.0`.
"""

visible: bool = True
cmap: Colormap = Field(default_factory=lambda: Colormap("gray"))
clims: ClimsType = Field(discriminator="clim_type", default_factory=ClimsMinMax)
clim_bounds: tuple[Optional[float], Optional[float]] = (None, None)
gamma: float = 1.0

@model_validator(mode="before")
Expand Down
12 changes: 12 additions & 0 deletions src/ndv/views/_jupyter/_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@
mpe = MousePressEvent(x=ev["x"], y=ev["y"], btn=active_btn)
intercepted |= receiver.on_mouse_press(mpe)
receiver.mousePressed.emit(mpe)
elif etype == "double_click":

Check warning on line 94 in src/ndv/views/_jupyter/_app.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_jupyter/_app.py#L94

Added line #L94 was not covered by tests
# Note that in Jupyter, a double_click event is not a pointer event
# and as such, we need to handle both press and release. See
# https://github.com/vispy/jupyter_rfb/blob/62831dd5a87bc19b4fd5f921d802ed21141e61ec/js/lib/widget.js#L270
btn = JupyterAppWrap.mouse_btn(ev["button"])
mpe = MousePressEvent(x=ev["x"], y=ev["y"], btn=btn)
intercepted |= receiver.on_mouse_double_press(mpe)
receiver.mouseDoublePressed.emit(mpe)

Check warning on line 101 in src/ndv/views/_jupyter/_app.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_jupyter/_app.py#L98-L101

Added lines #L98 - L101 were not covered by tests
# Release
mre = MouseReleaseEvent(x=ev["x"], y=ev["y"], btn=btn)
intercepted |= receiver.on_mouse_release(mre)
receiver.mouseReleased.emit(mre)

Check warning on line 105 in src/ndv/views/_jupyter/_app.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_jupyter/_app.py#L103-L105

Added lines #L103 - L105 were not covered by tests
elif etype == "pointer_up":
mre = MouseReleaseEvent(x=ev["x"], y=ev["y"], btn=active_btn)
active_btn = MouseButton.NONE
Expand Down
116 changes: 95 additions & 21 deletions src/ndv/views/_jupyter/_array_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from ndv._types import AxisKey, ChannelKey
from ndv.models._data_display_model import _ArrayDataDisplayModel
from ndv.views.bases._graphics._canvas import HistogramCanvas

# not entirely sure why it's necessary to specifically annotat signals as : PSignal
# i think it has to do with type variance?
Expand All @@ -47,7 +48,7 @@

def __init__(self, channel: ChannelKey = None) -> None:
self._channel = channel
self._histogram_wdg: widgets.Widget | None = None
self._histogram: HistogramCanvas | None = None
# WIDGETS
self._visible = widgets.Checkbox(value=True, indent=False)
self._visible.layout.width = "60px"
Expand Down Expand Up @@ -83,7 +84,7 @@
tooltip="Auto scale",
layout=widgets.Layout(min_width="40px"),
)
self._histogram = widgets.ToggleButton(
self._histogram_btn = widgets.ToggleButton(
value=False,
description="",
button_style="", # 'success', 'info', 'warning', 'danger' or ''
Expand All @@ -92,20 +93,67 @@
layout=widgets.Layout(width="40px"),
)

histogram_ctrl_width = 40
self._log = widgets.ToggleButton(
value=False,
description="log",
button_style="", # 'success', 'info', 'warning', 'danger' or ''
tooltip="Apply logarithm (base 10, count+1) to bins",
layout=widgets.Layout(width=f"{histogram_ctrl_width}px"),
)
self._reset_histogram = widgets.Button(
description="",
button_style="", # 'success', 'info', 'warning', 'danger' or ''
icon="expand",
tooltip="Reset histogram view",
layout=widgets.Layout(width=f"{histogram_ctrl_width}px"),
)

# LAYOUT

lut_ctrls = widgets.HBox(
[self._visible, self._cmap, self._clims, self._auto_clim, self._histogram]
[
self._visible,
self._cmap,
self._clims,
self._auto_clim,
self._histogram_btn,
]
)

histogram_ctrls = widgets.VBox(
[self._log, self._reset_histogram],
layout=widgets.Layout(
# Avoids scrollbar on buttons
min_width=f"{histogram_ctrl_width + 10}px",
# Floats buttons to the bottom
justify_content="flex-end",
),
)

self._histogram_container = widgets.HBox(
# Note that we'll add a histogram here later
[histogram_ctrls],
layout=widgets.Layout(
# Constrains histogram to 100px tall
max_height="100px",
# Avoids vertical scrollbar from
# histogram being *just a bit* too tall
overflow="hidden",
# Hide histogram initially
display="none",
),
)
self._histogram_container = widgets.HBox([])
self.layout = widgets.VBox([lut_ctrls, self._histogram_container])

# CONNECTIONS
self._visible.observe(self._on_visible_changed, names="value")
self._cmap.observe(self._on_cmap_changed, names="value")
self._clims.observe(self._on_clims_changed, names="value")
self._auto_clim.observe(self._on_autoscale_changed, names="value")
self._histogram.observe(self._on_histogram_requested, names="value")
self._histogram_btn.observe(self._on_histogram_requested, names="value")
self._log.observe(self._on_log_toggled, names="value")
self._reset_histogram.on_click(self._on_reset_histogram_clicked)

# ------------------ emit changes to the controller ------------------

Expand All @@ -131,13 +179,20 @@
self._model.clims = ClimsManual(min=clims[0], max=clims[1])

def _on_histogram_requested(self, change: dict[str, Any]) -> None:
if self._histogram_wdg:
# show or hide the actual widget itself
self._histogram_container.layout.display = (
"flex" if change["new"] else "none"
)
else:
# Generate the histogram if we haven't done so yet
if not self._histogram:
self.histogramRequested.emit(self._channel)
# show or hide the histogram controls
self._histogram_container.layout.display = "flex" if change["new"] else "none"

def _on_log_toggled(self, change: dict[str, Any]) -> None:
if hist := self._histogram:
hist.set_log_base(10 if change["new"] else None)

def _on_reset_histogram_clicked(self, change: dict[str, Any]) -> None:
self._log.value = False
if hist := self._histogram:
hist.set_range()

# ------------------ receive changes from the controller ---------------

Expand All @@ -155,6 +210,19 @@
with notifications_blocked(self._clims):
self._clims.value = clims

def set_clim_bounds(
self,
bounds: tuple[float | None, float | None] = (None, None),
) -> None:
mi = 0 if bounds[0] is None else int(bounds[0])
ma = 65535 if bounds[1] is None else int(bounds[1])
# block self._clims.observe, otherwise autoscale will be forced off
with notifications_blocked(self._clims):
self._clims.min = mi
self._clims.max = ma
current_value = self._clims.value
self._clims.value = (max(current_value[0], mi), min(current_value[1], ma))

def set_channel_visible(self, visible: bool) -> None:
self._visible.value = visible

Expand All @@ -171,6 +239,19 @@
def frontend_widget(self) -> Any:
return self.layout

# ------------------ private methods ---------------

def add_histogram(self, histogram: HistogramCanvas) -> None:
widget = histogram.frontend_widget()
# Resize widget to a respectable size
widget.set_trait("css_height", "auto")
# Add widget to view
self._histogram_container.children = (
*self._histogram_container.children,
widget,
)
self._histogram = histogram


class JupyterRGBView(JupyterLutView):
def __init__(self, channel: ChannelKey = None) -> None:
Expand Down Expand Up @@ -383,16 +464,9 @@
"""Emit signal when the channel mode changes."""
self.channelModeChanged.emit(ChannelMode(change["new"]))

def add_histogram(self, channel: ChannelKey, widget: Any) -> None:
def add_histogram(self, channel: ChannelKey, histogram: HistogramCanvas) -> None:
if lut := self._luts.get(channel, None):
# Resize widget to a respectable size
widget.set_trait("css_height", "100px")
# Add widget to view
lut._histogram_container.children = (
*lut._histogram_container.children,
widget,
)
lut._histogram_wdg = widget
lut.add_histogram(histogram)

Check warning on line 469 in src/ndv/views/_jupyter/_array_view.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/views/_jupyter/_array_view.py#L469

Added line #L469 was not covered by tests

def _on_add_roi_button_toggle(self, change: dict[str, Any]) -> None:
"""Emit signal when the channel mode changes."""
Expand Down Expand Up @@ -459,7 +533,7 @@
elif sig_name == "show_histogram_button":
# Note that "block" displays the icon better than "flex"
for lut in self._luts.values():
lut._histogram.layout.display = "block" if value else "none"
lut._histogram_btn.layout.display = "block" if value else "none"
elif sig_name == "show_roi_button":
self._add_roi_btn.layout.display = "flex" if value else "none"
elif sig_name == "show_channel_mode_selector":
Expand Down
Loading
Loading