FlexTail Sensor Evaluation Series
Activity Kinematics: Reading Distribution Shape, Not Just the Mean
How to analyse FlexTail activity recordings with participant-aware distributions, joint posture spaces, and a reproducible correction for the current sagittal-lateral plot implementation.
Published
Why an activity needs a distribution
One minute of a household task can include standing upright, reaching, bending, turning, and returning to neutral. A single mean angle cannot distinguish a consistently moderate posture from a mostly neutral task punctuated by deep bends. The activity-profile chapter therefore starts with the complete empirical distribution of lumbar flexion for every activity, pooled for visualisation across the 10 HAR participants.
For an activity $a$, collect all lumbar samples $b_{a,1}, \ldots, b_{a,n_a}$. Its empirical distribution function is:
$$ \widehat F_a(x) = \frac{1}{n_a}\sum_{i=1}^{n_a}\mathbb{1}(b_{a,i} \leq x). $$
The violin plot is a smoothed display of this distribution, with a box plot overlaid for the median and interquartile range. It is an exploratory graphic, not a model of the population distribution. Its width depends on the bandwidth selected by the plotting library and the sample density; conclusions should rely on reported quantiles and per-participant summaries rather than silhouette width alone.
The source code pools frame-level values into one vector per activity:
ACTIVITIES = [
"cutting", "eating", "loading_dishwasher",
"movement", "unloading_dishwasher", "vacuuming", "wiping",
]
act_bends = {activity: [] for activity in ACTIVITIES}
for (participant, activity), bend in recordings.items():
act_bends[activity].extend(bend.tolist())
parts = ax.violinplot(
[act_bends[activity] for activity in ACTIVITIES],
showmedians=True,
showextrema=True,
)
This is appropriate for showing the sample-level posture envelope. It is not participant-balanced: a longer or higher-frequency recording contributes more samples. For inference about people, first summarise each participant within each activity, then compare those 10 summaries. A robust set of participant-level outcomes is median, $P90$, time above 20 degrees, and perhaps the within-task interquartile range.
A participant-aware summary model
Let $b_{p,a,i}$ be frame $i$ from participant $p$ performing activity $a$. Compute a within-person statistic first, for example the mean:
$$ \bar b_{p,a} = \frac{1}{n_{p,a}}\sum_{i=1}^{n_{p,a}}b_{p,a,i}. $$
The activity estimate is then the average of participant summaries, not the average of all frames:
$$ \bar b_a^{(P)} = \frac{1}{P_a}\sum_{p=1}^{P_a}\bar b_{p,a}. $$
This gives every participant equal weight. It also makes the sampling structure clear: frames are nested within recordings, which are nested within people. A mixed-effects model can express the same structure when covariates or hypothesis tests are needed:
$$ b_{p,a,i} = \beta_0 + \beta_a + u_p + \epsilon_{p,a,i}, $$
where $u_p$ is a participant-specific random intercept. Time-series autocorrelation means that treating all frames as independent observations will make uncertainty artificially small. Downsampling, block bootstrapping, or modelling the serial correlation are better options for frame-level analyses.
Task signatures in posture space
The useful comparison is often not one channel but the geometry of two channels together. A sample can be represented as a posture vector:
$$ \mathbf{x}i = \begin{bmatrix} \theta{\mathrm{lat},i} \ \theta_{\mathrm{sag},i} \end{bmatrix}. $$
For each activity, the sample mean vector $\boldsymbol\mu_a$ locates its typical posture and the covariance matrix describes its spread and coupling:
$$ \boldsymbol\Sigma_a = \frac{1}{n_a-1} \sum_{i=1}^{n_a}(\mathbf{x}_i-\boldsymbol\mu_a) (\mathbf{x}_i-\boldsymbol\mu_a)^\mathsf{T}. $$
An elongated density contour means that the two angles tend to vary together. A compact contour close to zero means that the task occupies a narrow posture range. A curved or multimodal shape can reveal different task phases, such as reaching into a dishwasher at several depths. In that situation, the covariance is only a first summary; the raw scatter or a density map is more informative.
The original chapter describes this panel as a lumbar-flexion by axial-twist analysis. The code that generates ch06_lumbar_twist_joint.pdf currently reads the lateral and sagittal columns instead:
lat_all.extend(np.degrees(df["lateral"].values).tolist())
sag_all.extend(np.degrees(df["sagittal"].values).tolist())
ax.scatter(lat[idx_s], sag[idx_s], s=4, alpha=0.25)
ax.set_xlabel("Lateral (x-axis) (degrees)")
ax.set_ylabel("Sagittal (y-axis) (degrees)")
For reproducibility, this report calls the existing output a sagittal-lateral joint distribution. It should not be cited as an axial-twist figure unless the implementation is changed to read twist on one axis and the axis labels and caption are regenerated. This is a documentation correction, not a claim about the study result.
Building a true flexion-twist analysis
If axial rotation is the intended question, use a signed or absolute twist definition deliberately. Signed twist preserves left-right direction. Absolute twist measures asymmetry magnitude but folds both directions together. The choice changes the distribution and should be stated in the analysis plan.
lumbar_deg = np.degrees(df["lumbarAngle"].to_numpy())
twist_deg = np.degrees(df["twist"].to_numpy())
# Signed twist: direction is retained.
joint_signed = pd.DataFrame({"lumbar": lumbar_deg, "twist": twist_deg})
# Absolute twist: magnitude is retained, left and right are folded together.
joint_magnitude = pd.DataFrame({"lumbar": lumbar_deg, "twist": np.abs(twist_deg)})
summary = joint_magnitude.agg(["median", lambda x: x.quantile(0.90)])
summary.index = ["median", "p90"]
To estimate the degree of coupling, calculate a robust correlation per participant and activity before pooling. Pearson correlation assesses linear co-variation; Spearman correlation is less sensitive to nonlinear scaling and outliers.
$$ \rho_{a,p} = \operatorname{cor}\left(\theta_{\mathrm{lumbar}}, |\theta_{\mathrm{twist}}|\right)_{a,p}. $$
Report the distribution of $\rho_{a,p}$, rather than one pooled correlation. The pooled estimate can be distorted by between-person differences in task style, a form of aggregation bias sometimes called Simpson's paradox.
Separating activity effects from movement strategy
The HAR recordings show why activity labels are helpful but insufficient. Vacuuming, wiping, and dishwasher handling generally require reaching and forward inclination, while table activities occupy a more upright configuration. Yet the same activity can be performed with different stance, tool length, countertop height, reach distance, and pacing. A group average therefore describes a task context, not an immutable posture requirement.
For each activity, inspect at least four layers:
- Central position: median and mean angle values.
- Exposure tail: $P90$, $P95$, and time above a predeclared angle.
- Variability: interquartile range, range after outlier checks, and within-person standard deviation.
- Coupling: the joint distribution or participant-level correlation of relevant planes.
The temporal order of samples can add a fifth layer. Divide a one-minute recording into consecutive windows, calculate each metric per window, and plot the trajectory. This distinguishes a task that starts upright and transitions to a sustained stoop from one that alternates rapidly between positions.
A reproducible extraction pattern
The source loader walks participants and activities, parses each RSF file, and creates a DataFrame. The following compact version retains identifiers needed for later participant-aware analysis.
from pathlib import Path
import numpy as np
import pandas as pd
from flexlib import create_dataframe
from models.read_annotated_rsf import RSFReader
def load_activity_frames(root: Path, activities: list[str]) -> pd.DataFrame:
frames = []
for participant_dir in sorted(path for path in root.iterdir() if path.is_dir()):
for activity in activities:
path = participant_dir / f"{activity}.rsf"
if not path.exists():
continue
recording = RSFReader.parse(str(path))
df = create_dataframe(recording.measurements).copy()
df["participant"] = participant_dir.name
df["activity"] = activity
df["lumbar_deg"] = np.degrees(df["lumbarAngle"])
df["twist_deg"] = np.degrees(df["twist"])
frames.append(df)
return pd.concat(frames, ignore_index=True)
Keep raw radians and derived degree columns separate. flexlib channels should be treated as a documented interface: record the package version and verify channel semantics with a short calibration or known-pose recording before applying a pipeline to a new study.
Reporting and limits
The recorded activities are controlled exemplars, not a representative census of domestic work. Their duration, setting, instructions, and participant pool constrain what can be inferred. The plots support statements such as "this activity produced a broader lumbar-angle distribution in this protocol". They do not by themselves support statements about injury risk, clinical disease, or all instances of the named activity.
For publications, pre-specify the primary plane, signed versus absolute angle convention, frame inclusion rules, outlier treatment, and the level at which uncertainty is estimated. Archive code that produces the figures, including the exact selection of RSF files. These details are what make a visually compelling activity-profile plot scientifically reusable.
Source
- Walkling J, Masch A, Sander L, Deserno TM. Wearable Spine Tracker vs. Video-Based Pose Estimation for Human Activity Recognition. Sensors. 2025;25(12):3806. doi:10.3390/s25123806