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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ jobs:
exclude:
# for now, this combo segfaults too much to be worth the hassle of debugging
# when we unpin and update pygfx, reconsider
- gui: pyside
canvas: pygfx
# - gui: pyside
# canvas: pygfx
# wxpython does not build wheels for ubuntu or macos-latest py3.10
- os: ubuntu-latest
gui: wxpython
Expand Down Expand Up @@ -88,7 +88,7 @@ jobs:
sudo apt-get update -y -qq
sudo apt install -y libegl1-mesa-dev libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers
- name: Test
run: uv run --exact --no-dev --extra=${{ matrix.canvas }} --group=${{ matrix.gui }} coverage run -p -m pytest --color=yes -p no:faulthandler
run: uv run --exact --no-dev --extra=${{ matrix.canvas }} --group=${{ matrix.gui }} coverage run -p -m pytest --color=yes

- name: Upload coverage
uses: actions/upload-artifact@v6
Expand Down
12 changes: 4 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,16 @@ jupyter = [
pyqt = ["pyqt6 >=6.7", "qtpy >=2", "superqt[iconify] >=0.7.2"]
pyside = [
# defer to superqt's pyside6 restrictions
"superqt[iconify,pyside6] >=0.7.2,<0.7.5",
# https://github.com/pyapp-kit/ndv/issues/59
"pyside6 ==6.6.3; sys_platform == 'win32'",
"numpy >=1.23,<2; sys_platform == 'win32' and python_version < '3.13'", # needed for pyside6.6
"pyside6 >=6.4",
"pyside6 >=6.6; python_version >= '3.12' and sys_platform == 'win32'",
"pyside6 >=6.7; python_version >= '3.12' and sys_platform != 'win32'",
"superqt[iconify,pyside6] >=0.8.0",
# note: still may need to fix windows https://github.com/pyapp-kit/ndv/issues/59
"pyside6 >=6.7",
"qtpy >=2",
]
wxpython = ["pyconify>=0.2.1", "wxpython >=4.2.2"]

# Supported Canavs backends
vispy = ["vispy>=0.16", "pyopengl >=3.1"]
pygfx = ["pygfx==0.9.0", "rendercanvas ==2.0.1", "wgpu==0.19.3"]
pygfx = ["pygfx>=0.16.0", "rendercanvas>=2.6.1", "wgpu>=0.31.0"]

# ready to go bundles with pygfx
qt = ["ndv[pygfx,pyqt]", "imageio[tifffile] >=2.20"]
Expand Down
18 changes: 7 additions & 11 deletions src/ndv/views/_pygfx/_array_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,9 @@

if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from typing import TypeAlias

from pygfx.materials import ImageBasicMaterial
from pygfx.resources import Texture
from wgpu.gui.jupyter import JupyterWgpuCanvas
from wgpu.gui.qt import QWgpuCanvas
from wgpu.gui.wx import WxWgpuCanvas

WgpuCanvas: TypeAlias = "QWgpuCanvas | JupyterWgpuCanvas | WxWgpuCanvas"


def _is_inside(bounding_box: np.ndarray | None, pos: Sequence[float]) -> bool:
Expand Down Expand Up @@ -387,9 +381,9 @@ def __init__(self, viewer_model: ArrayViewerModel) -> None:
self._disconnect_mouse_events = filter_mouse_events(self._canvas, self)

self._renderer = pygfx.renderers.WgpuRenderer(self._canvas)
self._renderer.blend_mode = "additive"

self._scene = pygfx.Scene()
self._scene.add(pygfx.Background(None, pygfx.BackgroundMaterial("black")))
self._camera: pygfx.Camera | None = None
self._ndim: Literal[2, 3] | None = None

Expand All @@ -398,6 +392,8 @@ def __init__(self, viewer_model: ArrayViewerModel) -> None:
# Maintain a weak reference to the last ROI created.
self._last_roi_created: ReferenceType[PyGFXRectangle] | None = None

self._canvas.add_event_handler(lambda e: self.refresh(), "resize")

def frontend_widget(self) -> Any:
return self._canvas

Expand Down Expand Up @@ -450,8 +446,7 @@ def add_image(self, data: np.ndarray | None = None) -> PyGFXImageHandle:
tex = pygfx.Texture(data, dim=2)
image = pygfx.Image(
pygfx.Geometry(grid=tex),
# depth_test=False for additive-like blending
pygfx.ImageBasicMaterial(depth_test=False),
pygfx.ImageBasicMaterial(depth_test=False, alpha_mode="add"),
)
self._scene.add(image)

Expand All @@ -475,8 +470,9 @@ def add_volume(self, data: np.ndarray | None = None) -> PyGFXImageHandle:
tex = pygfx.Texture(data, dim=3)
vol = pygfx.Volume(
pygfx.Geometry(grid=tex),
# depth_test=False for additive-like blending
pygfx.VolumeRayMaterial(interpolation="nearest", depth_test=False),
pygfx.VolumeRayMaterial(
interpolation="nearest", depth_test=False, alpha_mode="add"
),
)
self._scene.add(vol)

Expand Down
7 changes: 0 additions & 7 deletions src/ndv/views/_pygfx/_histogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,9 @@

if TYPE_CHECKING:
from collections.abc import Sequence
from typing import TypeAlias

import cmap
import numpy.typing as npt
from wgpu.gui.jupyter import JupyterWgpuCanvas
from wgpu.gui.qt import QWgpuCanvas

WgpuCanvas: TypeAlias = "QWgpuCanvas | JupyterWgpuCanvas"

MIN_GAMMA: np.float64 = np.float64(1e-6)

Expand Down Expand Up @@ -126,8 +121,6 @@ def __init__(self, *, vertical: bool = False) -> None:

self._renderer = pygfx.renderers.WgpuRenderer(self._canvas)

self._renderer.blend_mode = "ordered1"

# Note that we split the view up into multiple scenes, each with their
# own camera and renderer.
#
Expand Down
7 changes: 2 additions & 5 deletions src/ndv/views/_pygfx/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,8 @@ def sizeHint(self) -> QSize:

return rendercanvas.jupyter.JupyterRenderCanvas # type: ignore[no-any-return]
if frontend == GuiFrontend.WX:
# ...still not working
# import rendercanvas.wx
# return rendercanvas.wx.WxRenderWidget
from wgpu.gui.wx import WxWgpuCanvas
import rendercanvas.wx

return WxWgpuCanvas # type: ignore[no-any-return]
return rendercanvas.wx.WxRenderCanvas # type: ignore[no-any-return]

raise ValueError(f"Unsupported frontend: {frontend}") # pragma: no cover
4 changes: 4 additions & 0 deletions src/ndv/views/_wx/_array_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,10 @@ def __init__(self, canvas_widget: wx.Window, parent: wx.Window | None = None):
if (parent := canvas_widget.GetParent()) and parent is not self:
canvas_widget.Reparent(self) # Reparent canvas_widget to this frame
if parent:
# Close the rendercanvas wrapper before destroying it so
# rendercanvas removes it from its internal tracking loop.
if hasattr(parent, "close"):
parent.close()
parent.Destroy()
canvas_widget.Show()

Expand Down
15 changes: 12 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,24 @@ def _catch_qt_leaks(request: FixtureRequest, qapp: QApplication) -> Iterator[Non
# if the test failed, don't worry about checking widgets
if request.session.testsfailed - failures_before:
return
allow: list[type] = []
try:
from vispy.app.backends._qt import CanvasBackendDesktop

allow: tuple[type, ...] = (CanvasBackendDesktop,)
allow.append(CanvasBackendDesktop)
except (ImportError, RuntimeError):
allow = ()
pass
try:
# This is a known widget that is not cleaned up properly
# but it's not clear how to fix it
from rendercanvas.qt import QRenderWidget

allow.append(QRenderWidget)
except (ImportError, RuntimeError):
pass

# This is a known widget that is not cleaned up properly
remaining = [w for w in qapp.topLevelWidgets() if not isinstance(w, allow)]
remaining = [w for w in qapp.topLevelWidgets() if not isinstance(w, tuple(allow))]
if len(remaining) > nbefore:
test_node = request.node

Expand Down
6 changes: 6 additions & 0 deletions tests/test_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@ def test_array_viewer_histogram() -> None:
@pytest.mark.usefixtures("any_app")
def test_roi_controller() -> None:
ctrl = ArrayViewer()
ctrl.show()
_app.process_events()
roi = RectangularROIModel()
viewer = ctrl._viewer_model

Expand All @@ -342,6 +344,7 @@ def test_roi_controller() -> None:
(world_pos[0] + 1, world_pos[1] + 1),
)
assert viewer.interaction_mode == InteractionMode.PAN_ZOOM
ctrl._canvas.close()


@no_type_check
Expand All @@ -352,6 +355,8 @@ def test_roi_interaction() -> None:
return

ctrl = ArrayViewer()
ctrl.show()
_app.process_events()
roi = RectangularROIModel()
ctrl.roi = roi
roi_view = ctrl._roi_view
Expand Down Expand Up @@ -428,6 +433,7 @@ def test_roi_interaction() -> None:
(canvas_roi_start[1] + canvas_roi_end[1]) / 2,
)
assert roi_view.get_cursor(mme) == CursorType.ALL_ARROW
ctrl._canvas.close()


@pytest.mark.allow_leaks
Expand Down
12 changes: 12 additions & 0 deletions tests/views/_pygfx/test_histogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@
from ndv.views._pygfx._histogram import PyGFXHistogramCanvas


# ugly hack
def _force_canvas_size(
canvas: PyGFXHistogramCanvas, w: int = 600, h: int = 600
) -> None:
"""Force the rendercanvas to report a valid size (needed before show)."""
rc = canvas._canvas
target = getattr(rc, "_subwidget", rc)
target._size_info.set_physical_size(w, h, 1.0)


@pytest.mark.usefixtures("any_app")
def test_hscroll() -> None:
model = LUTModel(
Expand All @@ -23,6 +33,7 @@ def test_hscroll() -> None:
# gamma=2,
)
histogram = PyGFXHistogramCanvas()
_force_canvas_size(histogram)
histogram.set_range(x=(0, 10), y=(0, 1))
histogram.model = model
left, right = 0, 10
Expand Down Expand Up @@ -85,6 +96,7 @@ def test_interaction() -> None:
# gamma=2,
)
histogram = PyGFXHistogramCanvas()
_force_canvas_size(histogram)
histogram.set_range(x=(0, 10), y=(0, 1))
histogram.model = model
left, right = 0, 10
Expand Down