Processing I: Motion tracking and balance

Overview

In the previous notebook, we have ran pose estimation on the trial videos using OpenPose, and triangulated the coordinates to get 3D coordinates for each trial using Pose2sim. Furthermore, we have performed inverse kinematics and dynamics to extract joint angles and moments using Pose2sim/OpenSim.

In this script, we will clean the 3D coordinates and joint angle data, and extract further information (such as speed, acceleration, etc.).

Code to load packages and prepare the environment
# packages
import os
import glob
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import scipy
import random

curfolder = os.getcwd()

# files to work with
MTfolder = os.path.join(curfolder, '..', '02_MotionTracking_processing', 'projectdata')
BBfolder = os.path.join(curfolder, '..', '01_XDF_processing', 'data', 'Data_processed', 'Data_trials')

# folders to save the processed data
MTfolder_processed = os.path.join(curfolder, 'TS_motiontracking')

if not os.path.exists(MTfolder_processed):
    os.makedirs(MTfolder_processed)

Motion processing - kinematics

Here we use the keypoint coordinates estimated via OpenPose and triangulated via Pose2Sim. While Pose2sim does provide in-built filter, it is not particularly strong and the data can be still noisy.

To decide on the smoothing strength, we can use a custom function check_smooth_strength to check the effect of different smoothing strengths on the data.

Code to prepare files to process
MTtotrack = glob.glob(os.path.join(MTfolder, '*','*'), recursive=True)

# get rid of all the folders that are not the ones we want to track, like .sto files
MTtotrack = [x for x in MTtotrack if 'sto' not in x]
MTtotrack = [x for x in MTtotrack if 'txt' not in x]
MTtotrack = [x for x in MTtotrack if 'xml' not in x]
MTtotrack = [x for x in MTtotrack if 'opensim' not in x]
MTtotrack = [x for x in MTtotrack if 'Results' not in x]
MTtotrack = [x for x in MTtotrack if 'toml' not in x]
MTtotrack = [x for x in MTtotrack if 'calibration' not in x]

MTfiles_all = []

for folder in MTtotrack:
    # last element is trialid
    trialid = folder.split('\\')[-1]
    if 'tpose' in trialid:
        continue
    
    # get all csv files in the folder
    csvfiles = glob.glob(os.path.join(folder, 'pose-3d', '*.csv'), recursive=True)
    # keep only the ones that have butterworth in the name - those are pre-processed
    csvfiles = [x for x in csvfiles if 'processed' in x]
    processedfile = csvfiles[0]
    # append to list with trialid
    MTfiles_all.append([trialid, processedfile])

print(f"Found {len(MTfiles_all)} files to process")
Found 8114 files to process
Code with function to check smoothing strength
def check_smooth_strength(df, windows, orders, keytoplot):
    """
    Applies Savitzky-Golay smoothing to a specified column in a DataFrame with various window sizes and polynomial orders,
    then plots the smoothed and original data for comparison.

    Parameters:
    -----------
    df : pandas.DataFrame
        Input DataFrame containing the data to be smoothed.
    windows : list of int
        List of window lengths to use for Savitzky-Golay smoothing.
    orders : list of int
        List of polynomial orders to use for Savitzky-Golay smoothing.
    keytoplot : str
        Name of the column in the DataFrame to be smoothed and plotted.

    Returns:
    --------
    None
        The function creates and displays a plot but does not return any values.
    """

    # prepare new df
    df_smooth = pd.DataFrame()

    for win in windows:
        for ord in orders:
            df_smooth[keytoplot + '_savgol' + str(win) + '_' + str(ord)] = scipy.signal.savgol_filter(df[keytoplot], win, ord)

    # make R_Hand_x from df_sample a list
    keytoplot_unsmoothed = df[keytoplot].tolist()

    # load these values into df_smooth as a new column
    df_smooth[keytoplot] = keytoplot_unsmoothed

    # plot keytoplot in all strengths
    colstoplot = [x for x in df_smooth.columns if keytoplot in x]
    plt.figure()
    for col in colstoplot:
        plt.plot(df_smooth[col], label=col)

    plt.legend()
    plt.show()

Here we can see a timeseries of vertical dimension of the left knee. Each color represents the timeseries in different smoothed version, pink one is the raw signal. The first number in the legend corresponds to window length and the second number to polynomial order.

Here we can see different setting options for wrist.

Legs seem to be more noisy than arms. One reason could be that legs are more commonly covered by clothes, which can make the pose estimation more prone to errors. Also, legs often stay without movement, making them more sensitive to noise.

For that reason, we opt for different smoothing strengths for leg-related keypoints than for upper body.

For lower body positional data, we will use a Savitzky-Golay filter with order 3 and span of ~580 ms (35).

For upper body positional data, a Savitzky-Golay filter with order 3 and span of ~400 ms (25) seems to be a good choice. We will use it both for raw coordinates as well as for the derivatives.

Further, we obtain the first, second and third derivative of the timeseries, namely speed, acceleration, and jerk. For derivatives, we will use a Savitzky-Golay filter with order 3 and span of ~400 ms (25) for both.

Lastly, to be able to work with timeseries that represent bigger segment of body than a single joint, we aggregate the kinematic derivatives for each body group (i.e., head, upperbody, arms, lowerbody) by computing euclidian sum over every derivative belonging to the group. This gives us, for instance, a measure for arm speed that represents a sum of speeds of all keypoints associated with the arm (i.e., wrist, elbow, shoulder, index)

