Read and write Cortex Motion Analysis Corporation ASCII files

Marcos Duarte
Laboratory of Biomechanics and Motor Control (http://demotu.org/)
Federal University of ABC, Brazil

Motion Analysis Corporation (MAC, http://www.motionanalysis.com/) builds motion capture systems and their software (e.g., Cortex) generates files in ASCII and binary formats for the different signals (kinematics, analog data, force plate data, etc.). Here are functions for reading most of the files saved in ASCII format. These files have headers with few lines with meta data and the signals are stored in columns and the rows for the different frames (instants of time).

The ".trc" (Track Row Column) file in ASCII contains X-Y-Z position data for the reflective markers from a motion capture trial. The position data for each marker is organized into 3 columns per marker (X, Y and Z position) with each row being a new frame. The position data is relative to the global coordinate system of the capture volume and the position values are in the units used for calibration.

The ".anc" (Analog ASCII Row Column) file contains ASCII analog data in row-column format. The data is derived from ".anb" analog binary files. These binary ".anb" files are generated simultaneously with video ".vc" files if an optional analog input board is used in conjunction with video data capture.

The ".cal" file contains force plate calibration parameters.

The ".forces" file contains force plate data. The data is saved based on the "forcepla.cal" file of the trial and converts the raw force plate data into calibrated forces. The units used are Newtons and Newton-meters and each line in the file equates to one analog sample.

In [1]:
import sys
sys.path.insert(1, r'./../functions')  # add to pythonpath
import io_cortexmac as io
In [2]:
print(io.__doc__)
Read and write Cortex Motion Analysis Corporation ASCII related files.

    read_trc(fname, fname2='_2', units='', df_multi=True): Read .trc file.
    read_anc(fname): Read .anc file.
    read_cal(fname): Read .cal file.
    read_forces(fname): Read .forces file.
    write_trc(fname, header, df): Write .trc file.
    write_v3dtxt(fname, trc, forces, freq=0): Write Visual3d text file
     from .trc and .forces files or dataframes.
    grf_moments(data, O): Calculate force plate moments around its origin
     given 3 forces, 2 COPs, 1 free moment, and its geometric position.

In [3]:
import sys, os

path2 = r'./../data/'
fname = os.path.join(path2, 'arm26_elbow_flex.trc')
In [4]:
h, df = io.read_trc(fname, fname2='_2', units='', df_multi=True)
Opening file "./../data/arm26_elbow_flex.trc"
Saving file "./../data/arm26_elbow_flex_2.trc"
In [7]:
df.head(10)
Out[7]:
Marker r_acromion r_humerus_epicondyle r_radius_styloid
Coordinate X Y Z X Y Z X Y Z
XYZ X1 Y1 Z1 X2 Y2 Z2 X3 Y3 Z3
Time
0.000000 -13.054524 39.505476 169.505476 -12.559380 -297.414380 199.985620 -13.124683 -533.569683 251.420317
0.008333 -12.960648 39.599352 169.599352 -12.567324 -297.422324 199.977676 -12.867025 -533.600380 251.382550
0.016667 -12.853425 39.706575 169.706575 -12.574394 -297.429394 199.970606 -12.582610 -533.629817 251.345015
0.025000 -12.736429 39.823571 169.823571 -12.580312 -297.435312 199.964688 -12.246998 -533.658334 251.306576
0.033333 -12.613556 39.946444 169.946444 -12.584846 -297.439846 199.960154 -11.837471 -533.686073 251.266170
0.041667 -12.488899 40.071101 170.071101 -12.587819 -297.442819 199.957181 -11.333753 -533.712924 251.222841
0.050000 -12.366610 40.193390 170.193390 -12.589113 -297.444113 199.955887 -10.718575 -533.738470 251.175766
0.058333 -12.250763 40.309237 170.309237 -12.588679 -297.443679 199.956321 -9.978059 -533.761944 251.124273
0.066667 -12.145217 40.414783 170.414783 -12.586533 -297.441533 199.958467 -9.101892 -533.782204 251.067848
0.075000 -12.053487 40.506513 170.506513 -12.582760 -297.437760 199.962240 -8.083304 -533.797739 251.006135
In [ ]:
# %load ./../functions/io_cortexmac.py
"""Read and write Cortex Motion Analysis Corporation ASCII related files.

    read_trc(fname, fname2='_2', units='', df_multi=True): Read .trc file.
    read_anc(fname): Read .anc file.
    read_cal(fname): Read .cal file.
    read_forces(fname): Read .forces file.
    write_trc(fname, header, df): Write .trc file.
    write_v3dtxt(fname, trc, forces, freq=0): Write Visual3d text file
     from .trc and .forces files or dataframes.
    grf_moments(data, O): Calculate force plate moments around its origin
     given 3 forces, 2 COPs, 1 free moment, and its geometric position.
"""

__author__ = "Marcos Duarte, https://github.com/demotu/BMC"
__version__ = "1.0.1"
__license__ = "MIT"

import os
import csv
import numpy as np
import pandas as pd


def read_trc(fname, fname2='_2', units='', df_multi=True):
    """Read .trc file format from Cortex MAC.

    This function: 1. Delete markers (columns) of empty data; 2. Correct
    number of markers in the header according to the actual number of
    non-empty markers; 3. Save a '.trc' file with updated information and
    data; 4. Returns header information and data.

    The .trc (Track Row Column) file in ASCII contains X-Y-Z position
    data for the reflective markers from a motion capture trial. The
    position data for each marker is organized into 3 columns per marker
    (X, Y and Z position) with each row being a new frame. The position
    data is relative to the global coordinate system of the capture volume
    and the position values are in the units used for calibration.

    Parameters
    ----------
    fname  : string
        Full file name of the .trc file to be opened.

    fname2  : string (default = '_2')
        Full file name of the .trc file to be saved with updated information
        and data if desired.
        If fname2 is '', no file is saved.
        If fname2 is '=', the original file name will be used.
        If fname2 is a string with length between 1 and 3, this string (other
        than '=') is appended to the original file name.

    units  : string (default = '')
        Change the units of the data if desired.
        Accepted output units are 'm' or 'mm'.

    df_multi  : bool (default = True)
        Whether to output data as pandas multiindex dataframe with "Marker"
        and "Coordinate" as labels and "Time" as index (True) or simple
        pandas dataframe with "Frame#" and "Time" as columns (False).

    Returns
    -------
    h  : Python dictionary with .trc header information
        keys: header (the .trc full header), data_rate (Hz), camera_rate (Hz),
        nframes, nmarkers, markers (names), xyz (X1,Y1,Z1...), units.

    data  : pandas dataframe
        Two possible output formats according to the `df_multi` option:
        Dataframe with shape (nframes, 2+3*nmarkers) with markerxyz as label
        and columns: Frame#, time and position data.
        Dataframe with shape (nframes, 3*nmarkers) with "Marker" and
        "Coordinate" as labels, "Time" as index, and data position as columns.

    """

    with open(file=fname, mode='rt', encoding='utf-8', newline='') as f:
        print('Opening file "{}"'.format(fname))
        # get header information
        read = csv.reader(f, delimiter='\t')
        header = [next(read) for x in range(5)]
        # actual number of markers
        nmarkers = int((len(header[3])-2)/3)
        # column labels
        markers = np.asarray(header[3])[np.arange(2, 2+3*nmarkers, 3)].tolist()
        markers3 = [m for m in markers for i in range(3)]
        markersxyz = [a+b for a, b in zip(markers3, ['x', 'y', 'z']*nmarkers)]
        # read data
        df = pd.read_csv(f, sep='\t', names=['Frame#', 'Time'] + markersxyz,
                         index_col=False, encoding='utf-8', engine='c')
        # drop markers with no data
        df.dropna(axis=1, how='all', inplace=True)
        # update header
        nmarkers = int((df.shape[1]-2)/3)
        if header[2][3] != str(nmarkers):
            print(' Number of markers changed from {} to {}.'
                  .format(header[2][3], nmarkers))
            header[2][3] = str(nmarkers)
        header[3] = ['' if c[-1] in ['y', 'z'] else c[:-1] if c[-1] in ['x']
                     else c for c in df.columns.values.tolist()] + ['']
        markers = np.asarray(header[3])[np.arange(2, 2+3*nmarkers, 3)].tolist()
        n3 = np.repeat(range(1, nmarkers+1), 3).tolist()
        xyz = [a+str(b) for a, b in zip(['X', 'Y', 'Z']*nmarkers, n3)]
        header[4] = ['', ''] +  xyz
        if units == 'm':
            if header[2][4] == 'mm':
                df.iloc[:, 2:] = df.iloc[:, 2:]/1000
                header[2][4] = 'm'
                print(' Units changed from {} to {}'.format('"mm"', '"m"'))
        elif units == 'mm':
            if header[2][4] == 'm':
                df.iloc[:, 2:] = df.iloc[:, 2:]*1000
                header[2][4] = 'mm'
                print(' Units changed from {} to {}'.format('"m"', '"mm"'))

    # save file
    if len(fname2):
        if fname2 == '=':
            fname2 = fname
        elif len(fname2) <= 3:
            name, extension = os.path.splitext(fname)
            fname2 = name + fname2 + extension

        write_trc(fname2, header, df)

    # outputs
    h = {'header': header,
         'data_rate': float(header[2][0]),
         'camera_rate': float(header[2][1]),
         'nframes': int(header[2][2]),
         'nmarkers': int(header[2][3]),
         'markers': markers,
         'xyz': xyz,
         'units': header[2][4],
         'fname': fname,
         'fname2': fname2}
    if df_multi:
        df.drop(labels='Frame#', axis=1, inplace=True)
        df.set_index('Time', inplace=True)
        df.index.name = 'Time'
        cols = [s[:-1] for s in df.columns.str.replace(r'.', '')]
        df.columns = [cols, list('XYZ')*int(df.shape[1]/3)]
        df.columns.set_names(names=['Marker', 'Coordinate'], level=[0, 1], inplace=True)

    return h, df


def read_anc(fname):
    """Read .anc file format from Cortex MAC.

    The .anc (Analog ASCII Row Column) file contain ASCII analog data
    in row-column format. The data is derived from *.anb analog binary
    files. These binary *.anb files are generated simultaneously with
    video *.vc files if an optional analog input board is used in
    conjunction with video data capture.

    Parameters
    ----------
    fname  : string
        full file name of the .anc file to be opened

    Returns
    -------
    h  : Python dictionary
        .anc header information
        keys: nbits, polarity, nchannels, data_rate, ch_names, ch_ranges

    data  : pandas dataframe
        analog data with shape (nframes, nchannels)

    """

    with open(file=fname, mode='rt', encoding='utf-8', newline='') as f:
        # get header information
        read = csv.reader(f, delimiter='\t')
        header = [next(read) for x in range(11)]
        h = {'nbits': int(header[3][1]),
             'polarity': header[1][3],
             'nchannels': int(header[2][7]),
             'data_rate': float(header[3][3]),
             'ch_names': header[8],
             'ch_ranges': header[10]}
        h['ch_names'] = h['ch_names'][1:-1]
        h['ch_ranges'] = np.asarray(h['ch_ranges'][1:-1], dtype=np.float)
        # analog data
        data = pd.read_csv(f, sep='\t', names=h['ch_names'], engine='c',
                           usecols=np.arange(1, 1+h['nchannels']))
        # convert ADC (bit) values to engineering units:
        data *= h['ch_ranges']/(2**h['nbits']/2 - 2)

    return h, data


def read_cal(fname):
    """Read .cal file format from Cortex MAC.

    The .cal (force plate calibration parameters) file in ASCII contains:

    <forceplate number> {1}
    <scale> <length (cm)> <width (cm)> {2}
    <N x N calibration matrix (the inverse sensitivity matrix)> {3}
    <true origin in relation to the geometric center (cm)>
    <geometric center in relation to LCS origin (cm)>
    <3 x 3 orientation matrix>
    ...repeat for next force plate...

    {1}: for a Kistler force plate, there is a 'K' after the number
    {2}: the scale is the inverse of the gain
    {3}: N equal 8 for Kistler and equal 6 for all AMTI and Bertec

    Parameters
    ----------
    fname  : string
        full file name of the .trc file to be opened

    Returns
    -------
    forcepla  : Python dictionary
        parameter from the froce plate calibration file
        keys: 'fp', 'scale', 'size', 'cal_matrix', 'origin', 'center', 'orientation'
    """

    fp, scale, size, cal_matrix, origin, center, orientation = [], [], [], [], [], [], []
    with open(file=fname, mode='rt', encoding='utf-8', newline='') as f:
        reader = csv.reader(f, delimiter=' ')
        for row in reader:
            # force plate number
            fp.append(int(row[0][0]))
            # number of rows for Kistler or AMTI/Bertec force plate
            n = 8 if row[0][-1] == 'K' else 6
            # scale (inverse of the gain)
            scale_size = np.array(next(reader)).astype(np.float)
            scale.append(scale_size[0])
            # force plate length (cm) and width (cm)
            size.append(scale_size[1:])
            # calibration matrix (the inverse sensitivity matrix)
            matrix = [next(reader) for x in range(n)]
            cal_matrix.append(np.array(matrix).astype(np.float))
            # true origin in relation to the geometric center (cm)
            origin.append(np.array(next(reader)).astype(np.float))
            # geometric center in relation to LCS origin (cm)
            center.append(np.array(next(reader)).astype(np.float))
            # 3 x 3 orientation matrix
            orienta = [next(reader) for x in range(3)]
            orientation.append(np.array(orienta).astype(np.float))

    forcepla = {'fp': fp, 'scale': scale, 'size': size, 'cal_matrix': cal_matrix,
                'origin': origin, 'center': center, 'orientation': orientation}

    return forcepla


def read_forces(fname):
    """Read .forces file format from Cortex MAC.

    The .forces file in ASCII contains force plate data. The data is saved
    based on the forcepla.cal file of the trial and converts the raw force
    plate data into calibrated forces. The units used are Newtons and
    Newton-meters and each line in the file equates to one analog sample.

    Parameters
    ----------
    fname  : string
        full file name of the .forces file to be opened

    Returns
    -------
    h  : Python dictionary
        .forces header information
        keys: name, nforceplates, data_rate, nsamples, ch_names

    data  : pandas dataframe
        force plate data with shape (nsamples, 7*nforceplates)

    """

    with open(file=fname, mode='rt', encoding='utf-8', newline='') as f:
        # get header information
        read = csv.reader(f, delimiter='\t')
        header = [next(read) for x in range(5)]
        h = {'name': header[0][0],
             'nforceplates': int(header[1][0].split('=')[1]),
             'data_rate': float(header[2][0].split('=')[1]),
             'nsamples': int(header[3][0].split('=')[1]),
             'ch_names': header[4][1:]}
        # force plate data
        data = pd.read_csv(f, sep='\t', names=h['ch_names'], index_col=False,
                           usecols=np.arange(1, 1+7*h['nforceplates']), engine='c')

    return h, data


def write_trc(fname, header, df):
    """Write .trc file format from Cortex MAC.

    See the read_trc.py function.

    Parameters
    ----------
    fname  : string
        Full file name of the .trc file to be saved.

    header  : list of lists
        header for the .trc file

    df  : pandas dataframe
        dataframe with data for the .trc file (with frame and time columns)

    """

    with open(file=fname, mode='wt', encoding='utf-8', newline='') as f:
        print('Saving file "{}"'.format(fname))
        for line in header:
            f.write('\t'.join(line) + '\n')
        f.write('\n')
        df.to_csv(f, header=None, index=None, sep='\t',
                  line_terminator='\t\n') # float_format='%.8f'


def write_v3dtxt(fname, trc, forces, freq=0):
    """Write Visual3d text file from .trc and .forces files or dataframes.

    The .trc and .forces data are assumed to correspond to the same time
    interval. If the data have different number of samples (different
    frequencies), the data will be resampled to the highest frequency (or to
    the inputed frequency if it is higher than the former two) using the tnorm
    function.

    Parameters
    ----------
    fname  : string
        Full file name of the Visual3d text file to be saved.

    trc  : pandas dataframe or string
        If string, it is a full file name of the .trc file to read.
        If dataframe, data of the .trc file has shape (nsamples, 2 + 3*nmarkers)
        where the first two columns are from the Frame and Time values.

    forces  : pandas dataframe or string
        If string, it is a full file name of the .forces file to read.
        If dataframe, data of the .forces file has shape (nsamples, 7*nforceplates)

    freq  : float (optional, dafault=0)
        Sampling frequency in Hz to resample data if desired.
        Data will be resampled to the highest frequency between freq, trc, forces.

    """

    if isinstance(trc, str):
        _, trc = read_trc(trc, fname2='', units='', df_multi=False)
    if isinstance(forces, str):
        _, forces = read_forces(forces)

    if trc.shape[0] != forces.shape[0] or freq:
        from tnorm import tnorm
        freq_trc = 1/np.nanmean(np.diff(trc.iloc[:, 1].values))
        freq_forces = freq_trc*(forces.shape[0]/trc.shape[0])
        freq = np.max([freq, freq_trc, freq_forces])
        nsample = np.max([trc.shape[0], forces.shape[0]]) * freq/(np.max([freq_trc, freq_forces]))
        trc2, _, _ = tnorm(trc.iloc[:, 2:].values, step=-nsample)
        trc2 = np.hstack((np.vstack((np.arange(1, nsample+1, 1),
                                     np.arange(0, nsample, 1)/freq)).T, trc2))
        trc = pd.DataFrame(trc2, index=None, columns=trc.columns)
        forces2, _, _ = tnorm(forces.values, step=-nsample)
        forces = pd.DataFrame(forces2, index=None, columns=forces.columns)

    ntrc = trc.shape[1]
    nforces = forces.shape[1]
    data = pd.concat([trc, forces], axis=1)

    with open(file=fname, mode='wt', encoding='utf-8', newline='') as f:
        rows = [[''] + ['default']*(ntrc + nforces - 1),
                [''] + data.columns.tolist()[1:],
                [''] + ['FRAME_NUMBERS'] + ['TARGET']*(ntrc - 2) + ['ANALOG']*nforces,
                [''] + ['ORIGINAL']*(ntrc + nforces -1),
                [data.columns[0]] + ['0'] + ['X', 'Y', 'Z']*int((ntrc - 2)/3) + ['0']*nforces]
        write = csv.writer(f, delimiter='\t')
        write.writerows(rows)
        write.writerows(data.values)


def grf_moments(data, O):
    """Calculate force plate moments around its origin given
    3 forces, 2 COPs, 1 free moment, and its geometric position.

    Parameters
    ----------
    data  : Numpy array (n, 7)
        array with [Fx, Fy, Fz, COPx, COPy, COPz, Tz].
    O  : Numpy array-like or list
        origin [x,y,z] of the force plate in the motion capture coordinate system [in meters].

    Returns
    -------
    grf  : Numpy array (n, 8)
        array with [Fx, Fy, Fz, Mx, My, Mz]
    """

    Fx, Fy, Fz, COPx, COPy, COPz, Tz = np.hsplit(data, 7)

    COPz = np.nanmean(COPz)  # most cases is zero

    Mx = COPy*Fz + COPz*Fy
    My = -COPx*Fz - COPz*Fx
    Mz = Tz + COPx*Fy - COPy*Fx

    Mx = Mx - Fy*O[2] + Fz*O[1]
    My = My - Fz*O[0] + Fx*O[2]
    Mz = Mz - Fx*O[1] + Fy*O[0]

    grf = np.hstack((Fx, Fy, Fz, Mx, My, Mz))

    return grf
In [ ]: