Each gesture is a fixed-duration time series captured at 104 Hz across six IMU axes: [gx, gy, gz, ax, ay, az]. At unlock time, the attempt goes through a three-stage pipeline before a distance comparison:
capture → energy pre-filter → z-normalization → DTW → threshold
The pre-filter runs first on raw data, before normalization. This is intentional - it operates on actual motion amplitude, not the normalized shape.
The obvious alternative is to reduce each gesture to a statistics vector (mean, variance, zero-crossings per axis) and compare Euclidean distances. The problem is that two perceptually different gestures can have nearly identical statistics - a slow arc and a fast flick with a pause will produce similar means, variances, and zero-crossing counts. The only information that distinguishes them is the shape of the trajectory over time, which a feature vector discards by design.
Dynamic Time Warping, however, preserves the full temporal structure. Rather than comparing samples at matching timestamps, DTW finds the optimal time alignment between the stored password and the unlock attempt before measuring distance. A gesture performed 10% faster gets time-stretched to match the reference. The comparison is then between shape rather than timing.
Before normalization, the RMS energy of the attempt is compared against the stored gesture's energy. Both are computed on mean-centered raw data - the stored gesture's per-axis mean is subtracted from both signals before computing energy. This removes the DC component (gravity, sensor bias, resting orientation) so the ratio reflects actual motion amplitude rather than absolute sensor readings.
energy(x) = sqrt( mean( sum_over_axes( (x[i][k] - μ_stored[k])² ) ) )
ratio = energy(attempt) / energy(stored)
reject if ratio < 0.5 or ratio > 3.0
If the ratio falls outside [PREFILTER_LOWER_BND, PREFILTER_UPPER_BND], the attempt is rejected immediately without running DTW. This catches obvious mismatches like no motion or random movement in microseconds.
After the pre-filter, z-score normalization places both signals in the same coordinate space:
normalized[i][k] = (sample[i][k] - μ_stored[k]) / σ_stored[k]
μ and σ are computed from the stored gesture at SET time using Welford's online algorithm (single pass). The same stored statistics are applied to both the reference and the attempt. This step is necessary because accelerometer axes (g) and gyroscope axes (°/s) have different units and magnitudes. Without normalization, the highest-magnitude axes dominate the joint distance and the comparison is effectively single-axis.
Each normalized IMU sample is a point in 6D space. The local distance between sample i of the stored gesture A and sample j of the attempt B is the Euclidean distance between those 6D vectors:
d(A[i], B[j]) = ||A[i] - B[j]||₂
The accumulated cost matrix is filled by dynamic programming:
D[i][j] = d(A[i], B[j]) + min( D[i-1][j], // stretch A
D[i][j-1], // stretch B
D[i-1][j-1] ) // advance both
The Sakoe-Chiba band constrains the warping path to |i - j| ≤ w, preventing alignments where the beginning of one gesture matches the end of another. This also reduces complexity from O(N²) to O(N·w).
Band width is computed as:
w = max( int(DTW_FACTOR × max(N, M)), |N - M|, 1 )
The |N-M| clamp ensures a feasible path always exists when the two sequences differ in length. With DTW_FACTOR = 0.25 and a typical gesture of ~400 samples, w ≈ 100, corresponding to roughly ±1.0 s of tolerated timing variation.
Only a rolling two-row buffer is needed during computation - the recurrence only ever looks at the previous row, so the full N×M matrix is never allocated. Working memory is approximately 3.3 KB regardless of gesture length.
The final DTW distance is normalized by total path length to make it comparable across gesture pairs of different lengths:
score = D[N-1][M-1] / (N + M)
The lock opens when all three gesture scores fall below the threshold τ:
unlock iff score₁ < τ ∧ score₂ < τ ∧ score₃ < τ
After z-normalization the score is dimensionless and comparable across gestures, so a single shared threshold is used. The default value is τ = 0.9. See testing.md for empirical results.