Code with functions for processing kinematic data
def aggregate_keypoints(df, measurement, finalcolname, use):
    """
    Aggregates keypoint data by calculating Euclidean sums for predefined groups of keypoints.

    Parameters:
    -----------
    df : pandas.DataFrame
        Input dataframe containing keypoint data
    measurement : str
        Measurement type to filter columns (e.g., 'speed', 'acceleration')
    finalcolname : str
        Suffix to append to the aggregated column names
    use : str
        Type of data processing ('kinematics' or 'angles') that determines which keypoint groups to use

    Returns:
    --------
    pandas.DataFrame
        DataFrame with additional columns containing aggregated Euclidean sums for each keypoint group

    Notes:
    ------
    - For 'kinematics' use, groups keypoints into lower body, legs, head, and arms
    - For 'angles' use, groups keypoints into pelvis, spine, lower body, legs, head, and arms
    - Each group's Euclidean sum is calculated and stored in a new column with name '{groupname}{finalcolname}'
    - The Euclidean sum is calculated as sqrt(sum(values^2)) for each row
    """

    if use == 'kinematics':
        # group keypoints that belong together
        lowerbodycols = ['RHip', 'LHip']
        legcols = ['RKnee', 'RAnkle', 'LAnkle', 'LKnee', 'RHeel', 'LHeel']
        headcols = ['Head', 'Neck', 'Nose']
        armcols = ['RShoulder', 'RElbow', 'RWrist', 'LShoulder', 'LElbow', 'LWrist', 'RIndex', 'LIndex']

        groups = [lowerbodycols, legcols, headcols, armcols]

    elif use == 'angles':
        pelviscols = ['pelvis']
        spinecols = ['L5_S1', 'L4_L5', 'L3_L4', 'L2_L3', 'L1_L2', 'L1_T12']
        lowerbodycols = ['pelvis', 'hip']
        legcols = ['knee', 'ankle', 'subtalar']
        headcols = ['neck']
        armcols = ['arm', 'elbow', 'wrist', 'pro_sup']

        groups = [lowerbodycols, legcols, headcols, armcols, pelviscols, spinecols]

    # make subdf only with speed
    subdf = df[[x for x in df.columns if measurement in x]]

    # loop through each joint group
    for group in groups:
        # get cols
        cols = [x for x in subdf.columns if any(y in x for y in group)]
        subdf_temp = subdf[cols]

        for index, row in subdf_temp.iterrows():
            # get all values of that row
            values = row.values
            # calculate euclidian sum
            euclidian_sum = np.sqrt(np.sum(np.square(values))) ## FLAGGED: possibly normalize
            # get a name for new col
            if group == lowerbodycols:
                colname = 'lowerbody'
            elif group == legcols:
                colname = 'leg'
            elif group == headcols:
                colname = 'head'
            elif group == armcols:
                colname = 'arm'
            elif group == pelviscols:
                colname = 'pelvis'
            elif group == spinecols:
                colname = 'spine'

            df.loc[index, colname + finalcolname] = euclidian_sum

    if use == 'kinematics':
        # group keypoints that belong together
        lowerbodycols = ['RHip', 'LHip']
        legcols = ['RKnee', 'RAnkle', 'LAnkle', 'LKnee', 'RHeel', 'LHeel']
        headcols = ['Head', 'Neck', 'Nose']
        armcols = ['RShoulder', 'RElbow', 'RWrist', 'LShoulder', 'LElbow', 'LWrist', 'RIndex', 'LIndex']

        groups = [lowerbodycols, legcols, headcols, armcols]

    elif use == 'angles':
        pelviscols = ['pelvis']
        spinecols = ['L5_S1', 'L4_L5', 'L3_L4', 'L2_L3', 'L1_L2', 'L1_T12']
        lowerbodycols = ['pelvis', 'hip']
        legcols = ['knee', 'ankle', 'subtalar']
        headcols = ['neck']
        armcols = ['arm', 'elbow', 'wrist', 'pro_sup']

        groups = [lowerbodycols, legcols, headcols, armcols, pelviscols, spinecols]

    # make subdf only with speed
    subdf = df[[x for x in df.columns if measurement in x]]

    # loop through each joint group
    for group in groups:
        # get cols
        cols = [x for x in subdf.columns if any(y in x for y in group)]
        subdf_temp = subdf[cols]

        for index, row in subdf_temp.iterrows():
            # get all values of that row
            values = row.values
            # calculate euclidian sum
            euclidian_sum = np.sqrt(np.sum(np.square(values))) ## FLAGGED: possibly normalize
            # get a name for new col
            if group == lowerbodycols:
                colname = 'lowerbody'
            elif group == legcols:
                colname = 'leg'
            elif group == headcols:
                colname = 'head'
            elif group == armcols:
                colname = 'arm'
            elif group == pelviscols:
                colname = 'pelvis'
            elif group == spinecols:
                colname = 'spine'
                

            df.loc[index, colname + finalcolname] = euclidian_sum

    return df

