Skip to content

Commit e2c538b

Browse files
massichcbrnr
authored andcommitted
[MRG] Add support for indexing/slicing Annotations objects (#5800)
* Add support for indexing and slicing Annotations * FIX: Avoid colateral effects * [wip] documents slicing * xx * remove plt close * more on slicning / iter * fix * use dict * clean up * wip: illustrate __iter__ * return a OrderedDict when iterating * fix test to reflect iterator returns dictionary * illustrate the fact that we have no access to Annotations attr. * remove copy * update tutorial * Use slicing that returns a single Annotation * TST: break slicing * fix tuple * update tests * typo * cosmit * this should break if slicing returns a view and not a copy * return copy on slice * nitpick * remove copy * add iterating to whats new * better test * remove the test
1 parent e0588ee commit e2c538b

File tree

4 files changed

+153
-27
lines changed

4 files changed

+153
-27
lines changed

doc/whats_new.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Current
1919
Changelog
2020
~~~~~~~~~
2121

22+
- Add support for indexing, slicing, and iterating :class:`mne.Annotations` by `Joan Massich`_
23+
2224
- :meth:`mne.io.Raw.plot` now uses the lesser of ``n_channels`` and ``raw.ch_names``, by `Joan Massich`_
2325

2426
- Add ``chunk_duration`` parameter to :func:`mne.events_from_annotations` to allow multiple events from a single annotation by `Joan Massich`_

mne/annotations.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import re
99
from copy import deepcopy
1010
from itertools import takewhile
11-
11+
from collections import OrderedDict
1212

1313
import numpy as np
1414

@@ -214,6 +214,25 @@ def __iadd__(self, other):
214214
other.orig_time))
215215
return self.append(other.onset, other.duration, other.description)
216216

217+
def __iter__(self):
218+
"""Iterate over the annotations."""
219+
for idx in range(len(self.onset)):
220+
yield self.__getitem__(idx)
221+
222+
def __getitem__(self, key):
223+
"""Propagate indexing and slicing to the underlying numpy structure."""
224+
if isinstance(key, int):
225+
out_keys = ('onset', 'duration', 'description', 'orig_time')
226+
out_vals = (self.onset[key], self.duration[key],
227+
self.description[key], self.orig_time)
228+
return OrderedDict(zip(out_keys, out_vals))
229+
else:
230+
key = list(key) if isinstance(key, tuple) else key
231+
return Annotations(onset=self.onset[key],
232+
duration=self.duration[key],
233+
description=self.description[key],
234+
orig_time=self.orig_time)
235+
217236
def append(self, onset, duration, description):
218237
"""Add an annotated segment. Operates inplace.
219238
@@ -795,20 +814,17 @@ def events_from_annotations(raw, event_id=None, regexp=None, use_rounding=True,
795814
inds = inds[event_sel]
796815
else:
797816
inds = values = np.array([]).astype(int)
798-
iterator = list(zip(annotations.onset[event_sel],
799-
annotations.duration[event_sel],
800-
annotations.description[event_sel]))
801-
802-
for onset, duration, description in iterator:
803-
_onsets = np.arange(start=onset, stop=(onset + duration),
817+
for annot in annotations[event_sel]:
818+
_onsets = np.arange(start=annot['onset'],
819+
stop=(annot['onset'] + annot['duration']),
804820
step=chunk_duration)
805821
_inds = raw.time_as_index(_onsets,
806822
use_rounding=use_rounding,
807823
origin=annotations.orig_time)
808824
_inds += raw.first_samp
809825
inds = np.append(inds, _inds)
810826
_values = np.full(shape=len(_inds),
811-
fill_value=event_id_[description],
827+
fill_value=event_id_[annot['description']],
812828
dtype=int)
813829
values = np.append(values, _values)
814830

mne/tests/test_annotations.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from datetime import datetime
66
from itertools import repeat
7+
from collections import OrderedDict
78

89
import os.path as op
910

@@ -794,4 +795,71 @@ def test_read_annotation_txt_orig_time(
794795
assert_array_equal(annot.description, ['AA', 'BB'])
795796

796797

798+
def test_annotations_simple_iteration():
799+
"""Test indexing Annotations."""
800+
NUM_ANNOT = 5
801+
EXPECTED_ELEMENTS_TYPE = (np.float64, np.float64, np.str_)
802+
EXPECTED_ONSETS = EXPECTED_DURATIONS = [x for x in range(NUM_ANNOT)]
803+
EXPECTED_DESCS = [x.__repr__() for x in range(NUM_ANNOT)]
804+
805+
annot = Annotations(onset=EXPECTED_ONSETS,
806+
duration=EXPECTED_DURATIONS,
807+
description=EXPECTED_DESCS,
808+
orig_time=None)
809+
810+
for ii, elements in enumerate(annot[:2]):
811+
assert isinstance(elements, OrderedDict)
812+
expected_values = (ii, ii, str(ii))
813+
for elem, expected_type, expected_value in zip(elements.values(),
814+
EXPECTED_ELEMENTS_TYPE,
815+
expected_values):
816+
assert np.isscalar(elem)
817+
assert type(elem) == expected_type
818+
assert elem == expected_value
819+
820+
821+
@requires_version('numpy', '1.12')
822+
def test_annotations_slices():
823+
"""Test indexing Annotations."""
824+
NUM_ANNOT = 5
825+
EXPECTED_ONSETS = EXPECTED_DURATIONS = [x for x in range(NUM_ANNOT)]
826+
EXPECTED_DESCS = [x.__repr__() for x in range(NUM_ANNOT)]
827+
828+
annot = Annotations(onset=EXPECTED_ONSETS,
829+
duration=EXPECTED_DURATIONS,
830+
description=EXPECTED_DESCS,
831+
orig_time=None)
832+
833+
# Indexing returns a copy. So this has no effect in annot
834+
annot[0]['onset'] = 42
835+
annot[0]['duration'] = 3.14
836+
annot[0]['description'] = 'foobar'
837+
838+
annot[:1].onset[0] = 42
839+
annot[:1].duration[0] = 3.14
840+
annot[:1].description[0] = 'foobar'
841+
842+
# Slicing with single element returns a dictionary
843+
for ii in EXPECTED_ONSETS:
844+
assert annot[ii] == dict(zip(['onset', 'duration',
845+
'description', 'orig_time'],
846+
[ii, ii, str(ii), None]))
847+
848+
# Slices should give back Annotations
849+
for current in (annot[slice(0, None, 2)],
850+
annot[[bool(ii % 2) for ii in range(len(annot))]],
851+
annot[:1],
852+
annot[[0, 2, 2]],
853+
annot[(0, 2, 2)],
854+
annot[np.array([0, 2, 2])],
855+
annot[1::2],
856+
):
857+
assert isinstance(current, Annotations)
858+
assert len(current) != len(annot)
859+
860+
for bad_ii in [len(EXPECTED_ONSETS), 42, 'foo']:
861+
with pytest.raises(IndexError):
862+
annot[bad_ii]
863+
864+
797865
run_tests_if_main()

tutorials/plot_object_annotations.py

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,12 @@
11
"""
2-
The **events** and :class:`~mne.Annotations` data structures
2+
The :term:`Events <events>` and :class:`~mne.Annotations` data structures
33
=========================================================================
44
5-
Events and :class:`~mne.Annotations` are quite similar.
5+
:term:`Events <events>` and :term:`annotations` are quite similar.
66
This tutorial highlights their differences and similarities, and tries to shed
77
some light on which one is preferred to use in different situations when using
88
MNE.
99
10-
Here are the definitions from the :ref:`glossary`.
11-
12-
events
13-
Events correspond to specific time points in raw data; e.g., triggers,
14-
experimental condition events, etc. MNE represents events with integers
15-
that are stored in numpy arrays of shape (n_events, 3). Such arrays are
16-
classically obtained from a trigger channel, also referred to as stim
17-
channel.
18-
19-
annotations
20-
An annotation is defined by an onset, a duration, and a string
21-
description. It can contain information about the experiment, but
22-
also details on signals marked by a human: bad data segments,
23-
sleep scores, sleep events (spindles, K-complex) etc.
24-
2510
Both events and :class:`~mne.Annotations` can be seen as triplets
2611
where the first element answers to **when** something happens and the last
2712
element refers to **what** it is.
@@ -101,8 +86,8 @@
10186

