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
Changes from 19 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
145 changes: 104 additions & 41 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
Loading