Skip to content

Commit 233cbd5

Browse files
authored
Merge pull request #60 from AlzhraaIbrahim/event_detection_for_ic_stride
Event detection for ic stride
2 parents 01e4a12 + 35cdd4b commit 233cbd5

File tree

34 files changed

+5351
-247
lines changed

34 files changed

+5351
-247
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@ project.
1010

1111
## [2.6.0] - Unreleased
1212

13+
### Scientific Changes
14+
15+
- The TC detection within FilteredRampp event detection now uses the filtered gyro signal instead of the raw gyro
16+
signal.
17+
We saw in a couple of patients that they had a lot of high frequency noise in the gyro signal, which affected the
18+
TC detection.
19+
For existing cases, this should change the position of the TC event slightly (approximately 1-2 samples).
20+
(https://github.com/mad-lab-fau/gaitmap/pull/60)
21+
22+
### Added
23+
24+
- Both variants of the Rampp event detection now support either IC-IC strides or "segmented" strides as inputs.
25+
This allows the use of strides that are pre-segmented by other algorithms or by a reference system as input to the
26+
rest of the pipeline.
27+
The algorithm still searches for a proper IC in the input data within a search window around the provided IC.
28+
(https://github.com/mad-lab-fau/gaitmap/pull/60)
1329
- Dataset checks can now optionally check magnetometer data (https://github.com/mad-lab-fau/gaitmap/pull/73)
1430
- Dataset rotations can now rotate magnetometer data (https://github.com/mad-lab-fau/gaitmap/pull/73)
1531
- Added an option to the madgwick algorithm to use the algorithm version that also uses the magnetometer for correction.

example_data/imu_sample_ic_stride.csv

Lines changed: 4055 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
s_id,foot,start,end,gsd_id
2+
0,left,696,812,1
3+
1,left,812,926,1
4+
2,left,1531,1647,1
5+
3,left,1647,1765,1
6+
4,left,2510,2627,1
7+
5,left,2627,2746,1
8+
6,left,3361,3479,1
9+
7,right,757,870,1
10+
8,right,870,986,1
11+
9,right,1591,1709,1
12+
10,right,2451,2570,1
13+
11,right,2570,2686,1
14+
12,right,3306,3424,1
15+
13,right,3424,3544,1

examples/event_detection/herzer_event_detection.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,10 @@
6666
min_vel_events_left.head()
6767

6868
# %%
69-
# As a secondary output we get the `segmented_event_list_`, which holds the same event information than the
69+
# As a secondary output we get the `annotated_original_event_list_`, which holds the same event information than the
7070
# `min_vel_event_list_`, but the start and the end of each stride are unchanged compared to the input.
7171
# This also means that no strides are removed due to the conversion step explained below.
72-
segmented_events_left = ed.segmented_event_list_["left_sensor"]
72+
segmented_events_left = ed.annotated_original_event_list_["left_sensor"]
7373
print(f"Gait events for {len(segmented_events_left)} segmented strides were detected.")
7474
segmented_events_left.head()
7575

@@ -202,7 +202,7 @@
202202
# This is required due to the shift of stride borders between the `stride_list` and the `min_vel_event_list_`.
203203
# Thus, the dropped first segmented stride of a continuous sequence only provides a pre_ic and a min_vel sample for
204204
# the first stride in the `min_vel_event_list_`.
205-
# Therefore, the `min_vel_event_list_` list has one stride less than the `segmented_event_list_`.
205+
# Therefore, the `min_vel_event_list_` list has one stride less than the `annotated_original_event_list_`.
206206

207207
ed2 = HerzerEventDetection()
208208
segmented_stride_list = stride_list["left_sensor"].iloc[[11, 12, 13, 14, 15, 16]]

examples/event_detection/rampp_event_detection.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,10 @@
8484
min_vel_events_left.head()
8585

8686
# %%
87-
# As a secondary output we get the `segmented_event_list_`, which holds the same event information than the
87+
# As a secondary output we get the `annotated_original_event_list_`, which holds the same event information than the
8888
# `min_vel_event_list_`, but the start and the end of each stride are unchanged compared to the input.
8989
# This also means that no strides are removed due to the conversion step explained below.
90-
segmented_events_left = ed.segmented_event_list_["left_sensor"]
90+
segmented_events_left = ed.annotated_original_event_list_["left_sensor"]
9191
print(f"Gait events for {len(segmented_events_left)} segmented strides were detected.")
9292
segmented_events_left.head()
9393

@@ -231,7 +231,7 @@
231231
# This is required due to the shift of stride borders between the `stride_list` and the `min_vel_event_list_`.
232232
# Thus, the dropped first segmented stride of a continuous sequence only provides a pre_ic and a min_vel sample for
233233
# the first stride in the `min_vel_event_list_`.
234-
# Therefore, the `min_vel_event_list_` list has one stride less than the `segmented_event_list_`.
234+
# Therefore, the `min_vel_event_list_` list has one stride less than the `annotated_original_event_list_`.
235235
from gaitmap.event_detection import RamppEventDetection
236236

237237
ed2 = RamppEventDetection()
@@ -334,7 +334,7 @@
334334
min_vel_events_left = edfilt.min_vel_event_list_["left_sensor"]
335335
print(f"Gait events for {len(min_vel_events_left)} min_vel strides using the filtered version were detected.")
336336
min_vel_events_left.head()
337-
segmented_events_left = edfilt.segmented_event_list_["left_sensor"]
337+
segmented_events_left = edfilt.annotated_original_event_list_["left_sensor"]
338338
print(f"Gait events for {len(segmented_events_left)} segmented strides using the filtered version were detected.")
339339
segmented_events_left.head()
340340
fig, (ax1, ax2) = plt.subplots(2, sharex=True, figsize=(10, 5))

gaitmap/_event_detection_common/_event_detection_mixin.py

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Mixin for event detection algorithms that work similar to Rampp et al."""
22

3+
import warnings
34
from typing import Any, Callable, Optional, Union
45

56
import numpy as np
67
import pandas as pd
78
from joblib import Memory
89
from numpy.linalg import norm
9-
from typing_extensions import Self
10+
from typing_extensions import Literal, Self
1011

1112
from gaitmap.utils._algo_helper import invert_result_dictionary, set_params_from_dict
1213
from gaitmap.utils._types import _Hashable
@@ -22,7 +23,7 @@
2223
)
2324
from gaitmap.utils.exceptions import ValidationError
2425
from gaitmap.utils.stride_list_conversion import (
25-
_segmented_stride_list_to_min_vel_single_sensor,
26+
_stride_list_to_min_vel_single_sensor,
2627
enforce_stride_list_consistency,
2728
)
2829

@@ -33,21 +34,24 @@ class _EventDetectionMixin:
3334
detect_only: Optional[tuple[str, ...]]
3435

3536
min_vel_event_list_: Optional[Union[pd.DataFrame, dict[str, pd.DataFrame]]]
36-
segmented_event_list_: Optional[Union[pd.DataFrame, dict[str, pd.DataFrame]]]
37+
annotated_original_event_list_: Optional[Union[pd.DataFrame, dict[str, pd.DataFrame]]]
3738

3839
data: SensorData
3940
sampling_rate_hz: float
4041
stride_list: pd.DataFrame
42+
input_stride_type: Literal["segmented", "ic"]
4143

4244
def __init__(
4345
self,
4446
memory: Optional[Memory] = None,
4547
enforce_consistency: bool = True,
4648
detect_only: Optional[tuple[str, ...]] = None,
47-
) -> None:
49+
input_stride_type: Literal["segmented", "ic"] = "segmented",
50+
):
4851
self.memory = memory
4952
self.enforce_consistency = enforce_consistency
5053
self.detect_only = detect_only
54+
self.input_stride_type = input_stride_type
5155

5256
def detect(self, data: SensorData, stride_list: StrideList, *, sampling_rate_hz: float) -> Self:
5357
"""Find gait events in data within strides provided by stride_list.
@@ -121,50 +125,62 @@ def _detect_single_dataset(
121125
# find events in all segments
122126
event_detection_func = self._select_all_event_detection_method()
123127
event_detection_func = memory.cache(event_detection_func)
124-
ic, tc, min_vel = event_detection_func(gyr, acc, stride_list, events=events, **detect_kwargs)
128+
ic, tc, min_vel = event_detection_func(
129+
gyr, acc, stride_list, events=events, input_stride_type=self.input_stride_type, **detect_kwargs
130+
)
125131

126132
# build first dict / df based on segment start and end
127-
segmented_event_list = {
133+
annotated_original_event_list = {
128134
"s_id": stride_list.index,
129135
"start": stride_list["start"],
130136
"end": stride_list["end"],
131137
}
132138
for event, event_list in zip(("ic", "tc", "min_vel"), (ic, tc, min_vel)):
133139
if event in events:
134-
segmented_event_list[event] = event_list
135-
136-
segmented_event_list = pd.DataFrame(segmented_event_list).set_index("s_id")
137-
140+
annotated_original_event_list[event] = event_list
141+
annotated_original_event_list = pd.DataFrame(annotated_original_event_list).set_index("s_id")
138142
if self.enforce_consistency:
139143
# check for consistency, remove inconsistent strides
140-
segmented_event_list, _ = enforce_stride_list_consistency(
141-
segmented_event_list, stride_type="segmented", check_stride_list=False
144+
annotated_original_event_list, _ = enforce_stride_list_consistency(
145+
annotated_original_event_list, input_stride_type=self.input_stride_type, check_stride_list=False
142146
)
143147

144148
if "min_vel" not in events or self.enforce_consistency is False:
145149
# do not set min_vel_event_list_ if consistency is not enforced as it would be completely scrambled
146150
# and can not be used for anything anyway
147-
return {"segmented_event_list": segmented_event_list}
151+
return {"annotated_original_event_list": annotated_original_event_list}
148152

149153
# convert to min_vel event list
150-
min_vel_event_list, _ = _segmented_stride_list_to_min_vel_single_sensor(
151-
segmented_event_list, target_stride_type="min_vel"
154+
min_vel_event_list, _ = _stride_list_to_min_vel_single_sensor(
155+
annotated_original_event_list, source_stride_type=self.input_stride_type, target_stride_type="min_vel"
152156
)
153157

154158
output_order = [c for c in ["start", "end", "ic", "tc", "min_vel", "pre_ic"] if c in min_vel_event_list.columns]
155159

156160
# We enforce consistency again here, as a valid segmented stride list does not necessarily result in a valid
157161
# min_vel stride list
158162
min_vel_event_list, _ = enforce_stride_list_consistency(
159-
min_vel_event_list[output_order], stride_type="min_vel", check_stride_list=False
163+
min_vel_event_list[output_order], input_stride_type="min_vel", check_stride_list=False
160164
)
161165

162-
return {"min_vel_event_list": min_vel_event_list, "segmented_event_list": segmented_event_list}
166+
return {
167+
"min_vel_event_list": min_vel_event_list,
168+
"annotated_original_event_list": annotated_original_event_list,
169+
}
170+
171+
@property
172+
def segmented_event_list_(self) -> Optional[Union[pd.DataFrame, dict[str, pd.DataFrame]]]:
173+
warnings.warn(
174+
"`segmented_event_list_` is deprecated and will be removed in a future version. "
175+
"Use `annotated_original_event_list_` instead.",
176+
DeprecationWarning,
177+
)
178+
return self.annotated_original_event_list_
163179

164180
def _select_all_event_detection_method(self) -> Callable:
165181
"""Select the function to calculate the all events.
166182
167-
This is separate method to make it easy to overwrite by a subclass.
183+
This is a separate method to make it easy to overwrite by a subclass.
168184
"""
169185
raise NotImplementedError()
170186

gaitmap/event_detection/_herzer_event_detection.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from joblib import Memory
88
from scipy import signal
99
from tpcp import cf
10+
from typing_extensions import Literal
1011

1112
from gaitmap._event_detection_common._event_detection_mixin import _detect_min_vel_gyr_energy, _EventDetectionMixin
1213
from gaitmap.base import BaseEventDetection
@@ -54,7 +55,9 @@ class HerzerEventDetection(_EventDetectionMixin, BaseEventDetection):
5455
By default, all events ("ic", "tc", "min_vel") are detected.
5556
If `min_vel` is not detected, the `min_vel_event_list_` output will not be available.
5657
If "ic" is not detected, the `pre_ic` will also not be available in the output.
57-
58+
input_stride_type
59+
Only segmented strides are supported by this method.
60+
Hence, this parameter is set to "segmented" by default and changing it will raise an error.
5861
5962
Attributes
6063
----------
@@ -65,11 +68,13 @@ class HerzerEventDetection(_EventDetectionMixin, BaseEventDetection):
6568
corresponds to the min_vel sample of the subsequent stride.
6669
Strides for which no valid events could be found are removed.
6770
Additional strides might have been removed due to the conversion from segmented to min_vel strides.
68-
segmented_event_list_ : A stride list or dictionary with such values
71+
annotated_original_event_list_ : A stride list or dictionary with such values
6972
The result of the `detect` method holding all temporal gait events and start / end of all strides.
7073
This version of the results has the same stride borders than the input `stride_list` and has additional columns
7174
for all the detected events.
7275
Strides for which no valid events could be found are removed.
76+
segmented_event_list_ :
77+
Deprecated, use `annotated_original_event_list_` instead.
7378
7479
7580
Other Parameters
@@ -89,7 +94,7 @@ class HerzerEventDetection(_EventDetectionMixin, BaseEventDetection):
8994
9095
>>> event_detection = HerzerEventDetection()
9196
>>> event_detection.detect(data=data, stride_list=stride_list, sampling_rate_hz=204.8)
92-
>>> event_detection.segmented_event_list_
97+
>>> event_detection.annotated_original_event_list_
9398
start end ic tc min_vel
9499
s_id
95100
0 48304 48558 48382.0 48304.0 48479.0
@@ -132,6 +137,9 @@ class HerzerEventDetection(_EventDetectionMixin, BaseEventDetection):
132137
The window size can be adjusted via the `min_vel_search_win_size_ms` parameter.
133138
This approach is identical to [1]_.
134139
140+
The :func:`~gaitmap.event_detection.HerzerEventDetection.detect` method is implemented only for "segmented" stride
141+
type
142+
135143
The :func:`~gaitmap.event_detection.HerzerEventDetection.detect` method provides a stride list `min_vel_event_list`
136144
with the gait events mentioned above and additionally `start` and `end` of each stride, which are aligned to the
137145
`min_vel` samples.
@@ -141,15 +149,15 @@ class HerzerEventDetection(_EventDetectionMixin, BaseEventDetection):
141149
the stride list.
142150
This function should NOT be used, since the detection of the min_vel gait events has not been validated
143151
for stair ambulation.
144-
Please refer to the `segmented_event_list_` instead.
152+
Please refer to the `annotated_original_event_list_` instead.
145153
146154
The :class:`~gaitmap.event_detection.HerzerEventDetection` includes a consistency check that is enabled by default.
147155
The gait events within one stride provided by the `stride_list` must occur in the expected order.
148156
Any stride where the gait events are detected in a different order or are not detected at all is dropped!
149157
For more infos on this see :func:`~gaitmap.utils.stride_list_conversion.enforce_stride_list_consistency`.
150158
If you wish to disable this consistency check, set `enforce_consistency` to False.
151-
In this case, the attribute `min_vel_event_list_` will not be set, but you can use `segmented_event_list_` to get
152-
all detected events for the exact stride list that was used as input.
159+
In this case, the attribute `min_vel_event_list_` will not be set, but you can use `annotated_original_event_list_`
160+
to get all detected events for the exact stride list that was used as input.
153161
Note, that this list might contain NaN for some events.
154162
155163
Furthermore, during the conversion from the segmented stride list to the "min_vel" stride list, breaks in
@@ -188,6 +196,7 @@ class HerzerEventDetection(_EventDetectionMixin, BaseEventDetection):
188196
ic_lowpass_filter: BaseFilter
189197
memory: Optional[Memory]
190198
enforce_consistency: bool
199+
input_stride_type: Literal["segmented"]
191200

192201
def __init__(
193202
self,
@@ -198,12 +207,18 @@ def __init__(
198207
memory: Optional[Memory] = None,
199208
enforce_consistency: bool = True,
200209
detect_only: Optional[tuple[str, ...]] = None,
201-
) -> None:
210+
input_stride_type: Literal["segmented"] = "segmented",
211+
):
202212
self.min_vel_search_win_size_ms = min_vel_search_win_size_ms
203213
self.mid_swing_peak_prominence = mid_swing_peak_prominence
204214
self.mid_swing_n_considered_peaks = mid_swing_n_considered_peaks
205215
self.ic_lowpass_filter = ic_lowpass_filter
206-
super().__init__(memory=memory, enforce_consistency=enforce_consistency, detect_only=detect_only)
216+
super().__init__(
217+
memory=memory,
218+
enforce_consistency=enforce_consistency,
219+
detect_only=detect_only,
220+
input_stride_type=input_stride_type,
221+
)
207222

208223
def _get_detect_kwargs(self) -> dict[str, int]:
209224
min_vel_search_win_size = int(self.min_vel_search_win_size_ms / 1000 * self.sampling_rate_hz)
@@ -234,8 +249,11 @@ def _find_all_events(
234249
mid_swing_n_considered_peaks: int,
235250
ic_lowpass_filter: BaseFilter,
236251
sampling_rate_hz: float,
252+
input_stride_type: Literal["segmented"],
237253
) -> tuple[Optional[np.ndarray], Optional[np.ndarray], Optional[np.ndarray]]:
238254
"""Find events in provided data by looping over single strides."""
255+
if input_stride_type != "segmented":
256+
raise NotImplementedError("This method support only segmented stride type")
239257
gyr_ml = gyr["gyr_ml"].to_numpy()
240258
gyr = gyr.to_numpy()
241259
# inverting acc, as this algorithm was developed assuming a flipped axis like the original Rampp algorithm

gaitmap/example_data.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,19 @@ def get_healthy_example_imu_data():
8383
return data
8484

8585

86+
def get_healthy_example_imu_data_ic_stride():
87+
"""Get example IMU data from a healthy subject doing a 4x10 gait test.
88+
89+
The sampling rate is 102.4 Hz
90+
"""
91+
test_data_path = _get_data("imu_sample_ic_stride.csv")
92+
data = pd.read_csv(test_data_path, header=[0, 1], index_col=0)
93+
94+
# Get index in seconds
95+
data.index /= 102.4
96+
return data
97+
98+
8699
def get_ms_example_imu_data():
87100
"""Get example IMU data from a MS subject performing a longer uninterrupted walking sequence.
88101
@@ -130,6 +143,24 @@ def get_healthy_example_stride_borders():
130143
return data
131144

132145

146+
def get_healthy_example_stride_borders_ic_stride():
147+
"""Get hand labeled stride borders for :func:`get_healthy_example_imu_data_ic_stride`.
148+
149+
The stride borders are obtained from mocap where each stride starts with initial contact.
150+
"""
151+
test_data_path = _get_data("stride_borders_sample_ic_stride.csv")
152+
data = pd.read_csv(test_data_path, header=0)
153+
154+
# Convert to dict with sensor name as key.
155+
# Sensor name here is derived from the foot. In the real pipeline that would be provided to the algo.
156+
data["sensor"] = data["foot"] + "_sensor"
157+
data = data.set_index("sensor")
158+
data = data.groupby(level=0)
159+
data = {k: v.reset_index(drop=True) for k, v in data}
160+
161+
return data
162+
163+
133164
def get_healthy_example_mocap_data():
134165
"""Get 3D Mocap information of the foot synchronised with :func:`get_healthy_example_imu_data`.
135166

0 commit comments

Comments
 (0)