Skip to content

Commit 1f41e7a

Browse files
authored
Merge pull request #5 from adriensizaret/main-new-metrics-dev
Main new metrics dev
2 parents 3f9f345 + 66f0690 commit 1f41e7a

File tree

7 files changed

+404
-14
lines changed

7 files changed

+404
-14
lines changed

gaitalytics/features.py

Lines changed: 300 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import gaitalytics.model as model
1111
import gaitalytics.utils.linalg as linalg
1212
import gaitalytics.utils.mocap as mocap
13+
import gaitalytics.utils.math as math
1314

1415

1516
class FeatureCalculation(ABC):
@@ -246,7 +247,20 @@ def _get_progression_vector(self, trial: model.Trial) -> xr.DataArray:
246247
An xarray DataArray containing the calculated progression vector.
247248
"""
248249
return mocap.get_progression_vector(trial, self._config)
250+
251+
def _get_sagittal_vector(self, trial: model.Trial) -> xr.DataArray:
252+
"""Calculate the sagittal vector for a trial.
249253
254+
The sagittal vector is the vector normal to the sagittal plane.
255+
256+
Args:
257+
trial: The trial for which to calculate the sagittal vector.
258+
Returns:
259+
An xarray DataArray containing the calculated sagittal vector.
260+
"""
261+
progression_vector = self._get_progression_vector(trial)
262+
vertical_vector = xr.DataArray([0,0,1], dims=['axis'], coords={'axis': ['x', 'y', 'z']})
263+
return linalg.get_normal_vector(progression_vector, vertical_vector)
250264

251265
class TimeSeriesFeatures(_CycleFeaturesCalculation):
252266
"""Calculate time series features for a trial.
@@ -477,14 +491,19 @@ class SpatialFeatures(_PointDependentFeature):
477491
This class calculates following spatial features for a trial.
478492
- step_length
479493
- step_width
494+
- minimal_toe_clearance
495+
- AP_margin_of_stability
496+
- ML_margin_of_stability
480497
"""
481498

482499
def _calculate(self, trial: model.Trial) -> xr.DataArray:
483500

484501
"""Calculate the spatial features for a trial.
485502
486-
Definitions of the spatial features
487-
Hollmann et al. 2011 (doi: 10.1016/j.gaitpost.2011.03.024)
503+
Definitions of the spatial features:
504+
Step length & Step width: Hollmann et al. 2011 (doi: 10.1016/j.gaitpost.2011.03.024)
505+
Margin of stability: Jinfeng et al. 2021 (doi: 10.1152/jn.00091.2021)
506+
Minimal toe clearance: Schulz 2017 (doi: 10.1016/j.jbiomech.2017.02.024)
488507
489508
Args:
490509
trial: The trial for which to calculate the features.
@@ -499,18 +518,83 @@ def _calculate(self, trial: model.Trial) -> xr.DataArray:
499518
if trial.events is None:
500519
raise ValueError("Trial does not have events.")
501520

