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

ENH: Single channel annotation interaction #255

Merged
merged 29 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6fa53e5
fix bugs
nmarkowitz May 31, 2024
d1b556f
visibility selection toggle update
nmarkowitz Jun 3, 2024
4c97dcc
Merge branch 'main' into ENH-single-channel-annotation
nmarkowitz Jun 5, 2024
52b5a9d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 6, 2024
c425376
create new class for single channel annot
nmarkowitz Jun 6, 2024
f2ba689
Merge branch 'ENH-single-channel-annotation' of https://github.com/nm…
nmarkowitz Jun 6, 2024
30567a4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 6, 2024
64ec5b1
toggle SingleChannelAnnot in viewbox
nmarkowitz Jun 10, 2024
febfc98
add toggle method for single chan annot
nmarkowitz Jun 12, 2024
fb1470b
update annotregion toggle single chan annot
nmarkowitz Jun 13, 2024
94e9663
click toggling correctly
nmarkowitz Jun 17, 2024
b13a246
Merge pull request #1 from nmarkowitz/ENH-single-channel-annotation-c…
nmarkowitz Jun 17, 2024
4473d73
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 17, 2024
4531cb5
Merge branch 'mne-tools:main' into ENH-single-channel-annotation
nmarkowitz Jun 17, 2024
ce625dc
Merge branch 'main' into ENH-single-channel-annotation
nmarkowitz Jun 20, 2024
b03eb30
TST: Ping
larsoner Jun 20, 2024
5713964
TST: Ping
larsoner Jun 20, 2024
7688ca4
update SingleChannelAnnot slide
nmarkowitz Jun 21, 2024
0c72430
Update mne_qt_browser/_pg_figure.py
larsoner Jun 21, 2024
fc1ab80
basic single chan annot tests added
nmarkowitz Jun 24, 2024
735b56b
add interactive single channel annot tests
nmarkowitz Jun 24, 2024
ed93017
Merge branch 'ENH-single-channel-annotation' of https://github.com/nm…
nmarkowitz Jun 24, 2024
2ed88ad
Update test_pg_specific.py
nmarkowitz Jun 24, 2024
1763088
Merge branch 'main' into ENH-single-channel-annotation
larsoner Jun 25, 2024
cb89927
add test for ch specific interaction
nmarkowitz Jun 25, 2024
ebc495d
Merge branch 'mne-tools:main' into ENH-single-channel-annotation
nmarkowitz Jun 25, 2024
0bb8196
update test for ch_spec_annot
nmarkowitz Jun 26, 2024
76921e1
change spec chan annot test
nmarkowitz Jun 27, 2024
c32fd84
hack at unit test
nmarkowitz Jun 27, 2024
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
161 changes: 116 additions & 45 deletions mne_qt_browser/_pg_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
InfLineLabel,
LinearRegionItem,
PlotCurveItem,
PlotDataItem,
PlotItem,
Point,
TextItem,
Expand Down Expand Up @@ -2176,12 +2177,75 @@ def closeEvent(self, event): # noqa: D102
main.close()


class SingleChannelAnnot(FillBetweenItem):
def __init__(self, mne, weakmain, annot, ch_name):
self.weakmain = weakmain
self.mne = mne
self.annot = annot
self.ch_name = ch_name

ypos = np.where(self.mne.ch_names[self.mne.ch_order] == self.ch_name)[0] + 1
self.ypos = ypos + np.array([-0.5, 0.5])

# self.lower = PlotCurveItem()
# self.upper = PlotCurveItem()
self.upper = PlotDataItem()
self.lower = PlotDataItem()

# init
super().__init__(self.lower, self.upper)

# self.setCurves(self.lower, self.upper)

self.update_plot_curves()

color_string = self.mne.annotation_segment_colors[self.annot.description]
self.update_color(color_string)

self.mne.plt.addItem(self, ignoreBounds=True)

self.annot.removeRequested.connect(self.remove)
self.annot.sigRegionChangeFinished.connect(self.update_plot_curves)
self.annot.sigRegionChanged.connect(self.update_plot_curves)
self.annot.sigToggleVisibility.connect(self.update_visible)
self.annot.sigUpdateColor.connect(self.update_color)

