Skip to content

Commit 23b050a

Browse files
gselzertlambert03
andauthored
refactor: Pass LUTModel to LUTView (#87)
* Convert LUTView to Model-View * rename views * Remove set_colormap_without_signal usage in tests * Sync autoscale and clims * ImageHandle.cmap -> ImageHandle.colormap Aligns with LutView * PyGFXImageHandle.set_gamma: Allow gamma=1 * Clean up LutView Use Optional"[LutModel]" over "LutModel | None" for Python 3.9 Remove unused ctor * Comment _channel_controller.py * More cleanups * Add tests for QLutView * Fix qt check * Fix pytest qt skip :) * Add wx to dev extra * Add LutView tests for Jupyter, Wx * Improve wx tests * Move autoscale clims to controller Since the actual value to set here is gathered from the data, not from the user, it shouldn't really be on the view. Also will make things much easier when multiple handles have different data. * Fix _CmapCombo.setCurrentColormap * Test enabling autoscaling * Fixes * Fix tests * use qtbot to manage QLutView * Test controller autoscaling logic * Make test smaller The use of a controller was causing failures in some places. * Ad __eq__ for ClimPolicy impls Will prevent a fair number of signal reemissions. Thanks to @tlambert03 for the suggestion * Clean up jupyter lutview Thanks again @tlambert03 for the suggestion * Clean up qt lutview * LutView: Strengthen method descriptions * vispy: remove fps printout * Cleanup * Align src and test package names * Add note for QLutView.set_clims * Remove breakpoint() * Remove unused ChannelController.clims --------- Co-authored-by: Talley Lambert <[email protected]>
1 parent 1c7658e commit 23b050a

23 files changed