502-
if trial.events.attrs["context"] == "Right":
503-
ipsi_marker = mapping.MappedMarkers.R_TOE
504-
contra_marker = mapping.MappedMarkers.L_TOE
505-
else:
506-
ipsi_marker = mapping.MappedMarkers.L_TOE
507-
contra_marker = mapping.MappedMarkers.R_TOE
508-
509-
results_dict = self._calculate_step_length(trial, ipsi_marker, contra_marker)
521+
marker_dict = self.select_markers_for_spatial_features(trial)
522+
523+
results_dict = self._calculate_step_length(trial, marker_dict["ipsi_heel"], marker_dict["contra_heel"])
524+
results_dict.update(
525+
self._calculate_step_width(trial, marker_dict["ipsi_heel"], marker_dict["contra_heel"])
526+
)
527+
results_dict.update(
528+
self._calculate_stride_length(trial, marker_dict["ipsi_heel"], marker_dict["contra_heel"])
529+
)
510530
results_dict.update(
511-
self._calculate_step_width(trial, ipsi_marker, contra_marker)
531+
self._calculate_minimal_toe_clearance(trial, marker_dict["ipsi_toe_2"], marker_dict["ipsi_heel"], marker_dict["ipsi_toe_5"])
512532
)
533+
if marker_dict["xcom"] is not None:
534+
results_dict.update(
535+
self._calculate_AP_margin_of_stability(trial, marker_dict["ipsi_toe_2"], marker_dict["contra_toe_2"], marker_dict["xcom"])
536+
)
537+
if (marker_dict['ipsi_ankle'] is not None) and (marker_dict['contra_ankle'] is not None):
538+
results_dict.update(
539+
self._calculate_ML_margin_of_stability(trial, marker_dict['ipsi_ankle'], marker_dict['contra_ankle'], marker_dict["xcom"])
540+
)
541+
513542
return self._create_result_from_dict(results_dict)
543+
544+
545+
def select_markers_for_spatial_features(self,
546+
trial: model.Trial
547+
) -> dict[str, mapping.MappedMarkers]:
548+
"""Select markers based on the trial's context (Right or Left). If some markers are missing, return them as None
549+
550+
Args:
551+
trial: The trial object containing event attributes.
552+
553+
Returns:
554+
A dictionary of markers based on context
555+
"""
556+
if trial.events.attrs["context"] == "Right":
557+
ipsi_heel_marker = mapping.MappedMarkers.R_HEEL
558+
ipsi_toe_2_marker = mapping.MappedMarkers.R_TOE
559+
ipsi_toe_5_marker = self.get_optional_marker('R_TOE_5')
560+
ipsi_ankle_marker = self.get_optional_marker('R_ANKLE')
561+
562+
contra_toe_2_marker = mapping.MappedMarkers.L_TOE
563+
contra_heel_marker = mapping.MappedMarkers.L_HEEL
564+
contra_ankle_marker = self.get_optional_marker('L_ANKLE')
565+
566+
else:
567+
ipsi_heel_marker = mapping.MappedMarkers.L_HEEL
568+
ipsi_toe_2_marker = mapping.MappedMarkers.L_TOE
569+
ipsi_toe_5_marker = self.get_optional_marker('L_TOE_5')
570+
ipsi_ankle_marker = self.get_optional_marker('L_ANKLE')
571+
572+
contra_toe_2_marker = mapping.MappedMarkers.R_TOE
573+
contra_heel_marker = mapping.MappedMarkers.R_HEEL
574+
contra_ankle_marker = self.get_optional_marker('R_ANKLE')
575+
576+
xcom_marker = self.get_optional_marker('XCOM')
577+
578+
return {
579+
"ipsi_toe_2": ipsi_toe_2_marker,
580+
"ipsi_toe_5": ipsi_toe_5_marker,
581+
"ipsi_heel": ipsi_heel_marker,
582+
"ipsi_ankle": ipsi_ankle_marker,
583+
"contra_toe_2": contra_toe_2_marker,
584+
"contra_heel": contra_heel_marker,
585+
"contra_ankle": contra_ankle_marker,
586+
"xcom": xcom_marker
587+
}
588+
589+
590+
def get_optional_marker(self, marker_name: str) -> mapping.MappedMarkers | None:
591+
""" Returns the marker if exists, else returns None
592+
593+
Args:
594+
marker_name (str): The marker name
595+
"""
596+
return getattr(mapping.MappedMarkers, marker_name, None)
597+
514598

