Skip to content

Commit 0c8cda3

Browse files
committed
Add support for segment annotation
1 parent cb1b40c commit 0c8cda3

4 files changed

Lines changed: 115 additions & 7 deletions

File tree

peakdet/editor.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def __init__(self, data):
3232

3333
# we need to create these variables in case someone doesn't "quit"
3434
# the plot appropriately (i.e., clicks X instead of pressing ctrl+q)
35-
self.deleted, self.rejected, self.included = set(), set(), set()
35+
self.deleted, self.rejected, self.included, self.annotated = set(), set(), set(), set()
3636

3737
# make main plot objects depending on supplementary data
3838
if self.suppdata is None:
@@ -57,8 +57,8 @@ def __init__(self, data):
5757
# three selectors for:
5858
# 1. rejection (central mouse),
5959
# 2. addition (right mouse), and
60-
# 3. deletion (left mouse)
61-
delete = functools.partial(self.on_edit, method="delete")
60+
# 3. deletion or annotation (left mouse)
61+
self.mode = "delete"
6262
reject = functools.partial(self.on_edit, method="reject")
6363
insert = functools.partial(self.on_edit, method="insert")
6464

@@ -71,7 +71,7 @@ def __init__(self, data):
7171

7272
self.span2 = SpanSelector(
7373
self.ax,
74-
delete,
74+
self._span_callback,
7575
"horizontal",
7676
button=1,
7777
useblit=True,
@@ -118,6 +118,21 @@ def plot_signals(self, plot=True):
118118
".g",
119119
)
120120

121+
if self.data.segments.shape[0]>0:
122+
for segment in self.data.segments:
123+
ymin, ymax = self.ax.get_ylim()
124+
height = ymax - ymin
125+
width = self.time[int(segment[1])] - self.time[int(segment[0])]
126+
127+
rect = plt.Rectangle(
128+
[self.time[int(segment[0])], ymin],
129+
width,
130+
height,
131+
alpha=0.3,
132+
facecolor='y'
133+
)
134+
self.ax.add_patch(rect)
135+
121136
if self.suppdata is not None:
122137
self._ax[1].plot(self.time, self.suppdata, "k", linewidth=0.7)
123138
self._ax[1].set_ylim(-0.5, 0.5)
@@ -142,6 +157,13 @@ def on_key(self, event):
142157
self.undo()
143158
elif event.key in ["ctrl+q", "super+d"]:
144159
self.quit()
160+
elif event.key in ["ctrl+a"]:
161+
self.annotate_mode()
162+
elif event.key in ["ctrl+e"]:
163+
self.edit_mode()
164+
165+
def _span_callback(self, xmin, xmax):
166+
self.on_edit(xmin, xmax, method=self.mode)
145167

146168
def on_edit(self, xmin, xmax, *, method):
147169
"""
@@ -150,10 +172,10 @@ def on_edit(self, xmin, xmax, *, method):
150172
Removes specified peaks by either rejection / deletion, OR
151173
Include one peak by finding the max in the selection.
152174
153-
method accepts 'insert', 'reject', 'delete'
175+
method accepts 'insert', 'reject', 'delete', 'annotate'
154176
"""
155177
logger.debug("Edited peaks with action: {}", method)
156-
if method not in ["insert", "reject", "delete"]:
178+
if method not in ["insert", "reject", "delete", "annotate"]:
157179
raise ValueError(f'Action "{method}" not supported.')
158180

159181
tmin, tmax = np.searchsorted(self.time, (xmin, xmax))
@@ -165,6 +187,8 @@ def on_edit(self, xmin, xmax, *, method):
165187
if newpeak == tmin:
166188
self.plot_signals()
167189
return
190+
elif method == "annotate":
191+
segment = (tmin, tmax)
168192
else:
169193
bad = np.arange(pmin, pmax, dtype=int)
170194
if len(bad) == 0:
@@ -180,6 +204,11 @@ def on_edit(self, xmin, xmax, *, method):
180204
if method == "insert":
181205
self.included.add(newpeak)
182206
self.data = operations.add_peaks(self.data, newpeak)
207+
elif method == "annotate":
208+
self.data = operations.annotate_segments(self.data, segment)
209+
self.annotated = set()
210+
for s in self.data.segments:
211+
self.annotated.add((s[0], s[1]))
183212
else:
184213
rej.update(self.data.peaks[bad].tolist())
185214
self.data = fcn(self.data, self.data.peaks[bad])
@@ -189,7 +218,7 @@ def on_edit(self, xmin, xmax, *, method):
189218
def undo(self):
190219
"""Reset last span select peak removal."""
191220
# check if last history entry was a manual reject / delete
192-
relevant = ["reject_peaks", "delete_peaks", "add_peaks"]
221+
relevant = ["reject_peaks", "delete_peaks", "add_peaks", "annotate_segments"]
193222
if self.data._history[-1][0] not in relevant:
194223
return
195224

