Skip to content

Commit 2a3dbfb

Browse files
authored
Merge pull request #58 from smoia/cristina
A set of major changes that are getting merged at once (check PR text)
2 parents 890ded9 + ed6d805 commit 2a3dbfb

File tree

5 files changed

+161
-43
lines changed

5 files changed

+161
-43
lines changed

docs/user_guide/editing.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ removed. We can do that easily with :py:func:`~.operations.edit_physio`:
4141

4242
This function will open up an interactive viewer, which supports scrolling
4343
through the time series (with the scroll wheel), rejection of noisy segments of
44-
data (left click + drag, red highlight), and deleting peaks / troughs that were
44+
data (left click + drag, blue highlight), and deleting peaks / troughs that were
4545
erroneously detected and shouldn't be considered at all (right click + drag,
46-
blue highlight):
46+
red highlight):
4747

4848
.. image:: physio_edit.gif
4949

peakdet/editor.py

Lines changed: 81 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
# -*- coding: utf-8 -*-
2-
"""
3-
Functions and class for performing interactive editing of physiological data
4-
"""
2+
"""Functions and class for performing interactive editing of physiological data."""
53

64
import functools
75
import numpy as np
@@ -12,7 +10,7 @@
1210

1311
class _PhysioEditor():
1412
"""
15-
Class for editing physiological data
13+
Class for editing physiological data.
1614
1715
Parameters
1816
----------
@@ -25,30 +23,49 @@ def __init__(self, data):
2523
self.data = utils.check_physio(data, copy=True)
2624
fs = 1 if data.fs is None else data.fs
2725
self.time = np.arange(0, len(data.data) / fs, 1 / fs)
26+
# Read if there is support data
27+
self.suppdata = data.suppdata
2828

2929
# we need to create these variables in case someone doesn't "quit"
3030
# the plot appropriately (i.e., clicks X instead of pressing ctrl+q)
31-
self.deleted, self.rejected = set(), set()
31+
self.deleted, self.rejected, self.included = set(), set(), set()
32+
33+
# make main plot objects depending on supplementary data
34+
if self.suppdata is None:
35+
self.fig, self._ax = plt.subplots(nrows=1, ncols=1,
36+
tight_layout=True, sharex=True)
37+
else:
38+
self.fig, self._ax = plt.subplots(nrows=2, ncols=1,
39+
tight_layout=True, sharex=True,
40+
gridspec_kw={'height_ratios': [3, 2]})
3241

33-
# make main plot objects
34-
self.fig, self.ax = plt.subplots(nrows=1, ncols=1, tight_layout=True)
3542
self.fig.canvas.mpl_connect('scroll_event', self.on_wheel)
3643
self.fig.canvas.mpl_connect('key_press_event', self.on_key)
3744

38-
# two selectors for rejection (left mouse) and deletion (right mouse)
39-
reject = functools.partial(self.on_remove, reject=True)
40-
delete = functools.partial(self.on_remove, reject=False)
41-
self.span1 = SpanSelector(self.ax, reject, 'horizontal',
45+
# Set axis handler
46+
self.ax = self._ax if self.suppdata is None else self._ax[0]
47+
48+
# three selectors for:
49+
# 1. rejection (central mouse),
50+
# 2. addition (right mouse), and
51+
# 3. deletion (left mouse)
52+
delete = functools.partial(self.on_edit, method='delete')
53+
reject = functools.partial(self.on_edit, method='reject')
54+
insert = functools.partial(self.on_edit, method='insert')
55+
self.span2 = SpanSelector(self.ax, delete, 'horizontal',
4256
button=1, useblit=True,
4357
rectprops=dict(facecolor='red', alpha=0.3))
44-
self.span2 = SpanSelector(self.ax, delete, 'horizontal',
45-
button=3, useblit=True,
58+
self.span1 = SpanSelector(self.ax, reject, 'horizontal',
59+
button=2, useblit=True,
4660
rectprops=dict(facecolor='blue', alpha=0.3))
61+
self.span3 = SpanSelector(self.ax, insert, 'horizontal',
62+
button=3, useblit=True,
63+
rectprops=dict(facecolor='green', alpha=0.3))
4764

4865
self.plot_signals(False)
4966

5067
def plot_signals(self, plot=True):
51-
""" Clears axes and plots data / peaks / troughs """
68+
"""Clear axes and plots data / peaks / troughs."""
5269
# don't reset x-/y-axis zooms on replot
5370
if plot:
5471
xlim, ylim = self.ax.get_xlim(), self.ax.get_ylim()
@@ -62,50 +79,79 @@ def plot_signals(self, plot=True):
6279
self.data[self.data.peaks], '.r',
6380
self.time[self.data.troughs],
6481
self.data[self.data.troughs], '.g')
82+
83+
if self.suppdata is not None:
84+
self._ax[1].plot(self.time, self.suppdata, 'k', linewidth=0.7)
85+
self._ax[1].set_ylim(-.5, .5)
86+
6587
self.ax.set(xlim=xlim, ylim=ylim, yticklabels='')
6688
self.fig.canvas.draw()
6789

6890
def on_wheel(self, event):
69-
""" Moves axis on wheel scroll """
91+
"""Move axis on wheel scroll."""
7092
(xlo, xhi), move = self.ax.get_xlim(), event.step * -10
7193
self.ax.set_xlim(xlo + move, xhi + move)
7294
self.fig.canvas.draw()
7395

7496
def quit(self):
75-
""" Quits editor """
97+
"""Quit editor."""
7698
plt.close(self.fig)
7799

78100
def on_key(self, event):
79-
""" Undoes last span select or quits peak editor """
101+
"""Undo last span select or quits peak editor."""
80102
# accept both control or Mac command key as selector
81103
if event.key in ['ctrl+z', 'super+d']:
82104
self.undo()
83105
elif event.key in ['ctrl+q', 'super+d']:
84106
self.quit()
85107

86-
def on_remove(self, xmin, xmax, *, reject):
87-
""" Removes specified peaks by either rejection / deletion """
108+
def on_edit(self, xmin, xmax, *, method):
109+
"""
110+
Edit peaks by rejection, deletion, or insert.
111+
112+
Removes specified peaks by either rejection / deletion, OR
113+
Include one peak by finding the max in the selection.
114+
115+
method accepts 'insert', 'reject', 'delete'
116+
"""
117+
if method not in ['insert', 'reject', 'delete']:
118+
raise ValueError(f'Action "{method}" not supported.')
119+
88120
tmin, tmax = np.searchsorted(self.time, (xmin, xmax))
89121
pmin, pmax = np.searchsorted(self.data.peaks, (tmin, tmax))
90-
bad = np.arange(pmin, pmax, dtype=int)
91122

92-
if len(bad) == 0:
93-
return
123+
if method == 'insert':
124+
tmp = np.argmax(self.data.data[tmin:tmax]) if tmin != tmax else 0
125+
newpeak = tmin + tmp
126+
if newpeak == tmin:
127+
self.plot_signals()
128+
return
129+
else:
130+
bad = np.arange(pmin, pmax, dtype=int)
131+
if len(bad) == 0:
132+
self.plot_signals()
133+
return
94134

95-
if reject:
135+
if method == 'reject':
96136
rej, fcn = self.rejected, operations.reject_peaks
97-
else:
137+
elif method == 'delete':
98138
rej, fcn = self.deleted, operations.delete_peaks
99139

100-
# store edits in local history
101-
rej.update(self.data.peaks[bad].tolist())
102-
self.data = fcn(self.data, self.data.peaks[bad])
140+
# store edits in local history & call function
141+
if method == 'insert':
142+
self.included.add(newpeak)
143+
self.data = operations.add_peaks(self.data, newpeak)
144+
else:
145+
rej.update(self.data.peaks[bad].tolist())
146+
self.data = fcn(self.data, self.data.peaks[bad])
147+
103148
self.plot_signals()
104149

105150
def undo(self):
106-
""" Resets last span select peak removal """
151+
"""Reset last span select peak removal."""
107152
# check if last history entry was a manual reject / delete
108-
if self.data._history[-1][0] not in ['reject_peaks', 'delete_peaks']:
153+
relevant = ['reject_peaks', 'delete_peaks', 'add_peaks']
154+
if self.data._history[-1][0] not in relevant:
109155
return
110156

111157
# pop off last edit and delete
@@ -123,7 +169,12 @@ def undo(self):
123169
peaks['remove']
124170
)
125171
self.deleted.difference_update(peaks['remove'])
126-
172+
elif func == 'add_peaks':
173+
self.data._metadata['peaks'] = np.delete(
174+
self.data._metadata['peaks'],
175+
np.searchsorted(self.data._metadata['peaks'], peaks['add']),
176+
)
177+
self.included.remove(peaks['add'])
127178
self.data._metadata['troughs'] = utils.check_troughs(self.data,
128179
self.data.peaks,
129180
self.data.troughs)

peakdet/operations.py

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,11 @@ def interpolate_physio(data, target_fs, *, kind='cubic'):
9292

9393
# interpolate data and generate new Physio object
9494
interp = interpolate.interp1d(t_orig, data, kind=kind)(t_new)
95-
interp = utils.new_physio_like(data, interp, fs=target_fs)
95+
if data.suppdata is None:
96+
suppinterp = None
97+
else:
98+
suppinterp = interpolate.interp1d(t_orig, data.suppdata, kind=kind)(t_new)
99+
interp = utils.new_physio_like(data, interp, fs=target_fs, suppdata=suppinterp)
96100

97101
return interp
98102

@@ -182,6 +186,52 @@ def reject_peaks(data, remove):
182186
return data
183187

184188

189+
@utils.make_operation()
190+
def add_peaks(data, add):
191+
"""
192+
Add `newpeak` to add them in `data`
193+
194+
Parameters
195+
----------
196+
data : Physio_like
197+
add : int
198+
199+
Returns
200+
-------
201+
data : Physio_like
202+
"""
203+
204+
data = utils.check_physio(data, ensure_fs=False, copy=True)
205+
idx = np.searchsorted(data._metadata['peaks'], add)
206+
data._metadata['peaks'] = np.insert(data._metadata['peaks'], idx, add)
207+
data._metadata['troughs'] = utils.check_troughs(data, data.peaks)
208+
209+
return data
210+
211+
212+
@utils.make_operation()
213+
def add_peaks(data, add):
214+
"""
215+
Add `newpeak` to add them in `data`
216+
217+
Parameters
218+
----------
219+
data : Physio_like
220+
add : int
221+
222+
Returns
223+
-------
224+
data : Physio_like
225+
"""
226+
227+
data = utils.check_physio(data, ensure_fs=False, copy=True)
228+
idx = np.searchsorted(data._metadata['peaks'], add)
229+
data._metadata['peaks'] = np.insert(data._metadata['peaks'], idx, add)
230+
data._metadata['troughs'] = utils.check_troughs(data, data.peaks)
231+
232+
return data
233+
234+
185235
def edit_physio(data):
186236
"""
187237
Opens interactive plot with `data` to permit manual editing of time series
@@ -206,13 +256,14 @@ def edit_physio(data):
206256
# perform manual editing
207257
edits = editor._PhysioEditor(data)
208258
plt.show(block=True)
209-
delete, reject = sorted(edits.deleted), sorted(edits.rejected)
210259