def get_derivatives(df, sr, use):
    """
    Computes speed, acceleration, and jerk for each keypoint or angle in the dataframe.

    Parameters:
    -----------
    df : pandas.DataFrame
        Input dataframe containing keypoint coordinate data (columns with '_x', '_y', '_z' suffixes)
        or angle data (all columns except the first 'time' column)
    sr : int or float
        Sampling rate in Hz, used to scale finite differences to per-second units
    use : str
        Type of data processing:
        - 'kinematics': computes 3D Euclidean speed, acceleration, and jerk from x/y/z coordinates;
          also computes vertical velocity for wrist keypoints
        - 'angles': computes angular speed, acceleration, and jerk from angle columns

    Returns:
    --------
    pandas.DataFrame
        Input dataframe with additional columns for each keypoint or angle:
        '{col}_speed', '{col}_acc', '{col}_jerk'
        For wrist keypoints (kinematics only), also adds '{col}_vertvel' (not yet stored in df)

    Notes:
    ------
    - Derivatives are computed as finite differences (np.diff) with a leading zero inserted
    - Finite differences are scaled by sr to convert from per-sample to per-second units
    - Each derivative is smoothed with a Savitzky-Golay filter (window=25, order=3 for kinematics;
      window=35, order=1 for angles); falls back to (10, 2) then (5, 2) if the signal is too short
    - Acceleration is derived from the smoothed speed; jerk from the smoothed acceleration
    """

    mtcols = df.columns
    if use == 'kinematics':
        # get rid of cols that are not x, y or z
        mtcols = [x for x in mtcols if '_x' in x or '_y' in x or '_z' in x]
    

        # prepare cols for speed
        cols = [x.split('_')[0] for x in mtcols]
        colsforspeed = list(set(cols))

        # for each unique colname (cols), calculate speed 
        for col in colsforspeed:
            # get x and y columns
            x = df[col + '_x']
            y = df[col + '_y']
            z = df[col + '_z'] # note that y and z are flipped
            # calculate speed
            speed = np.insert(np.sqrt(np.diff(x)**2 + np.diff(y)**2 + np.diff(z)**2),0,0)
            # multiply the values by sr, because now we have values in m/(s/sr)
            speed = speed*sr

            # smooth
            try:
                speed = scipy.signal.savgol_filter(speed, 25, 3)
            except ValueError:
                # if there is a ValueError, it means that the window is too large for the data, so we will use a smaller window
                try:
                    speed = scipy.signal.savgol_filter(speed, 10, 2)
                except ValueError:
                    speed = scipy.signal.savgol_filter(speed, 5, 2)

            # if the col contains wrist, we will alco calculate the vertical velocity (z dimension)
            if 'Wrist' in col:
                verticvel = np.insert(np.diff(z), 0, 0)
                verticvel = verticvel*sr
                try:
                    verticvel = scipy.signal.savgol_filter(verticvel, 25, 3)
                except ValueError:
                    # if there is a ValueError, it means that the window is too large for the data, so we will use a smaller window
                    try:
                        verticvel = scipy.signal.savgol_filter(verticvel, 10, 2)
                    except ValueError:
                        verticvel = scipy.signal.savgol_filter(verticvel, 5, 2)

            # derive acceleration   
            acceleration = np.insert(np.diff(speed), 0, 0)
            try:
                acceleration = scipy.signal.savgol_filter(acceleration, 25, 3)
            except ValueError:
                # if there is a ValueError, it means that the window is too large for the data, so we will use a smaller window
                try:
                    acceleration = scipy.signal.savgol_filter(acceleration, 10, 2)
                except ValueError:
                    acceleration = scipy.signal.savgol_filter(acceleration, 5, 2)

            # derive jerk
            jerk = np.insert(np.diff(acceleration), 0, 0)
            try:
                jerk = scipy.signal.savgol_filter(jerk, 25, 3)
            except ValueError:
                # if there is a ValueError, it means that the window is too large for the data, so we will use a smaller window
                try:    
                    jerk = scipy.signal.savgol_filter(jerk, 10, 2)
                except ValueError:
                    jerk = scipy.signal.savgol_filter(jerk, 5, 2)

            # new_data
            new_data = pd.DataFrame({col + '_speed': speed, col + '_acc': acceleration, col + '_jerk': jerk})
            df = pd.concat([df, new_data], axis=1)

    elif use == 'angles':
        # get rid of cols that are not angles (so skip time)
        mtcols = mtcols[1:]

        # derive speed
        for col in mtcols:
            speed = np.insert(np.diff(df[col]), 0, 0)
            speed = speed*sr
            try:
                speed = scipy.signal.savgol_filter(speed, 35, 3)
            except ValueError:
                # if there is a ValueError, it means that the window is too large for the data, so we will use a smaller window
                try:
                    speed = scipy.signal.savgol_filter(speed, 10, 2)
                except ValueError:
                    speed = scipy.signal.savgol_filter(speed, 5, 2)

            # derive acceleration
            acceleration = np.insert(np.diff(speed), 0, 0)
            try:
                acceleration = scipy.signal.savgol_filter(acceleration, 35, 3)
            except ValueError:
                # if there is a ValueError, it means that the window is too large for the data, so we will use a smaller window
                try:
                    acceleration = scipy.signal.savgol_filter(acceleration, 10, 2)
                except ValueError:
                    acceleration = scipy.signal.savgol_filter(acceleration, 5, 2)

            
            # derive jerk
            jerk = np.insert(np.diff(acceleration), 0, 0)
            try:
                jerk = scipy.signal.savgol_filter(jerk, 35, 3)
            except ValueError:
                # if there is a ValueError, it means that the window is too large for the data, so we will use a smaller window
                try:    
                    jerk = scipy.signal.savgol_filter(jerk, 10, 2)
                except ValueError:
                    jerk = scipy.signal.savgol_filter(jerk, 5, 2)

            # new_data
            new_data = pd.DataFrame({col + '_speed': speed, col + '_acc': acceleration, col + '_jerk': jerk})
            df = pd.concat([df, new_data], axis=1)

    return df
# upper body cols
upperbodycols = ['Head', 'Neck', 'RShoulder', 'RElbow', 'RWrist', 'LShoulder', 'LElbow', 'LWrist', 'Nose', 'RIndex', 'LIndex']
# lower body cols
lowerbodycols = ['RHip', 'RKnee', 'RAnkle', 'RHeel', 'LHip', 'LKnee', 'LAnkle', 'LHeel']

for folder in MTtotrack:
    trialid = folder.split(os.sep)[-1]
    print('working on: ' + trialid)

    # get all csv files in the folder
    csvfiles = glob.glob(os.path.join(folder, 'pose-3d', '*processed.csv'), recursive=True)
    processedfile = csvfiles[0]
    mt = pd.read_csv(processedfile)
    
    # the mt is missing 0 ms timepoint, so we need to create a row that copies the first row of mt and time = 0
    padrow = mt.iloc[0].copy()
    padrow['Time'] = 0

    # concatenate it to the beginning of mt 
    mt = pd.concat([pd.DataFrame(padrow).T, mt], ignore_index=True)

    # keep only cols of interest
    colstokeep = ["Time", "RHip", "RKnee", "RAnkle", "RHeel", "LHip", "LKnee", "LAnkle", "LHeel", 
                  "Neck", "Head", "Nose", "RShoulder", "RElbow", "RWrist", "RIndex", "LShoulder", 
                  "LElbow", "LWrist", "LIndex"]
    mt = mt[[col for col in mt.columns if any(x in col for x in colstokeep)]]

    ####### SMOOTHING ######

    # smooth all columns except time with savgol
    mtcols = mt.columns
    colstosmooth = mtcols[:-1]
    mt_smooth = pd.DataFrame()

    for col in colstosmooth:
        colname = col.split('_')[0] # to get rid of _x, _y, _z
        if colname in upperbodycols:
            try:
                mt_smooth[col] = scipy.signal.savgol_filter(mt[col], 25, 3)
            except ValueError:
                # if there is a ValueError, it means that the window is too large for the data, so we will use a smaller window
                try:
                    mt_smooth[col] = scipy.signal.savgol_filter(mt[col], 10, 2)
                except ValueError:
                    mt_smooth[col] = scipy.signal.savgol_filter(mt[col], 5, 2)
        elif colname in lowerbodycols:
            try:
                mt_smooth[col] = scipy.signal.savgol_filter(mt[col], 35, 3)
            except ValueError:
                # if there is a ValueError, it means that the window is too large for the data, so we will use a smaller window
                try:
                    mt_smooth[col] = scipy.signal.savgol_filter(mt[col], 10, 2)
                except ValueError:
                    mt_smooth[col] = scipy.signal.savgol_filter(mt[col], 5, 2)

    # And put them all to cms
    mt_smooth = mt_smooth*100

    # add back time column
    mt_smooth['Time'] = mt['Time']

    # get sampling rate
    sr = 1/np.mean(np.diff(mt['Time']))

    ###### DERIVATIVES ######

    # get kinematic derivatives
    mt_smooth = get_derivatives(mt_smooth, sr, 'kinematics')

    ###### AGGREGATING ######

    mt_smooth = aggregate_keypoints(mt_smooth, 'speed', '_speedKin_sum', 'kinematics')
    mt_smooth = aggregate_keypoints(mt_smooth, 'acc', '_accKin_sum', 'kinematics')
    mt_smooth = aggregate_keypoints(mt_smooth, 'jerk', '_jerkKin_sum', 'kinematics')

    # add trialid
    mt_smooth['TrialID'] = trialid
    # convert time to ms
    mt_smooth['Time'] = mt_smooth['Time']*1000
    # write to csv
    mt_smooth.to_csv(os.path.join(MTfolder_processed, 'mt_' + trialid + '.csv'), index=False)

