diff --git a/examples/scene/face_picking.py b/examples/scene/face_picking.py new file mode 100644 index 0000000000..953320c33b --- /dev/null +++ b/examples/scene/face_picking.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# vispy: gallery 30 +# ----------------------------------------------------------------------------- +# Copyright (c) Vispy Development Team. All Rights Reserved. +# Distributed under the (new) BSD License. See LICENSE.txt for more info. +# ----------------------------------------------------------------------------- +""" +Picking Faces from a Mesh +========================= + +Demonstrates how to identify (pick) individual faces on a mesh. + +Arguments: +* --mesh - Path to a mesh file (OBJ/OBJ.GZ) [optional] + +Controls: +* p - Toggle face picking view - shows the colors encoding face ID +* r - Clear painted faces +* s - Cycle shading modes (None, 'flat', 'smooth') +* w - Toggle wireframe +""" +import argparse +import itertools +import time + +import numpy as np + +from vispy import app, scene +from vispy.io import read_mesh, load_data_file +from vispy.scene.visuals import Mesh +from vispy.scene import transforms +from vispy.visuals.filters import ShadingFilter, WireframeFilter, FacePickingFilter + + +parser = argparse.ArgumentParser() +default_mesh = load_data_file('orig/triceratops.obj.gz') +parser.add_argument('--mesh', default=default_mesh) +args, _ = parser.parse_known_args() + +vertices, faces, _normals, _texcoords = read_mesh(args.mesh) + +canvas = scene.SceneCanvas(keys='interactive', bgcolor='white') +view = canvas.central_widget.add_view() + +view.camera = 'arcball' +view.camera.depth_value = 1e3 + +# Create a colored `MeshVisual`. +face_colors = np.tile((0.5, 0.0, 0.5, 1.0), (len(faces), 1)) +mesh = Mesh( + vertices, + faces, + face_colors=face_colors.copy() +) +mesh.transform = transforms.MatrixTransform() +mesh.transform.rotate(90, (1, 0, 0)) +mesh.transform.rotate(-45, (0, 0, 1)) +view.add(mesh) + +# Use filters to affect the rendering of the mesh. +wireframe_filter = WireframeFilter() +shading_filter = ShadingFilter() +face_picking_filter = FacePickingFilter() +mesh.attach(wireframe_filter) +mesh.attach(shading_filter) +mesh.attach(face_picking_filter) + + +def attach_headlight(view): + light_dir = (0, 1, 0, 0) + shading_filter.light_dir = light_dir[:3] + initial_light_dir = view.camera.transform.imap(light_dir) + + @view.scene.transform.changed.connect + def on_transform_change(event): + transform = view.camera.transform + shading_filter.light_dir = transform.map(initial_light_dir)[:3] + + +attach_headlight(view) + +shading = itertools.cycle(("flat", "smooth", None)) +shading_filter.shading = next(shading) + + +throttle = time.monotonic() + + +@canvas.events.mouse_move.connect +def on_mouse_move(event): + global throttle + # throttle mouse events to 50ms + if time.monotonic() - throttle < 0.05: + return + throttle = time.monotonic() + + # adjust the event position for hidpi screens + render_size = tuple(d * canvas.pixel_scale for d in canvas.size) + x_pos = event.pos[0] * canvas.pixel_scale + y_pos = render_size[1] - (event.pos[1] * canvas.pixel_scale) + + # render a small patch around the mouse cursor + restore_state = not face_picking_filter.enabled + face_picking_filter.enabled = True + mesh.update_gl_state(blend=False) + picking_render = canvas.render( + region=(x_pos - 1, y_pos - 1, 3, 3), + size=(3, 3), + bgcolor=(0, 0, 0, 0), + alpha=True, + ) + if restore_state: + face_picking_filter.enabled = False + mesh.update_gl_state(blend=not face_picking_filter.enabled) + + # unpack the face index from the color in the center pixel + face_idx = (picking_render.view(np.uint32) - 1)[1, 1, 0] + + if face_idx > 0 and face_idx < len(face_colors): + # this may be less safe, but it's faster than set_data + mesh.mesh_data._face_colors_indexed_by_faces[face_idx] = (0, 1, 0, 1) + mesh.mesh_data_changed() + + +@canvas.events.key_press.connect +def on_key_press(event): + if event.key == 'p': + face_picking_filter.enabled = not face_picking_filter.enabled + mesh.update_gl_state(blend=not face_picking_filter.enabled) + mesh.update() + + if event.key == 'r': + mesh.set_data(vertices, faces, face_colors=face_colors) + + if event.key == 's': + shading_filter.shading = next(shading) + mesh.update() + + if event.key == 'w': + wireframe_filter.enabled = not wireframe_filter.enabled + mesh.update() + + +canvas.show() + + +if __name__ == "__main__": + print(__doc__) + app.run() diff --git a/examples/scene/marker_picking.py b/examples/scene/marker_picking.py new file mode 100644 index 0000000000..6a2a320069 --- /dev/null +++ b/examples/scene/marker_picking.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +# vispy: gallery 30 +# ----------------------------------------------------------------------------- +# Copyright (c) Vispy Development Team. All Rights Reserved. +# Distributed under the (new) BSD License. See LICENSE.txt for more info. +# ----------------------------------------------------------------------------- +""" +Picking Markers +=============== + +Demonstrates how to identify (pick) markers. Hover markers to change their +symbol and color. + +Controls: +* p - Toggle picking view - shows the colors encoding marker ID +* r - Reset marker symbols and colors +""" +import random +import time +import numpy as np +from scipy.constants import golden as GOLDEN + +from vispy import app, scene +from vispy.scene.visuals import Markers +from vispy.visuals.filters import MarkerPickingFilter + +canvas = scene.SceneCanvas(keys='interactive', bgcolor='black') +view = canvas.central_widget.add_view(camera="panzoom") +view.camera.rect = (-1, -1, 2, 2) + +# floret pattern +n = 10_000 +radius = np.linspace(0, 0.9, n)**0.6 # prevent extreme density at center +theta = np.arange(n) * GOLDEN +pos = np.column_stack([radius * np.cos(theta), radius * np.sin(theta)]) + +COLORS = [ + (1, 0, 0, 1), # red + (1, 0.5, 0, 1), # orange + (1, 1, 0, 1), # yellow + (0, 1, 0, 1), # green + (0, 0, 1, 1), # blue + (0.29, 0, 0.51, 1), # indigo + (0.93, 0.51, 0.93, 1), # violet +] + +colors = np.zeros((n, 4), dtype=np.float32) +colors[:, 0] = 1 # red +colors[:, -1] = 1 # alpha +_colors = colors.copy() + +symbols = list(Markers._symbol_shader_values.keys()) +symbols_ring = dict(zip(symbols, symbols[1:])) +symbols_ring[symbols[-1]] = symbols[0] + +EDGE_COLOR = "white" +MARKER_SIZE = 0.0125 +EDGE_WDITH = MARKER_SIZE / 10 + +markers = Markers( + pos=pos, + edge_color=EDGE_COLOR, + face_color=colors, + size=MARKER_SIZE, + edge_width=EDGE_WDITH, + scaling="scene", +) +markers.update_gl_state(depth_test=True) +view.add(markers) + +# Use filters to affect the rendering of the mesh. +picking_filter = MarkerPickingFilter() +markers.attach(picking_filter) + + +@view.events.connect +def on_viewbox_change(event): + # workaround for vispy/#2501 + markers.update_gl_state(blend=not picking_filter.enabled) + + +throttle = time.monotonic() + + +@canvas.events.mouse_move.connect +def on_mouse_move(event): + global throttle + # throttle mouse events to 50ms + if time.monotonic() - throttle < 0.05: + return + throttle = time.monotonic() + + # adjust the event position for hidpi screens + render_size = tuple(d * canvas.pixel_scale for d in canvas.size) + x_pos = event.pos[0] * canvas.pixel_scale + y_pos = render_size[1] - (event.pos[1] * canvas.pixel_scale) + + # render a small patch around the mouse cursor + restore_state = not picking_filter.enabled + picking_filter.enabled = True + markers.update_gl_state(blend=False) + picking_render = canvas.render( + crop=(x_pos - 2, y_pos - 2, 5, 5), + bgcolor=(0, 0, 0, 0), + alpha=True, + ) + if restore_state: + picking_filter.enabled = False + markers.update_gl_state(blend=not picking_filter.enabled) + + # unpack the face index from the color in the center pixel + marker_idx = (picking_render.view(np.uint32) - 1)[2, 2, 0] + + if marker_idx >= 0 and marker_idx < len(pos): + new_symbols = list(markers.symbol) + new_symbol = symbols_ring[new_symbols[marker_idx]] + new_symbols[marker_idx] = new_symbol + + colors[marker_idx] = random.choice(COLORS) + markers.set_data( + pos=pos, + edge_color=EDGE_COLOR, + face_color=colors, + size=MARKER_SIZE, + edge_width=EDGE_WDITH, + symbol=new_symbols, + ) + + +@canvas.events.key_press.connect +def on_key_press(event): + global colors + if event.key == 'p': + # toggle face picking view + picking_filter.enabled = not picking_filter.enabled + markers.update_gl_state(blend=not picking_filter.enabled) + markers.update() + if event.key == 'r': + # reset marker symbols + colors = _colors.copy() + markers.set_data( + pos=pos, + edge_color=EDGE_COLOR, + face_color=colors, + size=MARKER_SIZE, + edge_width=EDGE_WDITH, + ) + + +canvas.show() + + +if __name__ == "__main__": + print(__doc__) + app.run() diff --git a/vispy/visuals/filters/__init__.py b/vispy/visuals/filters/__init__.py index a22f5e62f5..b283832038 100644 --- a/vispy/visuals/filters/__init__.py +++ b/vispy/visuals/filters/__init__.py @@ -2,8 +2,9 @@ # Copyright (c) Vispy Development Team. All Rights Reserved. # Distributed under the (new) BSD License. See LICENSE.txt for more info. -from .base_filter import Filter # noqa +from .base_filter import Filter, PrimitivePickingFilter # noqa from .clipper import Clipper # noqa from .color import Alpha, ColorFilter, IsolineFilter, ZColormapFilter # noqa from .picking import PickingFilter # noqa -from .mesh import TextureFilter, ShadingFilter, InstancedShadingFilter, WireframeFilter # noqa +from .markers import MarkerPickingFilter # noqa +from .mesh import TextureFilter, ShadingFilter, InstancedShadingFilter, WireframeFilter, FacePickingFilter # noqa diff --git a/vispy/visuals/filters/base_filter.py b/vispy/visuals/filters/base_filter.py index 333d39c0dd..d0cf38277e 100644 --- a/vispy/visuals/filters/base_filter.py +++ b/vispy/visuals/filters/base_filter.py @@ -2,6 +2,11 @@ # Copyright (c) Vispy Development Team. All Rights Reserved. # Distributed under the (new) BSD License. See LICENSE.txt for more info. +from abc import ABCMeta, abstractmethod + +import numpy as np + +from vispy.gloo import VertexBuffer from ..shaders import Function @@ -120,3 +125,118 @@ def _detach(self, visual): self._attached = False self._visual = None + + +class PrimitivePickingFilter(Filter, metaclass=ABCMeta): + """Abstract base class for Visual-specific filters to implement a + primitive-picking mode. + + Subclasses must (and usually only need to) implement + :py:meth:`_get_picking_ids`. + """ + + def __init__(self, fpos=9, *, discard_transparent=False): + # fpos is set to 9 by default to put it near the end, but before the + # default PickingFilter + vfunc = Function("""\ + varying vec4 v_marker_picking_color; + void prepare_marker_picking() { + v_marker_picking_color = $ids; + } + """) + ffunc = Function("""\ + varying vec4 v_marker_picking_color; + void marker_picking_filter() { + if ( $enabled != 1 ) { + return; + } + if ( $discard_transparent == 1 && gl_FragColor.a == 0.0 ) { + discard; + } + gl_FragColor = v_marker_picking_color; + } + """) + + self._id_colors = VertexBuffer(np.zeros((0, 4), dtype=np.float32)) + vfunc['ids'] = self._id_colors + self._n_primitives = 0 + super().__init__(vcode=vfunc, fcode=ffunc, fpos=fpos) + self.enabled = False + self.discard_transparent = discard_transparent + + @abstractmethod + def _get_picking_ids(self): + """Return a 1D array of picking IDs for the vertices in the visual. + + Generally, this method should be implemented to: + 1. Calculate the number of primitives in the visual (may be + persisted in `self._n_primitives`). + 2. Calculate a range of picking ids for each primitive in the + visual. IDs should start from 1, reserving 0 for the background. If + primitives comprise multiple vertices (triangles), ids may need to + be repeated. + + The return value should be an array of uint32 with shape + (num_vertices,). + + If no change to the picking IDs is needed (for example, the number of + primitives has not changed), this method should return `None`. + """ + raise NotImplementedError(self) + + def _update_id_colors(self): + """Calculate the colors encoding the picking IDs for the visual. + + For performance, this method will not update the id colors VertexBuffer + if :py:meth:`_get_picking_ids` returns `None`. + """ + # this should remain untouched + ids = self._get_picking_ids() + if ids is not None: + id_colors = self._pack_ids_into_rgba(ids) + self._id_colors.set_data(id_colors) + + @staticmethod + def _pack_ids_into_rgba(ids): + """Pack an array of uint32 primitive ids into float32 RGBA colors.""" + if ids.dtype != np.uint32: + raise ValueError(f"ids must be uint32, got {ids.dtype}") + + return np.divide( + ids.view(np.uint8).reshape(-1, 4), + 255, + dtype=np.float32 + ) + + def _on_data_updated(self, event=None): + if not self.attached: + return + self._update_id_colors() + + @property + def enabled(self): + return self._enabled + + @enabled.setter + def enabled(self, e): + self._enabled = bool(e) + self.fshader['enabled'] = int(self._enabled) + self._on_data_updated() + + @property + def discard_transparent(self): + return self._discard_transparent + + @discard_transparent.setter + def discard_transparent(self, d): + self._discard_transparent = bool(d) + self.fshader['discard_transparent'] = int(self._discard_transparent) + + def _attach(self, visual): + super()._attach(visual) + visual.events.data_updated.connect(self._on_data_updated) + self._on_data_updated() + + def _detach(self, visual): + visual.events.data_updated.disconnect(self._on_data_updated) + super()._detach(visual) diff --git a/vispy/visuals/filters/markers.py b/vispy/visuals/filters/markers.py new file mode 100644 index 0000000000..34b8b53824 --- /dev/null +++ b/vispy/visuals/filters/markers.py @@ -0,0 +1,28 @@ +import numpy as np + +from vispy.visuals.filters import PrimitivePickingFilter + + +class MarkerPickingFilter(PrimitivePickingFilter): + """Filter used to color markers by a picking ID. + + Note that the ID color uses the alpha channel, so this may not be used + with blending enabled. + + Examples + -------- + :ref:`sphx_glr_gallery_scene_marker_picking.py` + """ + + def _get_picking_ids(self): + if self._visual._data is None: + n_markers = 0 + else: + n_markers = len(self._visual._data['a_position']) + + # we only care about the number of markers changing + if self._n_primitives == n_markers: + return + self._n_primitives = n_markers + + return np.arange(1, n_markers + 1, dtype=np.uint32) diff --git a/vispy/visuals/filters/mesh.py b/vispy/visuals/filters/mesh.py index 5fa13d88b4..711d09e54e 100644 --- a/vispy/visuals/filters/mesh.py +++ b/vispy/visuals/filters/mesh.py @@ -7,7 +7,7 @@ from vispy.gloo import Texture2D, VertexBuffer from vispy.visuals.shaders import Function, Varying -from vispy.visuals.filters import Filter +from vispy.visuals.filters import Filter, PrimitivePickingFilter from ...color import Color @@ -756,13 +756,40 @@ def _update_data(self): bc = np.tile(bc[None, ...], (n_faces, 1, 1)) self._bc.set_data(bc, convert=True) - def on_mesh_data_updated(self, event): + def on_data_updated(self, event): self._update_data() def _attach(self, visual): super()._attach(visual) - visual.events.data_updated.connect(self.on_mesh_data_updated) + visual.events.data_updated.connect(self.on_data_updated) def _detach(self, visual): - visual.events.data_updated.disconnect(self.on_mesh_data_updated) + visual.events.data_updated.disconnect(self.on_data_updated) super()._detach(visual) + + +class FacePickingFilter(PrimitivePickingFilter): + """Filter used to color mesh faces by a picking ID. + + Note that the ID color uses the alpha channel, so this may not be used + with blending enabled. + + Examples + -------- + :ref:`sphx_glr_gallery_scene_face_picking.py` + """ + + def _get_picking_ids(self): + if self._visual.mesh_data.is_empty(): + n_faces = 0 + else: + n_faces = len(self._visual.mesh_data.get_faces()) + + # we only care about the number of faces changing + if self._n_primitives == n_faces: + return None + self._n_primitives = n_faces + + ids = np.arange(1, n_faces + 1, dtype=np.uint32) + ids = np.repeat(ids, 3, axis=0) # repeat id for each vertex + return ids diff --git a/vispy/visuals/filters/tests/test_primitive_picking_filters.py b/vispy/visuals/filters/tests/test_primitive_picking_filters.py new file mode 100644 index 0000000000..36e64bb54d --- /dev/null +++ b/vispy/visuals/filters/tests/test_primitive_picking_filters.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Vispy Development Team. All Rights Reserved. +# Distributed under the (new) BSD License. See LICENSE.txt for more info. +import numpy as np + +from vispy.geometry import create_plane +from vispy.scene.visuals import Markers, Mesh +from vispy.testing import requires_application, TestingCanvas +from vispy.visuals.filters import FacePickingFilter, MarkerPickingFilter + + +def test_empty_mesh_face_picking(): + mesh = Mesh() + filter = FacePickingFilter() + mesh.attach(filter) + filter.enabled = True + + +@requires_application() +def test_mesh_face_picking(): + vertices, faces, _ = create_plane(125, 125) + vertices = vertices["position"] + vertices[:, :2] += 125 / 2 + mesh = Mesh(vertices=vertices, faces=faces) + filter = FacePickingFilter() + mesh.attach(filter) + + with TestingCanvas(size=(125, 125)) as c: + view = c.central_widget.add_view() + view.add(mesh) + filter.enabled = True + mesh.update_gl_state(blend=False) + picking_render = c.render(bgcolor=(0, 0, 0, 0), alpha=True) + + # unpack the IDs + ids = picking_render.view(np.uint32) + # the plane is made up of two triangles and nearly fills the view + # pick one point on each triangle + assert ids[125 // 2, 125 // 4] == 1 + assert ids[125 // 2, 3 * 125 // 4] == 2 + + +def test_empty_markers_picking(): + markers = Markers() + filter = MarkerPickingFilter() + markers.attach(filter) + filter.enabled = True + + +@requires_application() +def test_markers_picking(): + markers = Markers( + pos=np.array([[-0.5, -0.5], [0.5, 0.5]]), + size=5, + ) + filter = MarkerPickingFilter() + markers.attach(filter) + + with TestingCanvas(size=(125, 125)) as c: + view = c.central_widget.add_view(camera="panzoom") + view.camera.rect = (-1, -1, 2, 2) + view.add(markers) + + filter.enabled = True + markers.update_gl_state(blend=False) + picking_render = c.render(bgcolor=(0, 0, 0, 0), alpha=True) + ids = picking_render.view(np.uint32) + + assert ids[3 * 125 // 4, 125 // 4] == 1 + assert ids[125 // 4, 3 * 125 // 4] == 2 diff --git a/vispy/visuals/markers.py b/vispy/visuals/markers.py index fab069232b..c3f80a1592 100644 --- a/vispy/visuals/markers.py +++ b/vispy/visuals/markers.py @@ -11,6 +11,7 @@ from ..gloo import VertexBuffer from .shaders import Function, Variable from .visual import Visual +from ..util.event import Event _VERTEX_SHADER = """ @@ -573,6 +574,8 @@ def __init__(self, scaling="fixed", alpha=1, antialias=1, spherical=False, blend_func=('src_alpha', 'one_minus_src_alpha')) self._draw_mode = 'points' + self.events.add(data_updated=Event) + if len(kwargs) > 0: self.set_data(**kwargs) @@ -662,6 +665,7 @@ def set_data(self, pos=None, size=10., edge_width=1., edge_width_rel=None, self._vbo.set_data(data) self.shared_program.bind(self._vbo) + self.events.data_updated() self.update() @property