Skip to content
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

Tooltip when hovering over atlas images #85

Merged
merged 10 commits into from
Sep 4, 2023
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())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line produces a deprecation warning:

FutureWarning: Public access to Window.qt_viewer is deprecated and will be removed in
v0.5.0. It is considered an "implementation detail" of the napari
application, not part of the napari viewer model. If your use case
requires access to qt_viewer, please open an issue to discuss.!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - I don't think we have a nice way around this if we want a tooltip until napari allows custom layer tooltips. It's something that should be easy to contribute to napari before 0.5.0 and then fix here, but better to get the tool to users earlier, I think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, but is there an ETA for v0.5.0? If it's soon, then we'll have an issue.

Copy link
Member Author

@alessandrofelder alessandrofelder Aug 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from a zulip convo in June, it's not soon, but I could be wrong.

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() == ""