Here is an example of the file

RHip_x RHip_y RHip_z RKnee_x RKnee_y RKnee_z RAnkle_x RAnkle_y RAnkle_z RHeel_x ... arm_speedKin_sum lowerbody_accKin_sum leg_accKin_sum head_accKin_sum arm_accKin_sum lowerbody_jerkKin_sum leg_jerkKin_sum head_jerkKin_sum arm_jerkKin_sum TrialID
0 30.704144 62.908071 98.773741 28.362702 19.538785 99.241641 29.094579 -25.516594 100.362822 27.376412 ... 43.022056 0.435711 1.905181 0.156356 3.169447 0.263902 0.321743 0.068385 0.665490 50_2_38_p1
1 30.738393 63.102124 98.781712 28.352873 19.643868 99.199836 29.147774 -25.648845 100.534907 27.428319 ... 41.283941 0.169279 1.470270 0.067637 2.699970 0.254439 0.326811 0.065026 0.646585 50_2_38_p1
2 30.763372 63.276455 98.780302 28.336202 19.740445 99.152301 29.195033 -25.766824 100.688211 27.474252 ... 39.652834 0.423540 1.155936 0.082119 2.561083 0.236845 0.325821 0.060222 0.624480 50_2_38_p1
3 30.779790 63.432081 98.770246 28.313357 19.828937 99.099673 29.236664 -25.871251 100.823691 27.514526 ... 38.005084 0.687406 0.969493 0.142141 2.607945 0.212326 0.317544 0.054185 0.590040 50_2_38_p1
4 30.788358 63.570018 98.752280 28.285006 19.909766 99.042586 29.272978 -25.962845 100.942307 27.549461 ... 36.281933 0.888293 0.900395 0.193325 2.701075 0.182187 0.301875 0.047153 0.540715 50_2_38_p1
5 30.789786 63.691282 98.727140 28.251816 19.983353 98.981674 29.304281 -26.042324 101.045019 27.579373 ... 34.476821 1.025697 0.910664 0.230814 2.755929 0.147929 0.279365 0.039385 0.478788 50_2_38_p1
6 30.784784 63.796889 98.695561 28.214454 20.050120 98.917574 29.330883 -26.110409 101.132786 27.604580 ... 32.624162 1.104014 0.954907 0.254977 2.733663 0.111578 0.250976 0.031189 0.411251 50_2_38_p1
7 30.774062 63.887856 98.658278 28.173589 20.110489 98.850919 29.353092 -26.167820 101.206567 27.625399 ... 30.790423 1.128678 1.001516 0.267048 2.622221 0.076941 0.217986 0.022975 0.351693 50_2_38_p1
8 30.758331 63.965198 98.616029 28.129887 20.164881 98.782345 29.371217 -26.215274 101.267321 27.642147 ... 29.066827 1.105595 1.034407 0.268521 2.425406 0.054599 0.182004 0.015503 0.322044 50_2_38_p1
9 30.738300 64.029931 98.569547 28.084017 20.213719 98.712486 29.385566 -26.253492 101.316008 27.655143 ... 27.562159 1.041118 1.047294 0.261021 2.159799 0.062701 0.145181 0.010810 0.342758 50_2_38_p1
10 30.714681 64.083072 98.519569 28.036645 20.257423 98.641978 29.396448 -26.283193 101.353586 27.664704 ... 26.393659 0.942296 1.039124 0.246285 1.859249 0.093065 0.110838 0.012119 0.411765 50_2_38_p1
11 30.688182 64.125636 98.466830 27.988439 20.296417 98.571455 29.404172 -26.305097 101.381016 27.671148 ... 25.674661 0.817559 1.011535 0.226193 1.590183 0.129070 0.085008 0.017433 0.509856 50_2_38_p1
12 30.659514 64.158641 98.412066 27.940068 20.331120 98.501552 29.409045 -26.319922 101.399257 27.674791 ... 25.500315 0.678437 0.967525 0.202840 1.471121 0.164465 0.076864 0.023413 0.619582 50_2_38_p1
13 30.629388 64.183101 98.356013 27.892197 20.361955 98.432904 29.411377 -26.328389 101.409267 27.675951 ... 23.356353 0.563076 0.972761 0.194495 1.703494 0.211432 0.103151 0.032314 0.761144 50_2_38_p1
14 30.598514 64.200034 98.299405 27.845495 20.389344 98.366146 29.411476 -26.331216 101.412006 27.674946 ... 25.797038 0.459483 0.864288 0.169402 2.189639 0.229108 0.113742 0.034947 0.846482 50_2_38_p1

15 rows × 128 columns


Let’s check one file to see how the data looks like by plotting RWrist and its kinematics, and also the euclidian sum for the whole arm along with it (as dashed black line)

Note that aggregates will always be directionless (i.e., in positive numbers) as they are squared when computed.