10287

10388
###############################################################################
104-
# Working with Annotations
105-
# ------------------------
89+
# Add :term:`annotations` to :term:`raw` objects
90+
# ----------------------------------------------
10691
#
10792
# An important element of :class:`~mne.Annotations` is
10893
# ``orig_time`` which is the time reference for the ``onset``.
@@ -179,6 +164,12 @@
179164
print('raw_a.annotations.onset[0] is {}'.format(raw_a.annotations.onset[0]))
180165

181166
###############################################################################
167+
# Valid operations in :class:`mne.Annotations`
168+
# --------------------------------------------
169+
#
170+
# Concatenate
171+
# ~~~~~~~~~~~
172+
#
182173
# It is possible to concatenate two annotations with the + operator (just like
183174
# lists) if both share the same ``orig_time``
184175

@@ -189,6 +180,55 @@
189180
print(annot)
190181

191182
###############################################################################
183+
# Iterating, Indexing and Slicing :class:`mne.Annotations`
184+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
185+
#
186+
# :class:`~mne.Annotations` supports iterating, indexing and slicing.
187+
# Iterating over :class:`~mne.Annotations` and indexing with an integer returns
188+
# a dictionary. While slicing returns a new :class:`~mne.Annotations` instance.
189+
#
190+
# See the following examples and usages:
191+
192+
# difference between indexing and slicing a single element
193+
print(annot[0]) # indexing
194+
print(annot[:1]) # slicing
195+
196+
###############################################################################
197+
# How about iterations?
198+
199+
for key, val in annot[0].items(): # iterate on one element which is dictionary
200+
print(key, val)
201+
202+
###############################################################################
203+
204+
for idx, my_annot in enumerate(annot): # iterate on the Annotations object
205+
print('annot #{0}: onset={1}'.format(idx, my_annot['onset']))
206+
print('annot #{0}: duration={1}'.format(idx, my_annot['duration']))
207+
print('annot #{0}: description={1}'.format(idx, my_annot['description']))
208+
209+
###############################################################################
210+
211+
for idx, my_annot in enumerate(annot[:1]):
212+
for key, val in my_annot.items():
213+
print('annot #{0}: {1} = {2}'.format(idx, key, val))
214+
215+
###############################################################################
216+
# Iterating, indexing and slicing return a copy. This has implications like the
217+
# fact that changes are not kept.
218+
219+
# this change is not kept
220+
annot[0]['onset'] = 42
221+
print(annot[0])
222+
223+
# this change is kept
224+
annot.onset[0] = 42
225+
print(annot[0])
226+
227+
228+
###############################################################################
229+
# Save
230+
# ~~~~
231+
#
192232
# Note that you can also save annotations to disk in FIF format::
193233
#
194234
# >>> annot.save('my-annot.fif')

0 commit comments

Comments
 (0)