Skip to content

WIP: feat: Add autoscale tails to Jupyter #179

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

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
138 changes: 132 additions & 6 deletions src/ndv/views/_jupyter/_array_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@

import ipywidgets as widgets
import psygnal
from IPython.display import Javascript, display

from ndv.models._array_display_model import ChannelMode
from ndv.models._lut_model import ClimPolicy, ClimsManual, ClimsMinMax
from ndv.models._lut_model import ClimPolicy, ClimsManual, ClimsPercentile
from ndv.models._viewer_model import ArrayViewerModel, InteractionMode
from ndv.views.bases import ArrayView, LutView

Expand Down Expand Up @@ -42,11 +43,118 @@ def notifications_blocked(
obj._trait_notifiers[name][type] = notifiers


class RightClickButton(widgets.ToggleButton):
"""Custom Button widget that shows a popup on right-click."""

# TODO: These are likely unnecessary
# _right_click_triggered = Bool(False).tag(sync=True)
# popup_content = Unicode("Right-click menu").tag(sync=True)

def __init__(self, channel: ChannelKey, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._channel = channel
self.add_class(f"right-click-button{channel}")
self.add_right_click_handler()

self.lower_tail = widgets.BoundedFloatText(
value=0.0,
min=0.0,
max=100.0,
step=0.1,
description="Ignore Lower Tail:",
style={"description_width": "initial"},
)
self.upper_tail = widgets.BoundedFloatText(
value=0.0,
min=0.0,
max=100.0,
step=0.1,
description="Ignore Upper Tail:",
style={"description_width": "initial"},
)
self.popup_content = widgets.VBox(
[self.lower_tail, self.upper_tail],
layout=widgets.Layout(
display="none",
),
)
self.popup_content.add_class(f"ipywidget-popup{channel}")
display(self.popup_content) # type: ignore [no-untyped-call]

def add_right_click_handler(self) -> None:
# fmt: off
js_code = ( """
(function() {
function setup_rightclick() {
// Get all buttons with the right-click-button class
var button = document.getElementsByClassName(
'right-click-button""" + f"{self._channel}" + """'
)[0];
if (!button) {
return;
}

// For each button, add a contextmenu listener
button.addEventListener('contextmenu', function(e) {
// Prevent default context menu
e.preventDefault();
e.stopPropagation();

// Get button position
var rect = this.getBoundingClientRect();
var scrollLeft = window.pageXOffset ||
document.documentElement.scrollLeft;
var scrollTop = window.pageYOffset ||
document.documentElement.scrollTop;

// Position the popup above the button
var popup = document.getElementsByClassName(
'ipywidget-popup""" + f"{self._channel}" + """'
)[0];
popup.style.display = '';
popup.style.position = 'absolute';
popup.style.top = (rect.bottom + scrollTop) + 'px';
popup.style.left = (rect.left + scrollLeft) + 'px';

// Style the popup
popup.style.background = 'white';
popup.style.border = '1px solid #ccc';
popup.style.borderRadius = '3px';
popup.style.padding = '8px';
popup.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
popup.style.zIndex = '1000';

// Add to body
document.body.appendChild(popup);

// Close popup when clicking elsewhere
document.addEventListener('click', function closePopup(event) {
var popup = document.getElementsByClassName(
'ipywidget-popup""" + f"{self._channel}" + """'
)[0];
if (popup && !popup.contains(event.target)) {
popup.style.display = 'none';
document.removeEventListener('click', closePopup);
}
});

return false;
});
}

// Make sure it works even after widget is redrawn/updated
setTimeout(setup_rightclick, 1000);
})();
""")
# fmt: on
display(Javascript(js_code)) # type: ignore [no-untyped-call]


class JupyterLutView(LutView):
# NB: In practice this will be a ChannelKey but Unions not allowed here.
histogramRequested = psygnal.Signal(object)

def __init__(self, channel: ChannelKey = None) -> None:
def __init__(self, channel: ChannelKey) -> None:
self._channel = channel
self._histogram: HistogramCanvas | None = None
# WIDGETS
Expand Down Expand Up @@ -77,7 +185,8 @@ def __init__(self, channel: ChannelKey = None) -> None:
readout_format=".0f",
)
self._clims.layout.width = "100%"
self._auto_clim = widgets.ToggleButton(
self._auto_clim = RightClickButton(
channel=channel,
value=True,
description="Auto",
button_style="", # 'success', 'info', 'warning', 'danger' or ''
Expand Down Expand Up @@ -151,6 +260,8 @@ def __init__(self, channel: ChannelKey = None) -> None:
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._auto_clim.lower_tail.observe(self._on_auto_tails_changed, names="value")
self._auto_clim.upper_tail.observe(self._on_auto_tails_changed, 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)
Expand All @@ -173,11 +284,20 @@ def _on_cmap_changed(self, change: dict[str, Any]) -> None:
def _on_autoscale_changed(self, change: dict[str, Any]) -> None:
if self._model:
if change["new"]: # Autoscale
self._model.clims = ClimsMinMax()
lower_tail = self._auto_clim.lower_tail.value
upper_tail = self._auto_clim.upper_tail.value
self._model.clims = ClimsPercentile(
min_percentile=lower_tail, max_percentile=100 - upper_tail
)
else: # Manually scale
clims = self._clims.value
self._model.clims = ClimsManual(min=clims[0], max=clims[1])

def _on_auto_tails_changed(self, change: dict[str, Any]) -> None:
# Update clim policy if autoscaling is active
if self._auto_clim.value:
self._on_autoscale_changed({"new": True})

def _on_histogram_requested(self, change: dict[str, Any]) -> None:
# Generate the histogram if we haven't done so yet
if not self._histogram:
Expand All @@ -200,15 +320,21 @@ def set_channel_name(self, name: str) -> None:
self._visible.description = name

def set_clim_policy(self, policy: ClimPolicy) -> None:
self._auto_clim.value = not policy.is_manual
with notifications_blocked(self._auto_clim):
self._auto_clim.value = not policy.is_manual
if isinstance(policy, ClimsPercentile):
self._auto_clim.lower_tail.value = policy.min_percentile
self._auto_clim.upper_tail.value = 100 - policy.max_percentile

def set_colormap(self, cmap: cmap.Colormap) -> None:
self._cmap.value = cmap.name.split(":")[-1] # FIXME: this is a hack

def set_clims(self, clims: tuple[float, float]) -> None:
# block self._clims.observe, otherwise autoscale will be forced off
with notifications_blocked(self._clims):
self._clims.value = clims
# FIXME: Internally the clims are being rounded to whole numbers.
# The rounding is somehow avoiding notifications_blocked.
self._clims.value = [int(c) for c in clims]

def set_clim_bounds(
self,
Expand Down
44 changes: 31 additions & 13 deletions tests/views/_jupyter/test_lut_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from jupyter_rfb.widget import RemoteFrameBuffer
from pytest import fixture

from ndv.models._lut_model import ClimsManual, ClimsMinMax, LUTModel
from ndv.models._lut_model import ClimsManual, ClimsMinMax, ClimsPercentile, LUTModel
from ndv.views._jupyter._array_view import JupyterLutView
from ndv.views.bases._graphics._canvas import HistogramCanvas

Expand All @@ -18,7 +18,7 @@ def model() -> LUTModel:

@fixture
def view(model: LUTModel) -> JupyterLutView:
view = JupyterLutView()
view = JupyterLutView(None)
# Set the model
assert view.model is None
view.model = model
Expand All @@ -29,14 +29,23 @@ def view(model: LUTModel) -> JupyterLutView:
def test_JupyterLutView_update_model(model: LUTModel, view: JupyterLutView) -> None:
"""Ensures the view updates when the model is changed."""

auto_scale = not model.clims.is_manual
assert view._auto_clim.value == auto_scale
model.clims = ClimsManual(min=0, max=1) if auto_scale else ClimsMinMax()
assert view._auto_clim.value != auto_scale

new_visible = not model.visible
model.visible = new_visible
assert view._visible.value == new_visible
# Test modifying model.clims
assert view._auto_clim.value
model.clims = ClimsManual(min=0, max=1)
assert not view._auto_clim.value
model.clims = ClimsPercentile(min_percentile=0, max_percentile=100)
assert view._auto_clim.value
model.clims = ClimsPercentile(min_percentile=1, max_percentile=99)
assert view._auto_clim.value
assert view._auto_clim.lower_tail.value == 1
assert view._auto_clim.upper_tail.value == 1

# Test modifying model.visible
assert view._visible.value
model.visible = False
assert not view._visible.value
model.visible = True
assert view._visible.value

new_cmap = cmap.Colormap("red")
new_name = new_cmap.name.split(":")[-1]
Expand All @@ -57,10 +66,19 @@ def test_JupyterLutView_update_view(model: LUTModel, view: JupyterLutView) -> No
view._cmap.value = new_cmap
assert model.cmap == new_cmap

# Test toggling auto_clim
assert model.clims == ClimsMinMax()
view._auto_clim.value = False
mi, ma = view._clims.value
new_clims = ClimsManual(min=mi, max=ma) if view._auto_clim.value else ClimsMinMax()
view._auto_clim.value = not new_clims.is_manual
assert model.clims == new_clims
assert model.clims == ClimsManual(min=mi, max=ma)
view._auto_clim.value = True
assert model.clims == ClimsPercentile(min_percentile=0, max_percentile=100)

# Test modifying tails changes percentiles
view._auto_clim.lower_tail.value = 0.1
assert model.clims == ClimsPercentile(min_percentile=0.1, max_percentile=100)
view._auto_clim.upper_tail.value = 0.2
assert model.clims == ClimsPercentile(min_percentile=0.1, max_percentile=99.8)

# When gui clims change, autoscale should be disabled
model.clims = ClimsMinMax()
Expand Down
Loading