loaded file:  f:\flesh_data_processed\03_TS_processing\TS_motiontracking\mt_10_2_93_p1.csv

Motion processing - inverse kinematics

In the previous notebook, we have extracted joint angles using OpenSim (Seth et al., 2018). Now again, we clean the data, smooth them, and extract further information before saving it into csv file per trial

We can once again check what would be the proper filter, below plot for wrist flexion

Code to prepare environment
# get all mot files in the folder
mot_files = glob.glob(os.path.join(MTfolder, '*', '*', 'kinematics', '*smoothed.mot'), recursive=True)
keypoints = ['wrist', 'pro_sup', 'elbow', 'arm', 'neck', 'subtalar', 'ankle', 'knee', 'hip', 'pelvis', 'L5_S1', 'L4_L5', 'L3_L4', 'L2_L3', 'L1_L2', 'L1_T12']

And for legs

We will apply a stronger filter of 1st order with span of ~580 ms because the data are more noisy than the kinematics.

# get all mot files in the folder
mot_files = glob.glob(os.path.join(MTfolder, '*', '*', 'kinematics', '*smoothed.mot'), recursive=True)
keypoints = ['wrist', 'pro_sup', 'elbow', 'arm', 'neck', 'subtalar', 'ankle', 'knee', 'hip', 'pelvis', 'L5_S1', 'L4_L5', 'L3_L4', 'L2_L3', 'L1_L2', 'L1_T12']

for mot in mot_files:

    trialid = mot.split(os.sep)[-1].split('.')[0]
    if 'rein' in trialid:
        trialid = '_'.join(trialid.split('_')[:5])
    else:
        trialid = '_'.join(trialid.split('_')[:4])
        
    print('working on ' + trialid)
    
    # load it
    mot_df = pd.read_csv(mot, sep='\t', skiprows=10)
    
    # check if the time starts with 0, if not, pad it
    if mot_df['time'].iloc[0] != 0:
        # pad 0 ms row
        padrow = mot_df.iloc[0].copy()
        padrow['time'] = 0

        # concatenate it to the beginning of mot_df
        mot_df = pd.concat([pd.DataFrame(padrow).T, mot_df], ignore_index=True)
        
    # get the sr
    sr = 1/np.mean(np.diff(mot_df['time']))

    ##### SMOOTHING ######

    # smooth all columns except the firts time (time) and last (trialid)
    colstosmooth = [x for x in mot_df.columns if 'time' not in x]

    for col in colstosmooth:
        try:
            mot_df[col] = scipy.signal.savgol_filter(mot_df[col], 35, 3)
        except ValueError:
            # if there is a ValueError, it means that the window is too large for the data, so we will use a smaller window
            try:
                mot_df[col] = scipy.signal.savgol_filter(mot_df[col], 10, 2)
            except ValueError:
                mot_df[col] = scipy.signal.savgol_filter(mot_df[col], 5, 2)
        
        # convert to radians
        mot_df[col] = np.deg2rad(mot_df[col])

    # keep only columns we might use
    coi = [x for x in mot_df.columns if any(y in x for y in keypoints) or 'time' in x or 'TrialID' in x]
    mot_df2 = mot_df[coi]

    ##### DERIVATIVES ######

    # get derivatives
    mot_df2 = get_derivatives(mot_df2, sr, 'angles')

    #### AGGREGATING #####

    mot_df2 = aggregate_keypoints(mot_df2, 'speed', '_angSpeed_sum', 'angles')
    mot_df2 = aggregate_keypoints(mot_df2, 'acc', '_angAcc_sum', 'angles')
    mot_df2 = aggregate_keypoints(mot_df2, 'jerk', '_angJerk_sum', 'angles')

    # add time and trialid
    mot_df2['time'] = mot_df['time']
    # convert time to ms
    mot_df2['time'] = mot_df2['time']*1000
    mot_df2['TrialID'] = trialid

    # write to csv
    mot_df2.to_csv(os.path.join(MTfolder_processed, 'ik_' + trialid + '.csv'), index=False)
    

Here is an example file

time pelvis_tilt pelvis_list pelvis_rotation pelvis_tx pelvis_ty pelvis_tz hip_flexion_r hip_adduction_r hip_rotation_r ... arm_angAcc_sum pelvis_angAcc_sum spine_angAcc_sum lowerbody_angJerk_sum leg_angJerk_sum head_angJerk_sum arm_angJerk_sum pelvis_angJerk_sum spine_angJerk_sum TrialID
0 0.000 -0.064934 -0.050629 1.619729 0.003211 0.008142 0.017670 0.095395 -0.052896 -0.205834 ... 0.146479 0.007684 0.006730 0.000582 0.000298 0.000637 0.006851 0.000226 0.000133 26_2_14_p0
1 16.949 -0.064509 -0.051991 1.622033 0.003198 0.008142 0.017675 0.102023 -0.054528 -0.207591 ... 0.141030 0.007471 0.006677 0.000577 0.000304 0.000607 0.006655 0.000223 0.000128 26_2_14_p0
2 33.898 -0.064153 -0.053048 1.624021 0.003187 0.008143 0.017679 0.107675 -0.055970 -0.209003 ... 0.135628 0.007260 0.006627 0.000572 0.000310 0.000577 0.006465 0.000221 0.000123 26_2_14_p0
3 50.847 -0.063860 -0.053825 1.625711 0.003177 0.008143 0.017683 0.112417 -0.057231 -0.210092 ... 0.130276 0.007050 0.006577 0.000568 0.000316 0.000547 0.006283 0.000219 0.000118 26_2_14_p0
4 67.797 -0.063625 -0.054350 1.627122 0.003169 0.008145 0.017686 0.116311 -0.058318 -0.210882 ... 0.124983 0.006843 0.006530 0.000564 0.000323 0.000518 0.006110 0.000218 0.000114 26_2_14_p0
5 84.746 -0.063443 -0.054649 1.628273 0.003162 0.008146 0.017688 0.119419 -0.059240 -0.211392 ... 0.119756 0.006637 0.006484 0.000561 0.000330 0.000490 0.005945 0.000217 0.000109 26_2_14_p0
6 101.695 -0.063308 -0.054750 1.629182 0.003157 0.008148 0.017691 0.121805 -0.060004 -0.211647 ... 0.114603 0.006434 0.006439 0.000558 0.000338 0.000462 0.005789 0.000217 0.000105 26_2_14_p0
7 118.644 -0.063215 -0.054679 1.629868 0.003153 0.008150 0.017692 0.123533 -0.060617 -0.211667 ... 0.109536 0.006233 0.006397 0.000557 0.000346 0.000434 0.005645 0.000216 0.000101 26_2_14_p0
8 135.593 -0.063158 -0.054463 1.630351 0.003149 0.008152 0.017694 0.124664 -0.061088 -0.211475 ... 0.104567 0.006035 0.006356 0.000556 0.000354 0.000408 0.005511 0.000217 0.000097 26_2_14_p0
9 152.542 -0.063132 -0.054128 1.630647 0.003147 0.008155 0.017695 0.125263 -0.061424 -0.211092 ... 0.099709 0.005841 0.006317 0.000555 0.000362 0.000382 0.005390 0.000218 0.000094 26_2_14_p0
10 169.492 -0.063132 -0.053703 1.630777 0.003146 0.008157 0.017697 0.125392 -0.061634 -0.210542 ... 0.094982 0.005650 0.006279 0.000556 0.000371 0.000358 0.005281 0.000219 0.000091 26_2_14_p0
11 186.441 -0.063151 -0.053213 1.630759 0.003146 0.008159 0.017698 0.125114 -0.061724 -0.209845 ... 0.090404 0.005462 0.006244 0.000557 0.000380 0.000335 0.005186 0.000220 0.000088 26_2_14_p0
12 203.390 -0.063186 -0.052685 1.630611 0.003147 0.008162 0.017699 0.124492 -0.061703 -0.209024 ... 0.086000 0.005279 0.006210 0.000558 0.000389 0.000314 0.005106 0.000222 0.000085 26_2_14_p0
13 220.339 -0.063229 -0.052146 1.630352 0.003148 0.008164 0.017700 0.123590 -0.061579 -0.208102 ... 0.081798 0.005101 0.006178 0.000561 0.000399 0.000295 0.005041 0.000225 0.000083 26_2_14_p0
14 237.288 -0.063277 -0.051623 1.630001 0.003150 0.008167 0.017701 0.122471 -0.061358 -0.207099 ... 0.077830 0.004928 0.006148 0.000563 0.000409 0.000279 0.004991 0.000228 0.000082 26_2_14_p0