@@ -215,7 +244,20 @@ def undo(self):
215244
np.searchsorted(self.data._metadata["peaks"], peaks["add"]),
216245
)
217246
self.included.remove(peaks["add"])
247+
elif func == "annotate_segments":
248+
self.data._metadata["segments"] = np.delete(
249+
self.data._metadata["segments"],
250+
-1,
251+
axis=0
252+
)
253+
self.annotated.pop()
218254
self.data._metadata["troughs"] = utils.check_troughs(
219255
self.data, self.data.peaks, self.data.troughs
220256
)
221257
self.plot_signals()
258+
259+
def annotate_mode(self):
260+
self.mode = "annotate"
261+
262+
def edit_mode(self):
263+
self.mode = "delete"

peakdet/operations.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,19 @@ def add_peaks(data, add):
249249
return data
250250

251251

252+
@utils.make_operation()
253+
def annotate_segments(data, segment):
254+
if isinstance(segment, tuple):
255+
segment = [segment]
256+
for s in segment:
257+
data = utils.check_physio(data, ensure_fs=False, copy=True)
258+
segments = utils.check_segment(data, np.array([s], dtype=data._metadata["segments"].dtype))
259+
260+
data._metadata["segments"] = np.unique(segments)
261+
262+
return data
263+
264+
252265
def edit_physio(data):
253266
"""
254267
Open interactive plot with `data` to permit manual editing of time series.
@@ -281,6 +294,8 @@ def edit_physio(data):
281294
data = delete_peaks(data, remove=sorted(edits.deleted))
282295
if len(edits.included) > 0:
283296
data = add_peaks(data, add=sorted(edits.included))
297+
if len(edits.annotated) > 0:
298+
data = annotate_segments(data, segment=sorted(edits.annotated))
284299

285300
return data
286301

peakdet/physio.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def __init__(self, data, fs=None, history=None, metadata=None, suppdata=None):
7474
peaks=np.empty(0, dtype=int),
7575
troughs=np.empty(0, dtype=int),
7676
reject=np.empty(0, dtype=int),
77+
segments=np.empty(0, dtype=[('onset', 'f8'), ('offset', 'f8')])
7778
)
7879
self._suppdata = None if suppdata is None else np.asarray(suppdata).squeeze()
7980

@@ -115,6 +116,11 @@ def peaks(self):
115116
def troughs(self):
116117
"""Indices of detected troughs in `data`."""
117118
return self._metadata["troughs"]
119+
120+
@property
121+
def segments(self):
122+
"""Indices marking the onset and offset of annotated segments"""
123+
return self._metadata["segments"]
118124

119125
@property
120126
def _masked(self):

peakdet/utils.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ def wrapper(data, *args, **kwargs):
6060
# append everything to data instance history
6161
data._history += [(name, provided)]
6262

63+
# clean duplicates from history
64+
tmp_history, duplicate = [], set()
65+
for name, value in data._history:
66+
if name == "annotate_segments":
67+
segments = value.get("segment", [])
68+
new_segments = [s for s in segments if s not in duplicate]
69+
duplicate.update(new_segments)
70+
71+
tmp_history.append((name, {"segment": new_segments}))
72+
else:
73+
tmp_history.append((name, value))
74+
75+
if len(tmp_history) > 0:
76+
data._history = tmp_history
77+
6378
return data
6479

6580
return wrapper
@@ -244,6 +259,36 @@ def check_troughs(data, peaks, troughs=None):
244259
return all_troughs
245260

246261

262+
def check_segment(data, segment):
263+
"""
264+
Confirms that segments are not overlapping. The last segment specified
265+
as precedence over the overlapping ones.
266+
267+
Parameters
268+
----------
269+
data : array-like
270+
Input data for which `troughs` and `peaks` were detected
271+
segments : array-like
272+
Array containing the onset and offset of segments to annotate
273+
274+
Returns
275+
-------
276+
segments : np.ndarray
277+
Array containing validated (non-overlapping) segments
278+
"""
279+
tmp_segment = data.segments
280+
if tmp_segment.shape[0] == 0:
281+
return segment
282+
else:
283+
new_segment = segment[0]
284+
for idx, s in enumerate(tmp_segment):
285+
if (new_segment[1] > s[0] and new_segment[1] <= s[1]) or (new_segment[0] >= s[0] and new_segment[0] < s[1]):
286+
tmp_segment[idx] = new_segment
287+
else:
288+
tmp_segment = np.append(tmp_segment, segment)
289+
return tmp_segment
290+
291+
247292
def enable_logger(loglevel="INFO", diagnose=True, backtrace=True):
248293
"""
249294
Toggle the use of the module's logger and configures it.

0 commit comments

Comments
 (0)