211260
# replay editing on original provided data object
212-
if reject is not None:
213-
data = reject_peaks(data, remove=reject)
214-
if delete is not None:
215-
data = delete_peaks(data, remove=delete)
261+
if len(edits.rejected) > 0:
262+
data = reject_peaks(data, remove=sorted(edits.rejected))
263+
if len(edits.deleted) > 0:
264+
data = delete_peaks(data, remove=sorted(edits.deleted))
265+
if len(edits.included) > 0:
266+
data = add_peaks(data, add=sorted(edits.included))
216267

217268
return data
218269

peakdet/physio.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class Physio():
2121
Functions performed on `data`. Default: None
2222
metadata : dict, optional
2323
Metadata associated with `data`. Default: None
24+
suppdata : array_like, optional
25+
Support data array. Default: None
2426
2527
Attributes
2628
----------
@@ -35,9 +37,11 @@ class Physio():
3537
Indices of peaks in `data`
3638
troughs : :obj:`numpy.ndarray`
3739
Indices of troughs in `data`
40+
suppdata : :obj:`numpy.ndarray`
41+
Secondary physiological waveform
3842
"""
3943

40-
def __init__(self, data, fs=None, history=None, metadata=None):
44+
def __init__(self, data, fs=None, history=None, metadata=None, suppdata=None):
4145
self._data = np.asarray(data).squeeze()
4246
if self.data.ndim > 1:
4347
raise ValueError('Provided data dimensionality {} > 1.'
@@ -68,6 +72,7 @@ def __init__(self, data, fs=None, history=None, metadata=None):
6872
self._metadata = dict(peaks=np.empty(0, dtype=int),
6973
troughs=np.empty(0, dtype=int),
7074
reject=np.empty(0, dtype=int))
75+
self._suppdata = None if suppdata is None else np.asarray(suppdata).squeeze()
7176

7277
def __array__(self):
7378
return self.data

peakdet/utils.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,13 @@ def check_physio(data, ensure_fs=True, copy=False):
141141
if copy is True:
142142
return new_physio_like(data, data.data,
143143
copy_history=True,
144-
copy_metadata=True)
144+
copy_metadata=True,
145+
copy_suppdata=True)
145146
return data
146147

147148

148-
def new_physio_like(ref_physio, data, *, fs=None, dtype=None,
149-
copy_history=True, copy_metadata=True):
149+
def new_physio_like(ref_physio, data, *, fs=None, suppdata=None, dtype=None,
150+
copy_history=True, copy_metadata=True, copy_suppdata=True):
150151
"""
151152
Makes `data` into physio object like `ref_data`
152153
@@ -159,10 +160,16 @@ def new_physio_like(ref_physio, data, *, fs=None, dtype=None,
159160
fs : float, optional
160161
Sampling rate of `data`. If not supplied, assumed to be the same as
161162
in `ref_physio`
163+
suppdata : array_like, optional
164+
New supplementary data. If not supplied, assumed to be the same.
162165
dtype : data_type, optional
163166
Data type to convert `data` to, if conversion needed. Default: None
164167
copy_history : bool, optional
165168
Copy history from `ref_physio` to new physio object. Default: True
169+
copy_metadata : bool, optional
170+
Copy metadata from `ref_physio` to new physio object. Default: True
171+
copy_suppdata : bool, optional
172+
Copy suppdata from `ref_physio` to new physio object. Default: True
166173
167174
Returns
168175
-------
@@ -177,9 +184,13 @@ def new_physio_like(ref_physio, data, *, fs=None, dtype=None,
177184
history = list(ref_physio.history) if copy_history else []
178185
metadata = dict(**ref_physio._metadata) if copy_metadata else None
179186

187+
if suppdata is None:
188+
suppdata = ref_physio._suppdata if copy_suppdata else None
189+
180190
# make new class
181191
out = ref_physio.__class__(np.array(data, dtype=dtype),
182-
fs=fs, history=history, metadata=metadata)
192+
fs=fs, history=history, metadata=metadata,
193+
suppdata=suppdata)
183194
return out
184195

185196

0 commit comments

Comments
 (0)