15 rows × 240 columns


Here we can see the joint angle speed next to kinematic speed.

loaded ik file:  f:\flesh_data_processed\03_TS_processing\TS_motiontracking\ik_10_1_11_p1.csv

Motion processing - inverse dynamics

Now we do exactly the same also for inverse dynamics data (joint torques/moments).

Code to prepare environment
# in MTfolders, find all sto files
sto_files = glob.glob(os.path.join(MTfolder, '*', '*', 'kinematics', '*smoothed.sto'), recursive=True)

Let’s once again check the different smoothing strengths, below for torque of elbow flexion

And for legs

We will a Savitzky-Golay filter with order 3 and span of 350 ms (21) for the moments and their first derivate (torque/moment change).

# in MTfolders, find all sto files
sto_files = glob.glob(os.path.join(MTfolder, '*', '*', 'kinematics', '*smoothed.sto'), recursive=True)

for sto in sto_files:

    # get trialid
    trialid = sto.split(os.sep)[-1].split('.')[0]

    if 'rein' in trialid:
        trialid = '_'.join(trialid.split('_')[:5])
    else:
        trialid = '_'.join(trialid.split('_')[:4])
        
    print('working on ' + trialid)

    # load it
    id_df = pd.read_csv(sto, sep='\t', skiprows=6)

    # get the sr
    sr = 1/np.mean(np.diff(id_df['time']))
    
    # if the first time is negative, make it zero
    if id_df['time'].iloc[0] < 0:
        id_df['time'] = id_df['time'] - id_df['time'].iloc[0]

    # if the first time is not zero but positive, we will pad a row with 0 time and the same values as the first row
    elif id_df['time'].iloc[0] > 0:
        padrow = id_df.iloc[0].copy()
        padrow['time'] = 0
        # concatenate it to the beginning of id_df
        id_df = pd.concat([pd.DataFrame(padrow).T, id_df], ignore_index=True)

    ##### SMOOTHING #####

    # smooth all columns except the firts time (time) and last (trialid)
    colstosmooth = [x for x in id_df.columns if 'time' not in x]
    colstosmooth = [x for x in colstosmooth if 'TrialID' not in x]

    for col in colstosmooth:
        try:
            id_df[col] = scipy.signal.savgol_filter(id_df[col], 21, 3) 
        except ValueError:
            # if there is a ValueError, it means that the window is too large for the data, so we will use a smaller window
            try:
                id_df[col] = scipy.signal.savgol_filter(id_df[col], 15, 3)
            except ValueError:
                id_df[col] = scipy.signal.savgol_filter(id_df[col], 5, 3)

    ##### AGGREGATING #####

    id_df = aggregate_keypoints(id_df, 'moment', '_moment_sum', 'angles')

    #### TORQUE CHANGE #####

    # for each moment col, we will also calculate the change 
    torquestodiff = [x for x in id_df.columns if 'moment' in x]

    for col in torquestodiff:
        torquechange = np.abs(np.insert(np.diff(id_df[col]), 0, 0))
        try:
            torquechange_smoothed = scipy.signal.savgol_filter(torquechange, 21, 3)
        except ValueError:
            # if there is a ValueError, it means that the window is too large for the data, so we will use a smaller window
            try:
                torquechange_smoothed = scipy.signal.savgol_filter(torquechange, 15, 3)
            except ValueError:
                torquechange_smoothed = scipy.signal.savgol_filter(torquechange, 5, 3)
        # new data
        new_data = pd.DataFrame({col + '_change': torquechange_smoothed})
        id_df = pd.concat([id_df, new_data], axis=1)

        # convert to Nm/s (instead of s/sr)
        id_df[col + '_change'] = id_df[col + '_change'] * sr
    
    # convert time to ms
    id_df['time'] = id_df['time']*1000
    # add trialid
    id_df['TrialID'] = trialid

    # write to csv
    id_df.to_csv(os.path.join(MTfolder_processed, 'id_' + trialid + '.csv'), index=False)

Here is an example file