515599
def _calculate_step_length(
516600
self,
@@ -525,7 +609,6 @@ def _calculate_step_length(
525609
ipsi_marker: The ipsi-lateral heel marker.
526610
contra_marker: The contra-lateral heel marker.
527611
528-
529612
Returns:
530613
The calculated step length.
531614
"""
@@ -545,6 +628,7 @@ def _calculate_step_length(
545628
distance = linalg.calculate_distance(projected_ipsi, projected_contra).values
546629
return {"step_length": distance}
547630

631+
548632
def _calculate_step_width(
549633
self,
550634
trial: model.Trial,
@@ -577,3 +661,207 @@ def _calculate_step_width(
577661
distance = linalg.calculate_distance(ipsi_heel, projected_ipsi).values
578662

579663
return {"step_width": distance}
664+
665+
666+
def _calculate_stride_length(
667+
self,
668+
trial: model.Trial,
669+
ipsi_marker: mapping.MappedMarkers,
670+
contra_marker: mapping.MappedMarkers,
671+
) -> dict[str, np.ndarray]:
672+
"""Calculate the stride length for a trial.
673+
674+
Args:
675+
trial: The trial for which to calculate the stride length.
676+
ipsi_marker: The ipsi-lateral heel marker.
677+
contra_marker: The contra-lateral heel marker.
678+
679+
Returns:
680+
The calculated stride length.
681+
"""
682+
683+
event_times = self.get_event_times(trial.events)
684+
progress_axis = self._get_progression_vector(trial)
685+
progress_axis = linalg.normalize_vector(progress_axis)
686+
687+
total_distance = 0
688+
689+
for event_time in [event_times[2], event_times[-1]]:
690+
ipsi_heel = self._get_marker_data(trial, ipsi_marker).sel(time=event_time, method="nearest")
691+
contra_heel = self._get_marker_data(trial, contra_marker).sel(time=event_time, method="nearest")
692+
print(f'ipsi heel: {ipsi_heel}')
693+
694+
projected_ipsi = linalg.project_point_on_vector(ipsi_heel, progress_axis)
695+
projected_contra = linalg.project_point_on_vector(contra_heel, progress_axis)
696+
print(f'projected ipsi: {projected_ipsi}')
697+
698+
distance = linalg.calculate_distance(projected_ipsi, projected_contra).values
699+
print(f'distance: {distance}')
700+
total_distance += distance
701+
702+
703+
return {"stride_length": total_distance}
704+
705+
706+
def _calculate_minimal_toe_clearance(
707+
self,
708+
trial: model.Trial,
709+
ipsi_toe_marker: mapping.MappedMarkers,
710+
ipsi_heel_marker: mapping.MappedMarkers,
711+
*opt_ipsi_toe_markers: mapping.MappedMarkers
712+
) -> dict[str, np.ndarray]:
713+
"""Calculate the minimal toe clearance for a trial. Toe clearance is computed for all toe markers passed, only the minimal is returned
714+
715+
Args:
716+
trial (model.Trial): The trial to compute the minimal toe clearance for
717+
ipsi_toe_marker (mapping.MappedMarkers): The ipsilateral toe marker
718+
ipsi_heel_marker (mapping.MappedMarkers): The ipsilateral heel marker
719+
720+
Returns:
721+
dict[str, np.ndarray]: The calculated minimal toe clearance in a dict
722+
"""
723+
event_times = self.get_event_times(trial.events)
724+
725+
ipsi_heel = self._get_marker_data(trial, ipsi_heel_marker).sel(time=slice(event_times[3], event_times[4]))
726+
ipsi_toe = self._get_marker_data(trial, ipsi_toe_marker).sel(time=slice(event_times[3], event_times[4]))
727+
728+
toes_vel = linalg.calculate_speed_norm(ipsi_toe)
729+
730+
additional_meta_data = []
731+
732+
for meta_marker in opt_ipsi_toe_markers:
733+
if meta_marker is not None:
734+
meta_data = self._get_marker_data(trial, meta_marker).sel(time=slice(event_times[3], event_times[4]))
735+
toes_vel += linalg.calculate_speed_norm(meta_data)
736+
additional_meta_data.append(meta_data)
737+
738+
toes_vel /= (1 + len(additional_meta_data))
739+
740+
mtc_i = self._find_mtc_index(ipsi_toe, ipsi_heel, toes_vel)
741+
mtc_additional_indices = [
742+
self._find_mtc_index(meta_data, ipsi_heel, toes_vel)
743+
for meta_data in additional_meta_data
744+
]
745+
746+
# Handle NaN cases and find minimal clearance
747+
mtc_values = [] if np.isnan(mtc_i) else [ipsi_toe.sel(axis='z')[mtc_i]]
748+
for i, meta_data in zip(mtc_additional_indices, additional_meta_data):
749+
if not np.isnan(i):
750+
mtc_values.append(meta_data.sel(axis='z')[i])
751+
752+
if not mtc_values:
753+
return {"minimal_toe_clearance": np.NaN}
754+
755+
return {"minimal_toe_clearance": min(mtc_values)}
756+
757+
758+
def _find_mtc_index(self,
759+
toe_position: xr.DataArray,
760+
heel_position: xr.DataArray,
761+
toes_vel: xr.DataArray):
762+
"""Find the time corresponding to minimal toe clearance of a specific toe.
763+
Valid minimal toe clearance point must validates conditions
764+
defined in Schulz 2017 (doi: 10.1016/j.jbiomech.2017.02.024)
765+
Args:
766+
toe_position: A DataArray containing positions of the toe
767+
heel_position: A DataArray containing positions of the heel
768+
toes_vel: A DataArray containing the mean toes velocity at each timepoint
769+
Returns:
770+
The time corresponding to minimal toe clearance for the input toe.
771+
"""
772+
toes_vel_up_quant = np.quantile(toes_vel, .5)
773+
toe_z = toe_position.sel(axis='z')
774+
heel_z = heel_position.sel(axis='z')
775+
776+
# Check conditions according to Schulz 2017
777+
mtc_i = math.find_local_minimas(toe_z)
778+
mtc_i = [i for i in mtc_i if toes_vel[i] >= toes_vel_up_quant]
779+
mtc_i = [i for i in mtc_i if toe_z[i] <= heel_z[i]]
780+
781+
return np.NaN if not mtc_i else min(mtc_i, key=lambda i: toe_z[i])
782+
783+
784+
def _calculate_AP_margin_of_stability(self,
785+
trial: model.Trial,
786+
ipsi_toe_marker: mapping.MappedMarkers,
787+
contra_toe_marker: mapping.MappedMarkers,
788+
xcom_marker: mapping.MappedMarkers,
789+
) -> dict[str, np.ndarray]:
790+
"""Calculate the anterio-posterior margin of stability at heel strike
791+
Args:
792+
trial: The trial for which to calculate the AP margin of stability
793+
ipsi_toe_marker: The ipsi-lateral toe marker
794+
contra_marker: The contra-lateral toe marker
795+
xcom_marker: The extrapolated center of mass marker
796+
797+
Returns:
798+
The calculated anterio-posterior margin of stability in a dict
799+
"""
800+
event_times = self.get_event_times(trial.events)
801+
802+
ipsi_toe = self._get_marker_data(trial, ipsi_toe_marker).sel(
803+
time=event_times[0], method="nearest"
804+
)
805+
contra_toe = self._get_marker_data(trial, contra_toe_marker).sel(
806+
time=event_times[0], method="nearest"
807+
)
808+
xcom = self._get_marker_data(trial, xcom_marker).sel(
809+
time=event_times[0], method="nearest"
810+
)
811+
812+
progress_axis = self._get_progression_vector(trial)
813+
progress_axis = linalg.normalize_vector(progress_axis)
814+
815+
projected_ipsi = linalg.project_point_on_vector(ipsi_toe, progress_axis)
816+
projected_contra = linalg.project_point_on_vector(contra_toe, progress_axis)
817+
projected_xcom = linalg.project_point_on_vector(xcom, progress_axis)
818+
819+
bos_len = linalg.calculate_distance(projected_ipsi, projected_contra).values
820+
xcom_len = linalg.calculate_distance(projected_contra, projected_xcom).values
821+
822+
mos = bos_len - xcom_len
823+
824+
return {"AP_margin_of_stability": mos}
825+
826+
827+
def _calculate_ML_margin_of_stability(self,
828+
trial: model.Trial,
829+
ipsi_ankle_marker: mapping.MappedMarkers,
830+
contra_ankle_marker: mapping.MappedMarkers,
831+
xcom_marker: mapping.MappedMarkers
832+
) -> dict[str, np.ndarray]:
833+
"""Calculate the medio-lateral margin of stability at heel strike
834+
Args:
835+
trial: The trial for which to calculate the AP margin of stability
836+
ipsi_toe_marker: The ipsi-lateral lateral ankle marker
837+
contra_marker: The contra-lateral lateral ankle marker
838+
xcom_marker: The extrapolated center of mass marker
839+
840+
Returns:
841+
The calculated anterio-posterior margin of stability in a dict
842+
"""
843+
event_times = self.get_event_times(trial.events)
844+
845+
ipsi_ankle = self._get_marker_data(trial, ipsi_ankle_marker).sel(
846+
time=event_times[0], method="nearest"
847+
)
848+
contra_ankle = self._get_marker_data(trial, contra_ankle_marker).sel(
849+
time=event_times[0], method="nearest"
850+
)
851+
xcom = self._get_marker_data(trial, xcom_marker).sel(
852+
time=event_times[0], method="nearest"
853+
)
854+
855+
sagittal_axis = self._get_sagittal_vector(trial)
856+
sagittal_axis = linalg.normalize_vector(sagittal_axis)
857+
858+
projected_ipsi = linalg.project_point_on_vector(ipsi_ankle, sagittal_axis)
859+
projected_contra = linalg.project_point_on_vector(contra_ankle, sagittal_axis)
860+
projected_xcom = linalg.project_point_on_vector(xcom, sagittal_axis)
861+
862+
bos_len = linalg.calculate_distance(projected_contra, projected_ipsi).values
863+
xcom_len = linalg.calculate_distance(projected_contra, projected_xcom).values
864+
865+
mos = bos_len - xcom_len
866+
867+
return {"ML_margin_of_stability": mos}

gaitalytics/mapping.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,23 @@ class MappedMarkers(Enum):
88
# Foot
99
L_HEEL = "l_heel"
1010
R_HEEL = "r_heel"
11-
L_TOE = "l_toe"
11+
L_TOE = "l_toe"
1212
R_TOE = "r_toe"
13+
L_ANKLE = "left_lat_malleoli"
14+
R_ANKLE = "right_lat_malleoli"
15+
# Additional toe markers
16+
#L_TOE_5 = ""
17+
#R_TOE_5 = ""
1318

1419
# Hip
1520
L_ANT_HIP = "l_ant_hip"
1621
R_POST_HIP = "r_post_hip"
1722
L_POST_HIP = "l_post_hip"
1823
R_ANT_HIP = "r_ant_hip"
1924
SACRUM = "sacrum"
25+
26+
# Extrapolated center of mass marker for margin of stability
27+
#XCOM =
2028

2129

2230
class MappingConfigs:

0 commit comments

Comments
 (0)