+550
-215
lines changed

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ docs = [
8686
"imageio[tifffile]",
8787
]
8888
dev = [
89-
"ndv[test,vispy,pygfx,pyqt,jupyter]",
89+
"ndv[test,vispy,pygfx,pyqt,jupyter,wxpython]",
9090
"pytest-qt",
9191
"ipython",
9292
"mypy",

src/ndv/controllers/_array_viewer.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -192,10 +192,10 @@ def _add_histogram(self) -> None:
192192
histogram_cls = _app.get_histogram_canvas_class() # will raise if not supported
193193
self._histogram = histogram_cls()
194194
self._view.add_histogram(self._histogram.frontend_widget())
195-
for view in self._lut_controllers.values():
196-
view.add_lut_view(self._histogram)
195+
for ctrl in self._lut_controllers.values():
196+
ctrl.add_lut_view(self._histogram)
197197
# FIXME: hack
198-
if handles := view.handles:
198+
if handles := ctrl.handles:
199199
data = handles[0].data()
200200
counts, edges = _calc_hist_bins(data)
201201
self._histogram.set_data(counts, edges)
@@ -259,7 +259,7 @@ def _fully_synchronize_view(self) -> None:
259259
self._clear_canvas()
260260
self._request_data()
261261
for lut_ctr in self._lut_controllers.values():
262-
lut_ctr._update_view_from_model()
262+
lut_ctr.synchronize()
263263
self._update_hist_domain_for_dtype()
264264

265265
def _on_model_visible_axes_changed(self) -> None:
@@ -427,7 +427,7 @@ def _on_data_response_ready(self, future: Future[DataResponse]) -> None:
427427
lut_views.append(self._histogram)
428428
self._lut_controllers[key] = lut_ctrl = ChannelController(
429429
key=key,
430-
model=model,
430+
lut_model=model,
431431
views=lut_views,
432432
)
433433

src/ndv/controllers/_channel_controller.py

+27-106
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@
33
from contextlib import suppress
44
from typing import TYPE_CHECKING
55

6-
from ndv.models._lut_model import ClimsManual, ClimsMinMax, ClimsType
7-
86
if TYPE_CHECKING:
97
from collections.abc import Iterable, Sequence
108

11-
import cmap
129
import numpy as np
1310

1411
from ndv.models._lut_model import LUTModel
@@ -27,113 +24,33 @@ class ChannelController:
2724
that displays the data, all for a single "channel" extracted from the data.
2825
"""
2926

30-
def __init__(self, key: LutKey, model: LUTModel, views: Sequence[LutView]) -> None:
27+
def __init__(
28+
self, key: LutKey, lut_model: LUTModel, views: Sequence[LutView]
29+
) -> None:
3130
self.key = key
3231
self.lut_views: list[LutView] = []
33-
self.lut_model = model
32+
self.lut_model = lut_model
33+
self.lut_model.events.clims.connect(self._auto_scale)
3434
self.handles: list[ImageHandle] = []
3535

3636
for v in views:
3737
self.add_lut_view(v)
3838

39-
# connect model changes to view callbacks that update the view
40-
self.lut_model.events.cmap.connect(self._on_model_cmap_changed)
41-
self.lut_model.events.clims.connect(self._on_model_clims_changed)
42-
self.lut_model.events.visible.connect(self._on_model_visible_changed)
43-
self.lut_model.events.gamma.connect(self._on_model_gamma_changed)
44-
4539
def add_lut_view(self, view: LutView) -> None:
4640
"""Add a LUT view to the controller."""
41+
view.model = self.lut_model
4742
self.lut_views.append(view)
48-
# connect view changes to controller callbacks that update the model
49-
view.visibilityChanged.connect(self._on_view_lut_visible_changed)
50-
view.autoscaleChanged.connect(self._on_view_lut_autoscale_changed)
51-
view.cmapChanged.connect(self._on_view_lut_cmap_changed)
52-
view.climsChanged.connect(self._on_view_lut_clims_changed)
53-
view.gammaChanged.connect(self._on_view_lut_gamma_changed)
54-
self._update_view_from_model(view)
55-
56-
def _on_model_clims_changed(self, clims: ClimsType) -> None:
57-
"""The contrast limits in the model have changed."""
58-
is_autoscale = not clims.is_manual
59-
for handle in self.handles:
60-
min_max = clims.calc_clims(handle.data())
61-
handle.set_clims(min_max)
62-
for v in self.lut_views:
63-
v.set_clims_without_signal(min_max)
64-
v.set_auto_scale_without_signal(is_autoscale)
65-
66-
def _on_model_gamma_changed(self, gamma: float) -> None:
67-
"""The gamma value in the model has changed."""
68-
for view in self.lut_views:
69-
view.set_gamma_without_signal(gamma)
70-
for handle in self.handles:
71-
handle.set_gamma(gamma)
72-
73-
def _on_model_cmap_changed(self, cmap: cmap.Colormap) -> None:
74-
"""The colormap in the model has changed."""
75-
for view in self.lut_views:
76-
view.set_colormap_without_signal(cmap)
77-
for handle in self.handles:
78-
handle.set_cmap(cmap)
79-
80-
def _on_model_visible_changed(self, visible: bool) -> None:
81-
"""The visibility in the model has changed."""
82-
for view in self.lut_views:
83-
view.set_channel_visible_without_signal(visible)
84-
for handle in self.handles:
85-
handle.set_visible(visible)
86-
87-
def _update_view_from_model(self, *views: LutView) -> None:
88-
"""Make sure the view matches the model."""
43+
# TODO: Could probably reuse cached clims
44+
self._auto_scale()
45+
46+
def synchronize(self, *views: LutView) -> None:
47+
"""Aligns all views against the backing model."""
8948
_views: Iterable[LutView] = views or self.lut_views
49+
name = str(self.key) if self.key is not None else ""
9050
for view in _views:
91-
view.set_colormap_without_signal(self.lut_model.cmap)
92-
if self.lut_model.clims and (clims := self.lut_model.clims.cached_clims):
93-
view.set_clims_without_signal(clims)
94-
95-
is_autoscale = not self.lut_model.clims.is_manual
96-
view.set_auto_scale_without_signal(is_autoscale)
97-
view.set_channel_visible_without_signal(True)
98-
name = str(self.key) if self.key is not None else ""
51+
view.synchronize()
9952
view.set_channel_name(name)
10053

101-
def _on_view_lut_visible_changed(self, visible: bool, key: LutKey = None) -> None:
102-
"""The visibility checkbox in the LUT widget has changed."""
103-
for handle in self.handles:
104-
previous = handle.visible()
105-
handle.set_visible(visible)
106-
if previous != visible:
107-
self._update_clims(handle)
108-
109-
def _on_view_lut_autoscale_changed(
110-
self, autoscale: bool, key: LutKey = None
111-
) -> None:
112-
"""The autoscale checkbox in the LUT widget has changed."""
113-
if autoscale:
114-
self.lut_model.clims = ClimsMinMax()
115-
elif cached := self.lut_model.clims.cached_clims:
116-
self.lut_model.clims = ClimsManual(min=cached[0], max=cached[1])
117-
118-
for view in self.lut_views:
119-
view.set_auto_scale_without_signal(autoscale)
120-
121-
def _on_view_lut_cmap_changed(
122-
self, cmap: cmap.Colormap, key: LutKey = None
123-
) -> None:
124-
"""The colormap in the LUT widget has changed."""
125-
for handle in self.handles:
126-
handle.set_cmap(cmap) # actually apply it to the Image texture
127-
self.lut_model.cmap = cmap # update the model as well
128-
129-
def _on_view_lut_clims_changed(self, clims: tuple[float, float]) -> None:
130-
"""The contrast limits slider in the LUT widget has changed."""
131-
self.lut_model.clims = ClimsManual(min=clims[0], max=clims[1])
132-
133-
def _on_view_lut_gamma_changed(self, gamma: float) -> None:
134-
"""The gamma slider in the LUT widget has changed."""
135-
self.lut_model.gamma = gamma
136-
13754
def update_texture_data(self, data: np.ndarray) -> None:
13855
"""Update the data in the image handle."""
13956
# WIP:
@@ -143,20 +60,12 @@ def update_texture_data(self, data: np.ndarray) -> None:
14360
return
14461
handle = handles[0]
14562
handle.set_data(data)
146-
if handle.visible():
147-
self._update_clims(handle)
63+
self._auto_scale()
14864

14965
def add_handle(self, handle: ImageHandle) -> None:
15066
"""Add an image texture handle to the controller."""
15167
self.handles.append(handle)
152-
handle.set_cmap(self.lut_model.cmap)
153-
self._update_clims(handle)
154-
155-
def _update_clims(self, handle: ImageHandle) -> None:
156-
min_max = self.lut_model.clims.calc_clims(handle.data())
157-
handle.set_clims(min_max)
158-
for view in self.lut_views:
159-
view.set_clims_without_signal(min_max)
68+
self.add_lut_view(handle)
16069

16170
def get_value_at_index(self, idx: tuple[int, ...]) -> float | None:
16271
"""Get the value of the data at the given index."""
@@ -174,3 +83,15 @@ def get_value_at_index(self, idx: tuple[int, ...]) -> float | None:
17483
# the data source directly.
17584
return handle.data()[idx] # type: ignore [no-any-return]
17685
return None
86+
87+
def _auto_scale(self) -> None:
88+
if self.lut_model and len(self.handles):
89+
policy = self.lut_model.clims
90+
handle_clims = [policy.calc_clims(handle.data()) for handle in self.handles]
91+
mi, ma = handle_clims[0]
92+
for clims in handle_clims[1:]:
93+
mi = min(mi, clims[0])
94+
ma = max(ma, clims[1])
95+
96+
for view in self.lut_views:
97+
view.set_clims((mi, ma))

src/ndv/models/_lut_model.py

+24
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ class ClimsManual(ClimPolicy):
6464
def get_limits(self, data: npt.NDArray) -> tuple[float, float]:
6565
return self.min, self.max
6666

67+
def __eq__(self, other: object) -> bool:
68+
return (
69+
isinstance(other, ClimsManual)
70+
and self.min == other.min
71+
and self.max == other.max
72+
)
73+
6774

6875
class ClimsMinMax(ClimPolicy):
6976
"""Autoscale contrast limits based on the minimum and maximum values in the data."""
@@ -73,6 +80,9 @@ class ClimsMinMax(ClimPolicy):
7380
def get_limits(self, data: npt.NDArray) -> tuple[float, float]:
7481
return (np.nanmin(data), np.nanmax(data))
7582

83+
def __eq__(self, other: object) -> bool:
84+
return isinstance(other, ClimsMinMax)
85+
7686

7787
class ClimsPercentile(ClimPolicy):
7888
"""Autoscale contrast limits based on percentiles of the data.
@@ -92,6 +102,13 @@ class ClimsPercentile(ClimPolicy):
92102
def get_limits(self, data: npt.NDArray) -> tuple[float, float]:
93103
return tuple(np.nanpercentile(data, [self.min_percentile, self.max_percentile]))
94104

105+
def __eq__(self, other: object) -> bool:
106+
return (
107+
isinstance(other, ClimsPercentile)
108+
and self.min_percentile == other.min_percentile
109+
and self.max_percentile == other.max_percentile
110+
)
111+
95112

96113
class ClimsStdDev(ClimPolicy):
97114
"""Automatically set contrast limits based on standard deviations from the mean.
@@ -114,6 +131,13 @@ def get_limits(self, data: npt.NDArray) -> tuple[float, float]:
114131
diff = self.n_stdev * np.nanstd(data)
115132
return center - diff, center + diff
116133

134+
def __eq__(self, other: object) -> bool:
135+
return (
136+
isinstance(other, ClimsStdDev)
137+
and self.n_stdev == other.n_stdev
138+
and self.center == other.center
139+
)
140+
117141

118142
# we can add this, but it needs to have a proper pydantic serialization method
119143
# similar to ReducerType

src/ndv/v1/_old_viewer.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -574,11 +574,11 @@ def _update_canvas_data(self, data: np.ndarray, index: Indices) -> None:
574574
)
575575
if datum.ndim == 2:
576576
handle = self._canvas.add_image(datum)
577-
handle.set_cmap(cm)
577+
handle.set_colormap(cm)
578578
handles.append(handle)
579579
elif datum.ndim == 3:
580580
handle = self._canvas.add_volume(datum)
581-
handle.set_cmap(cm)
581+
handle.set_colormap(cm)
582582
handles.append(handle)
583583
if imkey not in self._lut_ctrls:
584584
ch_index = index.get(self._channel_axis, 0)

src/ndv/v1/_qt/_lut_control.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def __init__(
5252
self._cmap = CmapCombo()
5353
self._cmap.currentColormapChanged.connect(self._on_cmap_changed)
5454
for handle in self._handles:
55-
self._cmap.addColormap(handle.cmap())
55+
self._cmap.addColormap(handle.colormap())
5656
for color in cmaplist:
5757
self._cmap.addColormap(color)
5858

@@ -110,7 +110,7 @@ def _on_visible_changed(self, visible: bool) -> None:
110110

111111
def _on_cmap_changed(self, cmap: cmap.Colormap) -> None:
112112
for handle in self._handles:
113-
handle.set_cmap(cmap)
113+
handle.set_colormap(cmap)
114114

115115
def update_autoscale(self) -> None:
116116
if (
@@ -139,5 +139,5 @@ def update_autoscale(self) -> None:
139139

140140
def add_handle(self, handle: ImageHandle) -> None:
141141
self._handles.append(handle)
142-
self._cmap.addColormap(handle.cmap())
142+
self._cmap.addColormap(handle.colormap())
143143
self.update_autoscale()

0 commit comments

Comments
 (0)