def update_plot_curves(self):
"""Update the lower and upper bounds of the region."""
# When using PlotCurveItem
# annot_range = np.array(self.annot.getRegion())
# self.lower.setData(x=annot_range, y=self.ypos[[0, 0]])
# self.upper.setData(x=annot_range, y=self.ypos[[1, 1]])

# When using PlotDataItem
x_min, x_max = self.annot.getRegion()
y_min, y_max = self.ypos
self.upper.setData(x=(x_min, x_max), y=(y_max, y_max))
self.lower.setData(x=(x_min, x_max), y=(y_min, y_min))

def update_visible(self, visible):
"""Update visibility to match the annot."""
self.setVisible(visible)

def update_color(self, color_string=None):
brush = _get_color(color_string, self.mne.dark)
brush.setAlpha(60)
self.setBrush(brush)

def remove(self):
"""Remove this from plot."""
vb = self.mne.viewbox
vb.removeItem(self)


class AnnotRegion(LinearRegionItem):
"""Graphics-Object for Annotations."""

regionChangeFinished = Signal(object)
gotSelected = Signal(object)
removeRequested = Signal(object)
sigToggleVisibility = Signal(bool)
sigUpdateColor = Signal(str)

def __init__(self, mne, description, values, weakmain, ch_names=None):
super().__init__(
Expand All @@ -2206,24 +2270,11 @@ def __init__(self, mne, description, values, weakmain, ch_names=None):
self.sigRegionChanged.connect(self.update_label_pos)

self.update_color(all_channels=(not ch_names))
self.ch_annot_fills = list() # container for FillBetween items

self.single_channel_annots = {}
if ch_names is not None and len(ch_names):
ch_is_in_annot = np.isin(self.mne.ch_names[self.mne.ch_order], ch_names)
yposes = np.nonzero(ch_is_in_annot)[0] + 1
color_string = self.mne.annotation_segment_colors[self.description]
brush = _get_color(color_string, self.mne.dark)
brush.setAlpha(60)
for _ypos in yposes:
logger.debug(
"Adding channel specific rectangle at "
f"position {_ypos} for {description}"
)
ypos = np.array([-0.5, 0.5]) + _ypos
lower = PlotCurveItem(x=np.array(values), y=ypos[[0, 0]])
upper = PlotCurveItem(x=np.array(values), y=ypos[[1, 1]])
fill = FillBetweenItem(lower, upper, brush=brush)
self.ch_annot_fills.append(fill)
self.mne.plt.addItem(fill, ignoreBounds=True)
for ch in ch_names:
self._add_single_channel_annot(ch)

self.mne.plt.addItem(self, ignoreBounds=True)
self.mne.plt.addItem(self.label_item, ignoreBounds=True)
Expand Down Expand Up @@ -2253,27 +2304,26 @@ def _region_changed(self):
with SignalBlocker(self):
self.setRegion((onset, offset))
self.update_label_pos()
# Update the FillBetweenItem shapes for channel specific annotations
self._update_channel_annot_fills(onset, offset)

def _update_channel_annot_fills(self, start, stop):
"""Update the FillBetweenItems for channel specific annotations.
def _add_single_channel_annot(self, ch_name):
self.single_channel_annots[ch_name] = SingleChannelAnnot(
self.mne, self.weakmain, self, ch_name
)

FillBetweenItems are used to highlight channels associated with an annotation.
Start and stop are time in seconds.
"""
for fi, this_fill in enumerate(self.ch_annot_fills):
if fi == 0:
logger.debug(
f"Moving {len(self.ch_annot_fills)} {self.description} "
f"rectangle(s) to {start} - {stop}"
)
# we have to update the upper and lower curves of the FillBetweenItem
_, upper_ypos = this_fill.curves[0].getData()
_, lower_ypos = this_fill.curves[1].getData()
new_xpos = np.array([start, stop])
this_fill.curves[0].setData(new_xpos, upper_ypos)
this_fill.curves[1].setData(new_xpos, lower_ypos)
def _remove_single_channel_annot(self, ch_name):
self.single_channel_annots[ch_name].remove()
self.single_channel_annots.pop(ch_name)

def _toggle_single_channel_annot(self, ch_name):
"""Add or remove single channel annotations."""
region_idx = self.weakmain()._get_onset_idx(self.getRegion()[0])
self.weakmain()._toggle_single_channel_annotation(ch_name, region_idx)
if ch_name not in self.single_channel_annots.keys():
self._add_single_channel_annot(ch_name)
else:
self._remove_single_channel_annot(ch_name)

self.update_color(all_channels=(not list(self.single_channel_annots.keys())))

def update_color(self, all_channels=True):
"""Update color of annotation-region.
Expand Down Expand Up @@ -2310,6 +2360,7 @@ def update_color(self, all_channels=True):
line.setPen(self.line_pen)
line.setHoverPen(self.hover_pen)
self.update()
self.sigUpdateColor.emit(color_string)

def update_description(self, description):
"""Update description of annoation-region."""
Expand All @@ -2321,6 +2372,7 @@ def update_visible(self, visible):
"""Update if annotation-region is visible."""
self.setVisible(visible)
self.label_item.setVisible(visible)
self.sigToggleVisibility.emit(visible)

def remove(self):
"""Remove annotation-region."""
Expand All @@ -2347,7 +2399,20 @@ def select(self, selected):

def mouseClickEvent(self, event):
"""Customize mouse click events."""
if self.mne.annotation_mode:
if self.mne.annotation_mode and (
event.button() == Qt.LeftButton and event.modifiers() & Qt.ShiftModifier
):
scene_pos = self.mapToScene(event.pos())

for t in self.mne.traces:
trace_path = t.shape()
trace_point = t.mapFromScene(scene_pos)
if trace_path.contains(trace_point):
self._toggle_single_channel_annot(t.ch_name)
event.accept()
break

elif self.mne.annotation_mode:
if event.button() == Qt.LeftButton and self.movable:
logger.debug(f"Mouse event in annotation mode for {event.pos()}...")
self.select(True)
Expand Down Expand Up @@ -2760,8 +2825,8 @@ def _start_changed(self):
if start < stop:
self.mne.selected_region.setRegion((start, stop))
# Make channel specific fillBetweens stay in sync with annot region
if sel_region.ch_annot_fills:
sel_region._update_channel_annot_fills(start, stop)
# if len(sel_region.single_channel_annots.keys()) > 0:
# sel_region.single_channel_annots(start, stop)
else:
self.weakmain().message_box(
text="Invalid value!",
Expand All @@ -2777,8 +2842,6 @@ def _stop_changed(self):
start = sel_region.getRegion()[0]
if start < stop:
sel_region.setRegion((start, stop))
# Make channel specific fillBetweens stay in sync with annot region
sel_region._update_channel_annot_fills(start, stop)
else:
self.weakmain().message_box(
text="Invalid value!",
Expand Down Expand Up @@ -4885,6 +4948,7 @@ def _fake_click(
xform="ax",
button=1,
kind="press",
modifier=None,
):
add_points = add_points or list()
# Wait until Window is fully shown.
Expand Down Expand Up @@ -4946,13 +5010,20 @@ def _fake_click(
# always click because most interactivity comes form
# mouseClickEvent from pyqtgraph (just press doesn't suffice
# here).
_mouseClick(widget=widget, pos=point, button=button)
_mouseClick(widget=widget, pos=point, button=button, modifier=modifier)
elif kind == "release":
_mouseRelease(widget=widget, pos=point, button=button)
_mouseRelease(
widget=widget, pos=point, button=button, modifier=modifier
)
elif kind == "motion":
_mouseMove(widget=widget, pos=point, buttons=button)
_mouseMove(widget=widget, pos=point, buttons=button, modifier=modifier)
elif kind == "drag":
_mouseDrag(widget=widget, positions=[point] + add_points, button=button)
_mouseDrag(
widget=widget,
positions=[point] + add_points,
button=button,
modifier=modifier,
)

for exc in exceptions:
raise RuntimeError(
Expand Down
41 changes: 29 additions & 12 deletions mne_qt_browser/tests/test_pg_specific.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import pytest
from mne import Annotations
from numpy.testing import assert_allclose
from pyqtgraph.graphicsItems.FillBetweenItem import FillBetweenItem
from qtpy.QtCore import Qt
from qtpy.QtTest import QTest

from mne_qt_browser._colors import _lab_to_rgb, _rgb_to_lab
Expand Down Expand Up @@ -113,18 +113,16 @@ def test_ch_specific_annot(raw_orig, pg_backend):
annot_dock = fig.mne.fig_annotation

# one FillBetweenItem for each channel in a channel specific annot
fill_betweens = [
item for item in fig.mne.plt.items if isinstance(item, FillBetweenItem)
]
assert len(fill_betweens) == 4 # 4 channels in annots[0].ch_names
annot = fig.mne.regions[0]
assert (
len(annot.single_channel_annots) == 4
) # 4 channels in annots[0].single_channel_annots

# check that a channel specific annot is plotted at the correct ypos
last_fill_between = fill_betweens[-1].curves[0]
# "MEG 0423" should be the 28th channel in the plot.
single_channel_annot = annot.single_channel_annots["MEG 0423"]
# the +1 is needed because ypos indexing of the traces starts at 1, not 0
want_index = fig_ch_names.index(raw_orig.annotations.ch_names[0][-1]) + 1
# The round basically just rounds 27.5 up to 28
got_index = np.round(last_fill_between.yData[0]).astype(int)
got_index = np.mean(single_channel_annot.ypos).astype(int)
assert got_index == want_index # should be 28

fig._fake_keypress("a") # activate annotation mode
Expand All @@ -135,16 +133,35 @@ def test_ch_specific_annot(raw_orig, pg_backend):
# change the stop value of the annotation
annot_dock.stop_bx.setValue(6)
annot_dock.stop_bx.editingFinished.emit()
# does the channel specific rectangle stay in sync with the annot?
# does the single channel annot stay within the annot
assert annot_dock.stop_bx.value() == 6
assert last_fill_between.xData[1] == 6
assert single_channel_annot.lower.xData[1] == 6

# now change the start value of the annotation
annot_dock.start_bx.setValue(4)
annot_dock.start_bx.editingFinished.emit()
# does the channel specific rectangle stay in sync with the annot?
assert annot_dock.start_bx.value() == 4
assert last_fill_between.xData[0] == 4
assert single_channel_annot.lower.xData[0] == 4

# test if shift click an existing annotation removes object
ch_index = np.mean(annot.single_channel_annots["MEG 0133"].ypos).astype(int)
fig._fake_click(
(4 + 2 / 2, ch_index),
xform="data",
button=1,
modifier=Qt.ShiftModifier,
)
assert "MEG 0133" not in annot.single_channel_annots.keys()

# test if shift click on channel adds annotation
Copy link
Member

Choose a reason for hiding this comment

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

@nmarkowitz Failure is related to the changes:

Indeed mne-tools/mne-python#12669 hasn't landed and even once it does the MNE maint/* tests will fail (which I'll update to 1.7 momentarily). The best option here I think would be to take the new tests and split them into a new test function like

def test_ch_specific_annot_interaction

(and maybe rename the existing one ..._annot_display or something). Then you can decorate the new function like:

@pytest.mark.skipif(not hasattr(mne.something.SomeClass, "_toggle_single_channel_annotation"), reason="Needs MNE 1.8+")
def test_...

or similar. Then in this test suite should pass, and once mne-tools/mne-python#12669 lands tests will run on the main version of the CIs and tests should be skipped on the maint/* version.

Make sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think so. I'll keep trying and see how far I can get

fig._fake_click(
(4 + 2 / 2, ch_index),
xform="data",
button=1,
modifier=Qt.ShiftModifier,
)
assert "MEG 0133" in annot.single_channel_annots.keys()

fig.close()

Expand Down
Loading