Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Let event selection dialogs show event labels #302

Merged
merged 11 commits into from
Mar 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- Add support for loading data from .MAT files ([#314](https://github.com/cbrnr/mnelab/pull/314) by [Clemens Brunner](https://github.com/cbrnr))
- Add support for reading multiple XDF streams (via resampling) ([#312](https://github.com/cbrnr/mnelab/pull/312) by [Florian Hofer](https://github.com/hofaflo))
- Add app icon ([#319](https://github.com/cbrnr/mnelab/pull/319) by [Clemens Brunner](https://github.com/cbrnr))
- Add dialog to modify mapping between event IDs and labels (Edit - Events...) ([#302](https://github.com/cbrnr/mnelab/pull/302) by [Florian Hofer](https://github.com/hofaflo) and [Clemens Brunner](https://github.com/cbrnr))

### Changed
- Simplify rereferencing workflow ([#258](https://github.com/cbrnr/mnelab/pull/258) by [Florian Hofer](https://github.com/hofaflo))
Expand All @@ -27,6 +28,7 @@
- Stop requiring existing annotations or events to enable editing them ([#283](https://github.com/cbrnr/mnelab/pull/283) by [Florian Hofer](https://github.com/hofaflo))
- Replace "(channels dropped)" suffix with "(channels picked)" and use `pick_channels` instead of `drop_channels` ([#285](https://github.com/cbrnr/mnelab/pull/285) by [Florian Hofer](https://github.com/hofaflo))
- The overwrite confirmation dialog is now fail-safe because it defaults to creating a new dataset ([#304](https://github.com/cbrnr/mnelab/pull/304) by [Clemens Brunner](https://github.com/cbrnr))
- Dialogs where events can be selected now show both the integer IDs and the event labels ([#302](https://github.com/cbrnr/mnelab/pull/302) by [Florian Hofer](https://github.com/hofaflo))

### Fixed
- Fix splitting name and extension for compatibility with Python 3.8 ([#252](https://github.com/cbrnr/mnelab/pull/252) by [Johan Medrano](https://github.com/yop0))
Expand Down
1 change: 1 addition & 0 deletions mnelab/dialogs/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def __init__(self, parent, onset, duration, description):
self.remove_button.clicked.connect(self.toggle_buttons)
self.add_button.clicked.connect(self.toggle_buttons)
self.toggle_buttons()
self.setMinimumSize(500, 500)
self.resize(500, 500)

@Slot()
Expand Down
3 changes: 1 addition & 2 deletions mnelab/dialogs/epoch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
#
# License: BSD (3-clause)

from numpy import unique
from PySide6.QtCore import Qt, Slot
from PySide6.QtWidgets import (
QCheckBox,
Expand All @@ -26,7 +25,7 @@ def __init__(self, parent, events):
grid.addWidget(label, 0, 0, 1, 1)

self.events = QListWidget()
self.events.insertItems(0, unique(events[:, 2]).astype(str))
self.events.insertItems(0, events)
self.events.setSelectionMode(QListWidget.ExtendedSelection)
grid.addWidget(self.events, 0, 1, 1, 2)

Expand Down
133 changes: 106 additions & 27 deletions mnelab/dialogs/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
#
# License: BSD (3-clause)

from collections import defaultdict

from PySide6.QtCore import Qt, Slot
from PySide6.QtWidgets import (
QAbstractItemView,
Expand All @@ -10,84 +12,161 @@
QHBoxLayout,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
)

from .utils import IntTableWidgetItem


class EventsDialog(QDialog):
def __init__(self, parent, pos, desc):
def __init__(self, parent, pos, desc, event_mapping):
super().__init__(parent)
self.setWindowTitle("Edit Events")

self.table = QTableWidget(len(pos), 2)
self.event_table = QTableWidget(len(pos), 2)

for row, (p, d) in enumerate(zip(pos, desc)):
self.table.setItem(row, 0, IntTableWidgetItem(p))
self.table.setItem(row, 1, IntTableWidgetItem(d))
self.event_table.setItem(row, 0, IntTableWidgetItem(p))
self.event_table.setItem(row, 1, IntTableWidgetItem(d))

self.event_table.setHorizontalHeaderLabels(["Position", "Type"])
self.event_table.horizontalHeader().setStretchLastSection(True)
self.event_table.verticalHeader().setVisible(False)
self.event_table.setShowGrid(False)
self.event_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.event_table.setSortingEnabled(True)
self.event_table.sortByColumn(0, Qt.AscendingOrder)

self.table.setHorizontalHeaderLabels(["Position", "Type"])
self.table.horizontalHeader().setStretchLastSection(True)
self.table.verticalHeader().setVisible(False)
self.table.setShowGrid(False)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSortingEnabled(True)
self.table.sortByColumn(0, Qt.AscendingOrder)
self.event_mapping = defaultdict(str, event_mapping) # make copy

vbox = QVBoxLayout(self)
vbox.addWidget(self.table)
vbox.addWidget(self.event_table)
hbox = QHBoxLayout()
self.add_button = QPushButton("+")
self.remove_button = QPushButton("-")
self.mapping_button = QPushButton("Mapping...")
buttonbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
hbox.addWidget(self.add_button)
hbox.addWidget(self.remove_button)
hbox.addWidget(self.mapping_button)
hbox.addStretch()
hbox.addWidget(buttonbox)
vbox.addLayout(hbox)
buttonbox.accepted.connect(self.accept)
buttonbox.rejected.connect(self.reject)
self.table.itemSelectionChanged.connect(self.toggle_buttons)
self.event_table.itemSelectionChanged.connect(self.toggle_buttons)
self.remove_button.clicked.connect(self.remove_event)
self.add_button.clicked.connect(self.add_event)
self.remove_button.clicked.connect(self.toggle_buttons)
self.add_button.clicked.connect(self.toggle_buttons)
self.mapping_button.clicked.connect(self.open_mapping_dialog)
self.toggle_buttons()
self.resize(300, 500)
self.setMinimumSize(500, 500)
self.resize(500, 500)

@Slot()
def open_mapping_dialog(self):
dialog = EventMappingDialog(self)
if dialog.exec():
self.event_mapping = dialog.event_mapping

@Slot()
def toggle_buttons(self):
"""Toggle + and - buttons."""
n_items = len(self.table.selectedItems())
if self.table.rowCount() == 0: # no events available
n_items = len(self.event_table.selectedItems())
if self.event_table.rowCount() == 0: # no events available
self.add_button.setEnabled(True)
self.remove_button.setEnabled(False)
self.mapping_button.setEnabled(False)
elif n_items == 2: # one row (2 items) selected
self.add_button.setEnabled(True)
self.remove_button.setEnabled(True)
self.mapping_button.setEnabled(True)
elif n_items > 2: # more than one row selected
self.add_button.setEnabled(False)
self.remove_button.setEnabled(True)
self.mapping_button.setEnabled(True)
else: # no rows selected
self.add_button.setEnabled(False)
self.remove_button.setEnabled(False)
self.mapping_button.setEnabled(True)

def add_event(self):
if self.table.selectedIndexes():
current_row = self.table.selectedIndexes()[0].row()
pos = int(self.table.item(current_row, 0).data(Qt.DisplayRole))
if self.event_table.selectedIndexes():
current_row = self.event_table.selectedIndexes()[0].row()
pos = int(self.event_table.item(current_row, 0).data(Qt.DisplayRole))
else:
current_row = 0
pos = 0
self.table.setSortingEnabled(False)
self.table.insertRow(current_row)
self.table.setItem(current_row, 0, IntTableWidgetItem(pos))
self.table.setItem(current_row, 1, IntTableWidgetItem(0))
self.table.setSortingEnabled(True)
self.event_table.setSortingEnabled(False)
self.event_table.insertRow(current_row)
self.event_table.setItem(current_row, 0, IntTableWidgetItem(pos))
self.event_table.setItem(current_row, 1, IntTableWidgetItem(0))
self.event_table.setSortingEnabled(True)

def remove_event(self):
rows = {index.row() for index in self.table.selectedIndexes()}
self.table.clearSelection()
rows = {index.row() for index in self.event_table.selectedIndexes()}
self.event_table.clearSelection()
for row in sorted(rows, reverse=True):
self.table.removeRow(row)
del self.event_mapping[self.event_table.item(row, 1).value()]
self.event_table.removeRow(row)


class EventMappingDialog(QDialog):
def __init__(self, parent):
super().__init__(parent)
self.setWindowTitle("Event Mapping")
self.event_table = parent.event_table
self.event_mapping = defaultdict(str, parent.event_mapping) # make copy

self.unique_events = set()
for i in range(self.event_table.rowCount()):
if item := self.event_table.item(i, 1):
self.unique_events.add(int(item.value()))

self.mapping_table = QTableWidget(0, 2)
self.mapping_table.setHorizontalHeaderLabels(["Type", "Label"])
self.mapping_table.horizontalHeader().setStretchLastSection(True)
self.mapping_table.verticalHeader().setVisible(False)
self.mapping_table.setShowGrid(False)
self.mapping_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.fill_mapping_table()
self.clear_button = QPushButton("Clear mapping")

vbox = QVBoxLayout(self)
vbox.addWidget(self.mapping_table)
hbox = QHBoxLayout()
hbox.addWidget(self.clear_button)
hbox.addStretch()
buttonbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
hbox.addWidget(buttonbox)
vbox.addLayout(hbox)
buttonbox.accepted.connect(self.accept)
buttonbox.rejected.connect(self.reject)

self.mapping_table.itemChanged.connect(self.store_mapping)
self.clear_button.clicked.connect(self.clear_mapping)

self.mapping_table.setMinimumHeight(150)

def fill_mapping_table(self):
self.mapping_table.setRowCount(0)
for row, id_ in enumerate(sorted(self.unique_events)):
id_item = IntTableWidgetItem(id_)
id_item.setFlags(id_item.flags() ^ Qt.ItemIsEditable)
self.mapping_table.insertRow(row)
self.mapping_table.setItem(row, 0, id_item)
self.mapping_table.setItem(row, 1, QTableWidgetItem(self.event_mapping[id_]))

def store_mapping(self):
for i in range(self.mapping_table.rowCount()):
event_id = int(self.mapping_table.item(i, 0).value())
if event_id not in self.unique_events:
del self.event_mapping[event_id]
if self.mapping_table.item(i, 1) is not None:
self.event_mapping[event_id] = self.mapping_table.item(i, 1).text()

def clear_mapping(self):
self.event_mapping.clear()
self.fill_mapping_table()
20 changes: 20 additions & 0 deletions mnelab/dialogs/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,25 @@ def setData(self, role, value):
if value >= 0: # event position and type must not be negative
super().setData(role, str(value))

def value(self):
return int(self.data(Qt.DisplayRole))


class FloatTableWidgetItem(QTableWidgetItem):
def __init__(self, value):
super().__init__(str(value))

def __lt__(self, other):
return float(self.data(Qt.EditRole)) < float(other.data(Qt.EditRole))

def setData(self, role, value):
try:
value = float(value)
except ValueError:
return
else:
if value >= 0: # event position and type must not be negative
super().setData(role, str(value))

def value(self):
return float(self.data(Qt.DisplayRole))
4 changes: 2 additions & 2 deletions mnelab/dialogs/xdf_streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
QVBoxLayout,
)

from .utils import IntTableWidgetItem
from .utils import FloatTableWidgetItem, IntTableWidgetItem


class XDFStreamsDialog(QDialog):
Expand All @@ -34,7 +34,7 @@ def __init__(self, parent, rows, fname, selected=None, disabled=None):
self.view.setItem(i, 2, QTableWidgetItem(row[2]))
self.view.setItem(i, 3, IntTableWidgetItem(row[3]))
self.view.setItem(i, 4, QTableWidgetItem(row[4]))
self.view.setItem(i, 5, IntTableWidgetItem(row[5]))
self.view.setItem(i, 5, FloatTableWidgetItem(row[5]))
if i in disabled:
for col in range(6):
self.view.item(i, col).setFlags(Qt.NoItemFlags)
Expand Down
29 changes: 22 additions & 7 deletions mnelab/mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import multiprocessing as mp
import sys
import traceback
from collections import defaultdict
from functools import partial
from pathlib import Path
from sys import version_info
Expand Down Expand Up @@ -709,14 +710,19 @@ def edit_annotations(self):
def edit_events(self):
pos = self.model.current["events"][:, 0].tolist()
desc = self.model.current["events"][:, 2].tolist()
dialog = EventsDialog(self, pos, desc)
dialog = EventsDialog(self, pos, desc, self.model.current["event_mapping"])
if dialog.exec():
rows = dialog.table.rowCount()
rows = dialog.event_table.rowCount()
events = np.zeros((rows, 3), dtype=int)
for i in range(rows):
pos = int(dialog.table.item(i, 0).data(Qt.DisplayRole))
desc = int(dialog.table.item(i, 1).data(Qt.DisplayRole))
pos = int(dialog.event_table.item(i, 0).data(Qt.DisplayRole))
desc = int(dialog.event_table.item(i, 1).data(Qt.DisplayRole))
events[i] = pos, 0, desc
self.model.current["event_mapping"] = dict(dialog.event_mapping)
if self.model.current["dtype"] == "epochs":
event_id_old = self.model.current["data"].event_id
event_id_new = {f"{k} ({v})": k for k, v in dialog.event_mapping.items() if k in event_id_old.values()} # noqa: E501
self.model.current["data"].event_id = event_id_new
self.model.set_events(events)

def crop(self):
Expand Down Expand Up @@ -1038,9 +1044,18 @@ def annotations_from_events(self):

def epoch_data(self):
"""Epoch raw data."""
dialog = EpochDialog(self, self.model.current["events"])
unique_events = np.unique(self.model.current["events"][:, 2]).astype(str)

# Display the events as "ID (label)", e.g. "1 (hand)".
event_id_to_label = defaultdict(str, self.model.current["event_mapping"])
unique_events = [f"{e} ({event_id_to_label[int(e)]})" for e in unique_events]

dialog = EpochDialog(self, unique_events)
if dialog.exec():
events = [int(item.text()) for item in dialog.events.selectedItems()]
# Create a dict {"ID (label)": ID, ...}. This is then passed to `mne.Epochs` as
# `event_id`, so the labels are also shown in plots and plotting dialogs.
selected_events = {item.text(): int(item.text().split(" ")[0]) for item in dialog.events.selectedItems()} # noqa: E501

tmin = dialog.tmin.value()
tmax = dialog.tmax.value()

Expand All @@ -1051,7 +1066,7 @@ def epoch_data(self):

duplicated = self.auto_duplicate()
try:
self.model.epoch_data(events, tmin, tmax, baseline)
self.model.epoch_data(selected_events, tmin, tmax, baseline)
except ValueError as e:
if duplicated: # undo
self.model.remove_data()
Expand Down
1 change: 1 addition & 0 deletions mnelab/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ def load(self, fname, *args, **kwargs):
dtype="raw",
montage=None,
events=np.empty((0, 3), dtype=int),
event_mapping={},
))

@data_changed
Expand Down
2 changes: 1 addition & 1 deletion mnelab/viz.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def plot_erds(tfr_and_masks):
ax.label_outer()
for ax in axes[..., -1].flat:
fig.colorbar(axes.flat[0].images[-1], cax=ax)
fig.suptitle(f"ERDS ({event})")
fig.suptitle(f"ERDS {event}")
figs.append(fig)
return figs

Expand Down