Skip to content

Commit

Permalink
Tooltip when hovering over atlas images (#85)
Browse files Browse the repository at this point in the history
* wip:quick and dirty viewer tooltip

* Revert "wip:quick and dirty viewer tooltip"

This reverts commit a4e7c7f.

* wip

* initial draft of clean implementation

* add test for viewer tooltip

* assert callbacks connected

* test keyerror edge case

* tidy

* simplify mouse move callback

tooltip does not need to depend on annotation
improve docstrings

* use QCursor instead of event position
  • Loading branch information
alessandrofelder authored Sep 4, 2023
1 parent 0e30dd4 commit f65836c
Show file tree
Hide file tree
Showing 2 changed files with 160 additions and 5 deletions.
79 changes: 75 additions & 4 deletions brainrender_napari/napari_atlas_representation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import numpy as np
from bg_atlasapi import BrainGlobeAtlas
from meshio import Mesh
from napari.settings import get_settings
from napari.viewer import Viewer
from qtpy.QtCore import Qt
from qtpy.QtGui import QCursor
from qtpy.QtWidgets import QLabel


@dataclass
Expand All @@ -15,23 +19,40 @@ class NapariAtlasRepresentation:
mesh_opacity: float = 0.4
mesh_blending: str = "translucent_no_depth"

def __post_init__(self) -> None:
"""Setup a custom QLabel tooltip and enable napari layer tooltips"""
self._tooltip = QLabel(self.viewer.window.qt_viewer.parent())
self._tooltip.setWindowFlags(
Qt.Window | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
)
self._tooltip.setAttribute(Qt.WA_ShowWithoutActivating)
self._tooltip.setAlignment(Qt.AlignCenter)
self._tooltip.setStyleSheet("color: black")
napari_settings = get_settings()
napari_settings.appearance.layer_tooltip_visibility = True

def add_to_viewer(self):
"""Adds the reference and annotation images to the viewer.
"""Adds the reference and annotation images as layers to the viewer.
The layers are connected to the mouse move callback to set tooltip.
The reference image's visibility is off, the annotation's is on.
"""
self.viewer.add_image(
reference = self.viewer.add_image(
self.bg_atlas.reference,
scale=self.bg_atlas.resolution,
name=f"{self.bg_atlas.atlas_name}_reference",
visible=False,
)
self.viewer.add_labels(

annotation = self.viewer.add_labels(
self.bg_atlas.annotation,
scale=self.bg_atlas.resolution,
name=f"{self.bg_atlas.atlas_name}_annotation",
)

annotation.mouse_move_callbacks.append(self._on_mouse_move)
reference.mouse_move_callbacks.append(self._on_mouse_move)

def add_structure_to_viewer(self, structure_name: str):
"""Adds the mesh of a structure to the viewer
Expand Down Expand Up @@ -67,8 +88,58 @@ def _add_mesh(self, mesh: Mesh, name: str, color=None):
self.viewer.add_surface((points, cells), **viewer_kwargs)

def add_additional_reference(self, additional_reference_key: str):
self.viewer.add_image(
"""Adds a given additional reference as a layer to the viewer.
and connects it to the mouse move callback to set tooltip.
"""
additional_reference = self.viewer.add_image(
self.bg_atlas.additional_references[additional_reference_key],
scale=self.bg_atlas.resolution,
name=f"{self.bg_atlas.atlas_name}_{additional_reference_key}_reference",
)
additional_reference.mouse_move_callbacks.append(self._on_mouse_move)

def _on_mouse_move(self, layer, event):
"""Adapts the tooltip according to the cursor position.
The tooltip is only displayed if
* the viewer is in 2D display
* and the cursor is inside the annotation
* and the user has not switched off layer tooltips.
Note that layer, event input args are unused,
because all the required info is in
* the bg_atlas.structure_from_coords
* the (screen) cursor position
* the (napari) cursor position
"""
cursor_position = self.viewer.cursor.position
napari_settings = get_settings()
tooltip_visibility = (
napari_settings.appearance.layer_tooltip_visibility
)
if (
tooltip_visibility
and np.all(np.array(cursor_position) > 0)
and self.viewer.dims.ndisplay == 2
):
self._tooltip.move(QCursor.pos().x() + 20, QCursor.pos().y() + 20)
try:
structure_acronym = self.bg_atlas.structure_from_coords(
cursor_position, microns=True, as_acronym=True
)
structure_name = self.bg_atlas.structures[structure_acronym][
"name"
]
hemisphere = self.bg_atlas.hemisphere_from_coords(
cursor_position, as_string=True, microns=True
).capitalize()
tooltip_text = f"{structure_name} | {hemisphere}"
self._tooltip.setText(tooltip_text)
self._tooltip.adjustSize()
self._tooltip.show()
except (KeyError, IndexError):
# cursor position outside the image or in the image background
# so no tooltip to be displayed
# this saves us a bunch of assertions and extra computation
self._tooltip.setText("")
self._tooltip.hide()
86 changes: 85 additions & 1 deletion tests/test_unit/test_napari_atlas_representation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from bg_atlasapi import BrainGlobeAtlas
from napari.layers import Image, Labels
from numpy import all, allclose
from qtpy.QtCore import QEvent, QPoint, Qt
from qtpy.QtGui import QMouseEvent

from brainrender_napari.napari_atlas_representation import (
NapariAtlasRepresentation,
Expand Down Expand Up @@ -50,6 +52,13 @@ def test_add_to_viewer(make_napari_viewer, expected_atlas_name, anisotropic):
assert isinstance(annotation, Labels)
assert isinstance(reference, Image)

assert (
atlas_representation._on_mouse_move in annotation.mouse_move_callbacks
)
assert (
atlas_representation._on_mouse_move in reference.mouse_move_callbacks
)

assert allclose(annotation.extent.world, reference.extent.world)


Expand Down Expand Up @@ -118,8 +127,83 @@ def test_add_additional_reference(make_napari_viewer):
atlas_representation = NapariAtlasRepresentation(atlas, viewer)
atlas_representation.add_additional_reference(additional_reference_name)

additional_reference = viewer.layers[0]
assert len(viewer.layers) == 1
assert (
viewer.layers[0].name
additional_reference.name
== f"{atlas_name}_{additional_reference_name}_reference"
)
assert (
atlas_representation._on_mouse_move
in additional_reference.mouse_move_callbacks
)


@pytest.mark.parametrize(
"cursor_position, expected_tooltip_text",
[
((6500.0, 4298.5, 9057.6), "Caudoputamen | Left"),
((-1000, 0, 0), ""), # outside image
],
)
def test_viewer_tooltip(
make_napari_viewer, mocker, cursor_position, expected_tooltip_text
):
"""Checks that the custom callback for mouse movement sets the expected
tooltip text."""
viewer = make_napari_viewer()
atlas_name = "allen_mouse_100um"
atlas = BrainGlobeAtlas(atlas_name=atlas_name)
atlas_representation = NapariAtlasRepresentation(atlas, viewer)
atlas_representation.add_to_viewer()
annotation = viewer.layers[1]

event = QMouseEvent(
QEvent.MouseMove,
QPoint(0, 0), # any pos will do to check text
Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
# a slight hacky mock of event.pos to circumvent
# the napari read-only wrapper around qt events
mock_event = mocker.patch.object(event, "pos", return_value=(50, 50))
viewer.cursor.position = cursor_position
atlas_representation._on_mouse_move(annotation, mock_event)
assert atlas_representation._tooltip.text() == expected_tooltip_text


def test_too_quick_mouse_move_keyerror(make_napari_viewer, mocker):
"""Quickly moving the cursor position can cause
structure_from_coords to be called with a background label.
This test checks that we handle that case gracefully."""
viewer = make_napari_viewer()
atlas_name = "allen_mouse_100um"
atlas = BrainGlobeAtlas(atlas_name=atlas_name)
atlas_representation = NapariAtlasRepresentation(atlas, viewer)
atlas_representation.add_to_viewer()
annotation = viewer.layers[1]

event = QMouseEvent(
QEvent.MouseMove,
QPoint(0, 0), # any pos will do to check text
Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
# a slight hacky mock of event.pos to circumvent
# the napari read-only wrapper around qt events
mock_event = mocker.patch.object(event, "pos", return_value=(0, 0))
viewer.cursor.position = (6500.0, 4298.5, 9057.6)

# Mock the case where a quick mouse move calls structure_from_coords
# with key 0 (background)
mock_structure_from_coords = mocker.patch.object(
atlas_representation.bg_atlas,
"structure_from_coords",
side_effect=KeyError(),
)

atlas_representation._on_mouse_move(annotation, mock_event)
mock_structure_from_coords.assert_called_once()
assert atlas_representation._tooltip.text() == ""

0 comments on commit f65836c

Please sign in to comment.