time pelvis_tilt_moment pelvis_list_moment pelvis_rotation_moment pelvis_tx_force pelvis_ty_force pelvis_tz_force hip_flexion_r_moment hip_adduction_r_moment hip_rotation_r_moment ... wrist_dev_r_moment_change wrist_flex_l_moment_change wrist_dev_l_moment_change lowerbody_moment_sum_change leg_moment_sum_change head_moment_sum_change arm_moment_sum_change pelvis_moment_sum_change spine_moment_sum_change TrialID
0 -0.0 -4.525047 -30.560492 -2.331474 -3.197522 570.684122 -0.587019 -0.735656 0.465648 -0.038928 ... 0.474793 1.102529 0.093195 27.671004 -0.081486 9.731283 10.931281 27.194697 26.025110 66_2_10_p0
1 25.0 -3.586662 -29.544500 -2.287422 -4.695149 568.508441 -4.514829 -0.568680 0.171819 -0.070916 ... 0.842255 0.998786 0.339026 21.872732 0.038633 8.848386 10.245269 21.468214 21.184085 66_2_10_p0
2 50.0 -2.710147 -28.861659 -2.204717 -5.467895 567.312206 -7.608209 -0.453949 -0.042393 -0.088308 ... 1.120588 0.909225 0.536818 17.359714 0.142132 8.356612 9.571475 17.023645 17.430075 66_2_10_p0
3 75.0 -1.900828 -28.466650 -2.090331 -5.616642 566.967221 -9.911854 -0.385122 -0.184784 -0.093072 ... 1.319639 0.832782 0.691063 14.016891 0.230215 8.195857 8.932844 13.745773 14.660518 66_2_10_p0
4 100.0 -1.164030 -28.314152 -1.951237 -5.242271 567.345292 -11.470461 -0.355855 -0.263152 -0.087176 ... 1.449253 0.768395 0.806254 11.729202 0.304086 8.306015 8.352319 11.519383 12.772850 66_2_10_p0
5 125.0 -0.505078 -28.358847 -1.794407 -4.445664 568.318221 -12.328727 -0.359808 -0.285294 -0.072585 ... 1.519279 0.715000 0.886883 10.381589 0.364948 8.626981 7.852844 10.229262 11.664509 66_2_10_p0
6 150.0 0.070701 -28.555413 -1.626813 -3.327703 569.757815 -12.531347 -0.390637 -0.259007 -0.051268 ... 1.539562 0.671534 0.937442 9.858990 0.414004 9.098651 7.457362 9.760192 11.232932 66_2_10_p0
7 175.0 0.557982 -28.858532 -1.455427 -1.989270 571.535879 -12.123018 -0.442001 -0.192087 -0.025192 ... 1.519949 0.636933 0.962424 10.046347 0.452457 9.660920 7.188816 9.996959 11.375557 66_2_10_p0
8 200.0 0.951439 -29.222884 -1.287222 -0.531245 573.524215 -11.148435 -0.507557 -0.092331 0.003676 ... 1.470287 0.610135 0.966320 10.828599 0.481510 10.253683 7.070151 10.824349 11.989821 66_2_10_p0
9 225.0 1.245748 -29.603149 -1.129169 0.945490 575.594631 -9.652296 -0.580963 0.032463 0.033368 ... 1.400423 0.590077 0.953623 12.090688 0.502367 10.816835 7.124311 12.127146 12.973160 66_2_10_p0
10 250.0 1.435582 -29.954007 -0.988241 2.340053 577.618929 -7.679297 -0.655877 0.174499 0.061918 ... 1.320203 0.575694 0.928825 13.717552 0.516231 11.290270 7.374237 13.790134 14.223014 66_2_10_p0
11 275.0 1.533762 -30.124906 -0.836590 3.448567 579.272798 -4.393954 -0.729918 0.320152 0.084338 ... 1.233796 0.459873 0.902714 13.980564 0.543148 10.169936 7.607109 14.131134 13.967435 66_2_10_p0
12 300.0 1.455589 -30.058230 -0.720960 3.841546 580.511375 -0.710243 -0.775990 0.458354 0.098016 ... 1.345188 0.501227 0.958419 17.708893 0.576348 10.444342 9.743561 17.857556 16.143564 66_2_10_p0
13 325.0 1.194989 -29.724246 -0.696166 3.467937 581.157760 2.997164 -0.786775 0.577328 0.100469 ... 1.429160 0.539899 0.985847 20.219659 0.590939 10.505900 12.374509 20.364914 17.598975 66_2_10_p0
14 350.0 0.771116 -29.142421 -0.793058 2.450108 581.105900 6.468120 -0.762215 0.672547 0.091052 ... 1.454084 0.577326 0.966208 21.582709 0.581705 10.236786 14.339475 21.721742 18.325122 66_2_10_p0

15 rows × 131 columns


Now we can check by ploting the joint moment change against kinematic acceleration

loaded id file:  f:\flesh_data_processed\Effort_study\03_TS_processing\TS_motiontracking\id_10_1_11_p1.csv

Balance Board (Ground reaction forces) - processing

Lastly, we need to process the balance board data. We apply a Savitzky-Golay filter of order 5 and span of ~102 ms (61). To have a measure for postural adjustments, we compute the change in 2D magnitude (L2 norm of the center of pressure x and y) in center of pressure. The code is adaptation from Pouw et al. (2025).

Code to prepare environment
BB_files = glob.glob(os.path.join(BBfolder, '*BalanceBoard*.csv'), recursive=True)
BB_files = [file for file in BB_files if '_pr_' not in file]
print('Number of BB files to process: ', len(BB_files))
Number of BB files to process:  8099
bb_error = []

for bb in BB_files:

    if '_rein_' in bb:
        trialid = bb.split(os.sep)[-1].split('.')[0]
        trialid = trialid.split('_')[0] + '_' + trialid.split('_')[1] + '_' + trialid.split('_')[2] + '_' + trialid.split('_')[4] + '_' + trialid.split('_')[9]
    else:
        # get trialid
        trialid = bb.split(os.sep)[-1].split('.')[0]
        # get the first, second, fourth, nineth elements
        trialid = '_'.join(trialid.split('_')[:2] + trialid.split('_')[3:4] + trialid.split('_')[8:9])

    print('working on ' + trialid)

    # because we are going to merge on bb, we will store also more information
    fileinfo = bb.split(os.sep)[-1].split('.')[0]

    # if second element is 1, we will store last three elements
    if fileinfo.split('_')[1] == '1':
        if 'cut' in fileinfo:
            info = '_'.join(fileinfo.split('_')[-4:-1])
        else:
            info = '_'.join(fileinfo.split('_')[-3:])
    elif fileinfo.split('_')[1] == '2':
        if 'cut' in fileinfo:
            info = '_'.join(fileinfo.split('_')[-5:-1])
        else:
            info = '_'.join(fileinfo.split('_')[-4:])

    # Load the balanceboard data
    df_bb = pd.read_csv(bb)

    # drop last column if there is 5 cols
    if len(df_bb.columns) == 6:
        df_bb = df_bb.iloc[:, :-1]

    # Rename columns
    df_bb.columns = ['time_s', 'left_back', 'right_forward', 'right_back', 'left_forward']

    # Calculate sampling rate
    bbsamp = 1 / np.mean(np.diff(df_bb['time_s'] - min(df_bb['time_s'])))
    #print('Balance board sampling rate: ', bbsamp)

    # Apply Savitzky-Golay filter to smooth the data
    for col in df_bb.columns[1:]:
        df_bb[col] = scipy.signal.savgol_filter(df_bb[col], 61, 5) 

    # Calculate COPX and COPY
    COPX = (df_bb['right_forward'] + df_bb['right_back']) - (df_bb['left_forward'] + df_bb['left_back'])
    COPY = (df_bb['right_forward'] + df_bb['left_forward']) - (df_bb['left_back'] + df_bb['right_back'])

    # Calculate COPXc and COPYc 
    df_bb['COPXc'] = scipy.signal.savgol_filter(np.insert(np.diff(COPX), 0, 0), 61, 5) 
    df_bb['COPYc'] = scipy.signal.savgol_filter(np.insert(np.diff(COPY), 0, 0), 61, 5)

    # Calculate COPc
    df_bb['COPc'] = np.sqrt(df_bb['COPXc']**2 + df_bb['COPYc']**2)
    # Multiply by sampling rate to get in units in s (not per frame)
    df_bb['COPc'] = df_bb['COPc']*bbsamp

    # restart the time so that starts from 0
    df_bb['time_s'] = df_bb['time_s'] - min(df_bb['time_s'])
    # convert to ms
    df_bb['time_s'] = df_bb['time_s']*1000

    # rename time_s to time
    df_bb.rename(columns={'time_s': 'time'}, inplace=True)

    # Add trialid
    df_bb['TrialID'] = trialid
    # Add info
    df_bb['FileInfo'] = info

    # Write as csv 
    df_bb.to_csv(os.path.join(MTfolder_processed, 'bb_' + trialid + '.csv'), index=False)

Here is an example of a file

time left_back right_forward right_back left_forward COPXc COPYc COPc TrialID FileInfo
0 0.000000 1.367532 1.076887 1.718133 1.530981 0.000398 -0.000424 0.290479 48_2_11_p0 p0_zout_combinatie_c0
1 1.999991 1.367452 1.076767 1.718550 1.530793 0.000351 -0.000381 0.258688 48_2_11_p0 p0_zout_combinatie_c0
2 3.999983 1.367343 1.076631 1.718853 1.530600 0.000308 -0.000339 0.228874 48_2_11_p0 p0_zout_combinatie_c0
3 5.999974 1.367213 1.076482 1.719056 1.530403 0.000270 -0.000299 0.201162 48_2_11_p0 p0_zout_combinatie_c0
4 7.999966 1.367067 1.076327 1.719175 1.530204 0.000236 -0.000261 0.175659 48_2_11_p0 p0_zout_combinatie_c0
5 15.663633 1.366910 1.076168 1.719225 1.530005 0.000207 -0.000225 0.152455 48_2_11_p0 p0_zout_combinatie_c0
6 17.663624 1.366749 1.076010 1.719219 1.529807 0.000181 -0.000192 0.131620 48_2_11_p0 p0_zout_combinatie_c0
7 19.663616 1.366586 1.075856 1.719168 1.529612 0.000158 -0.000163 0.113204 48_2_11_p0 p0_zout_combinatie_c0
8 21.663607 1.366427 1.075708 1.719084 1.529422 0.000139 -0.000136 0.097234 48_2_11_p0 p0_zout_combinatie_c0
9 23.663598 1.366276 1.075571 1.718979 1.529239 0.000123 -0.000114 0.083709 48_2_11_p0 p0_zout_combinatie_c0
10 25.663590 1.366136 1.075447 1.718860 1.529065 0.000110 -0.000095 0.072594 48_2_11_p0 p0_zout_combinatie_c0
11 27.663581 1.366011 1.075338 1.718737 1.528901 0.000100 -0.000080 0.063815 48_2_11_p0 p0_zout_combinatie_c0
12 29.663573 1.365905 1.075246 1.718618 1.528749 0.000092 -0.000068 0.057254 48_2_11_p0 p0_zout_combinatie_c0
13 31.663564 1.365819 1.075174 1.718510 1.528611 0.000087 -0.000061 0.052756 48_2_11_p0 p0_zout_combinatie_c0
14 33.663555 1.365757 1.075123 1.718419 1.528489 0.000083 -0.000057 0.050151 48_2_11_p0 p0_zout_combinatie_c0


Here is an example of a timeseries representing change in center of pressure (COPc)

References

Pouw, W., Werner, R., Burchardt, L. S., & Selen, L. P. J. (2025). The human voice aligns with whole-body kinetics. Proceedings. Biological Sciences, 292(2047), 20250160. https://doi.org/10.1098/rspb.2025.0160
Seth, A., Hicks, J. L., Uchida, T. K., Habib, A., Dembia, C. L., Dunne, J. J., Ong, C. F., DeMers, M. S., Rajagopal, A., Millard, M., Hamner, S. R., Arnold, E. M., Yong, J. R., Lakshmikanth, S. K., Sherman, M. A., Ku, J. P., & Delp, S. L. (2018). OpenSim: Simulating musculoskeletal dynamics and neuromuscular control to study human and animal movement. PLOS Computational Biology, 14(7), e1006223. https://doi.org/10.1371/journal.pcbi.1006223