Advanced Capacitive Sensing Algorithms
Position interpolation, noise filtering, gesture classification, and signal processing techniques for the MPR121.
The MPR121 outputs 12 channels of raw capacitance data. What you do with those numbers is entirely up to you. This tutorial treats capacitive sensing as a signal processing problem and works through the techniques that separate a crude on/off detector from a precision instrument: position interpolation, noise rejection, baseline management, and machine-learning gesture classification.
The Raw Signal
Before processing, understand what the MPR121 actually outputs. The filtered data register for each electrode (0x04 + 2n) is a 10-bit value (0–1023) representing the measured capacitance in counts. The baseline register (0x1E + n) is an 8-bit value that represents the long-term average shifted right by two bits (multiply by 4 to compare with filtered data).
The proximity signal — what we actually want — is:
proximity[n] = baseline[n] * 4 - filtered[n]
At rest this is close to zero. It goes positive as a hand approaches. It saturates near the touch threshold (typically set to 40–60 counts for standard electrodes).
Reading All 12 Channels Efficiently
The MPR121 supports burst I²C reads. Read all filtered data registers in a single transaction:
int16_t filteredData[12];
int baselineData[12];
void readAllChannels() {
Wire.beginTransmission(0x5C);
Wire.write(0x04); // start of filtered data registers
Wire.endTransmission(false);
Wire.requestFrom(0x5C, 24); // 12 electrodes × 2 bytes
for (int i = 0; i < 12; i++) {
uint8_t lo = Wire.read();
uint8_t hi = Wire.read();
filteredData[i] = (int16_t)((hi << 8) | lo) & 0x03FF;
}
Wire.beginTransmission(0x5C);
Wire.write(0x1E); // baseline registers
Wire.endTransmission(false);
Wire.requestFrom(0x5C, 12);
for (int i = 0; i < 12; i++) {
baselineData[i] = (int)Wire.read() << 2;
}
}
int proximity(int electrode) {
return baselineData[electrode] - filteredData[electrode];
}
This takes roughly 800 µs at 100 kHz I²C, or ~200 µs at 400 kHz. Call it in a timed interrupt or tight loop at up to 100 Hz.
Position Interpolation
If twelve electrodes are arranged in a row, you can compute a continuous 1D position by computing the centroid of the proximity distribution — also known as the centre of mass or weighted mean.
Centroid Algorithm
float computeCentroid() {
float weightedSum = 0.0f;
float totalWeight = 0.0f;
for (int i = 0; i < 12; i++) {
float w = max(0, proximity(i));
weightedSum += i * w;
totalWeight += w;
}
if (totalWeight < 10.0f) return -1.0f; // no hand detected
return weightedSum / totalWeight; // 0.0 to 11.0
}
The centroid jumps discontinuously at the edges of the electrode array. To extend range, add virtual electrodes at −1 and 12 with zero weight, or apply a wrap-around algorithm for circular arrangements.
Improved Interpolation: Parabolic Peak Fitting
Centroid is stable but sluggish at low proximity values. Parabolic peak fitting finds the electrode with the maximum signal and fits a parabola through it and its two neighbours:
float parabolicPeak() {
// Find electrode with maximum proximity
int peakIdx = 0;
float peakVal = 0;
for (int i = 0; i < 12; i++) {
float p = max(0, proximity(i));
if (p > peakVal) { peakVal = p; peakIdx = i; }
}
if (peakVal < 10 || peakIdx == 0 || peakIdx == 11) return peakIdx;
float y0 = max(0, proximity(peakIdx - 1));
float y1 = max(0, proximity(peakIdx));
float y2 = max(0, proximity(peakIdx + 1));
// Parabolic interpolation: offset from peak
float offset = 0.5f * (y0 - y2) / (y0 - 2*y1 + y2);
return (float)peakIdx + offset;
}
Parabolic peak fitting works best when the hand activates three or more adjacent electrodes — common when electrodes are spaced closer than the hand width.
2D Position from a Grid
Arrange 12 electrodes in a 3×4 grid and compute separate X and Y centroids:
// Grid layout: electrode (row, col) mapping
// Row 0: E0 E1 E2 E3
// Row 1: E4 E5 E6 E7
// Row 2: E8 E9 E10 E11
const int ROWS = 3, COLS = 4;
struct Vec2 { float x, y; };
Vec2 compute2DCentroid() {
float wx = 0, wy = 0, total = 0;
for (int r = 0; r < ROWS; r++) {
for (int c = 0; c < COLS; c++) {
int e = r * COLS + c;
float w = max(0, proximity(e));
wx += c * w;
wy += r * w;
total += w;
}
}
if (total < 10) return {-1, -1};
return { wx / total, wy / total };
}
Noise Rejection
Capacitive sensors pick up noise from switching power supplies, fluorescent lighting (50/60 Hz), and nearby digital lines. Several strategies address this.
Moving Average
Simple, but introduces lag:
const int AVG_LEN = 8;
float avgBuf[12][AVG_LEN] = {};
int avgIdx = 0;
void updateMovingAverage() {
for (int i = 0; i < 12; i++) {
avgBuf[i][avgIdx] = proximity(i);
}
avgIdx = (avgIdx + 1) % AVG_LEN;
}
float smoothedProximity(int electrode) {
float sum = 0;
for (int j = 0; j < AVG_LEN; j++) sum += avgBuf[electrode][j];
return sum / AVG_LEN;
}
Median Filter
Robust against impulse noise spikes (e.g. ESD events):
const int MED_LEN = 7;
float medBuf[12][MED_LEN] = {};
int medIdx = 0;
void updateMedianBuffer() {
for (int i = 0; i < 12; i++) medBuf[i][medIdx] = proximity(i);
medIdx = (medIdx + 1) % MED_LEN;
}
float medianProximity(int electrode) {
float sorted[MED_LEN];
memcpy(sorted, medBuf[electrode], sizeof(sorted));
// Insertion sort
for (int i = 1; i < MED_LEN; i++) {
float k = sorted[i]; int j = i - 1;
while (j >= 0 && sorted[j] > k) { sorted[j+1] = sorted[j]; j--; }
sorted[j+1] = k;
}
return sorted[MED_LEN / 2];
}
Kalman Filter (1D)
The Kalman filter is optimal for Gaussian noise and gives smooth, low-latency estimates with an explicit noise model. For each electrode:
struct Kalman1D {
float q; // process noise covariance
float r; // measurement noise covariance
float x; // estimated state
float p; // estimated error covariance
float k; // Kalman gain
Kalman1D(float q = 0.1f, float r = 5.0f)
: q(q), r(r), x(0), p(1), k(0) {}
float update(float measurement) {
// Predict
p += q;
// Update
k = p / (p + r);
x += k * (measurement - x);
p *= (1 - k);
return x;
}
};
Kalman1D kf[12];
void setup() {
// Tune per electrode: higher r = more smoothing, more lag
for (int i = 0; i < 12; i++) kf[i] = Kalman1D(0.5f, 10.0f);
}
void loop() {
readAllChannels();
for (int i = 0; i < 12; i++) {
float smoothed = kf[i].update((float)proximity(i));
// Use smoothed value for centroid / gesture logic
}
}
Tune r upward for more smoothing (at the cost of latency) and q upward to track faster motions.
Baseline Management
The MPR121’s on-chip baseline tracking works for gradual drift, but fast environmental changes (putting the board down on a conductive surface, picking it up, pressing it against a wall) can throw the baseline off. Implement a manual baseline reset in firmware:
void resetBaseline(int electrode) {
// Read current filtered value and write it as the new baseline
int current = filteredData[electrode];
// Baseline register stores upper 8 bits of 10-bit value
uint8_t baseVal = (current >> 2) & 0xFF;
Wire.beginTransmission(0x5C);
Wire.write(0x1E + electrode);
Wire.write(baseVal);
Wire.endTransmission();
}
void resetAllBaselines() {
for (int i = 0; i < 12; i++) resetBaseline(i);
}
Call resetAllBaselines() on startup after a 500 ms settle delay, or when a user presses a dedicated recalibrate button.
Gesture Classification
Binary touch events and continuous position give you two-thirds of a gesture system. The third component is temporal pattern recognition — classifying sequences of positions and events into named gestures.
Feature Extraction
Record a trajectory when any proximity signal exceeds a threshold and the hand is not yet in touch:
struct TrajectoryPoint {
float position; // centroid value
float energy; // total proximity sum
uint32_t time; // millis()
};
const int TRAJ_LEN = 50;
TrajectoryPoint trajectory[TRAJ_LEN];
int trajCount = 0;
bool tracking = false;
void updateTrajectory() {
float pos = computeCentroid();
float energy = 0;
for (int i = 0; i < 12; i++) energy += max(0, proximity(i));
if (energy > 50 && !tracking) {
tracking = true;
trajCount = 0;
}
if (energy < 20 && tracking) {
tracking = false;
classifyGesture();
}
if (tracking && trajCount < TRAJ_LEN) {
trajectory[trajCount++] = { pos, energy, millis() };
}
}
Extracting Simple Features
struct GestureFeatures {
float startPos, endPos, deltaPos;
float duration;
float peakEnergy;
float avgVelocity;
};
GestureFeatures extractFeatures() {
if (trajCount < 2) return {};
GestureFeatures f;
f.startPos = trajectory[0].position;
f.endPos = trajectory[trajCount-1].position;
f.deltaPos = f.endPos - f.startPos;
f.duration = trajectory[trajCount-1].time - trajectory[0].time;
f.avgVelocity = f.duration > 0 ? f.deltaPos / (f.duration / 1000.0f) : 0;
f.peakEnergy = 0;
for (int i = 0; i < trajCount; i++)
f.peakEnergy = max(f.peakEnergy, trajectory[i].energy);
return f;
}
Rule-Based Classifier
const char* classifyGesture() {
GestureFeatures f = extractFeatures();
if (f.duration < 50) return "TAP";
if (f.deltaPos > 3.0f && f.avgVelocity > 8.0f) return "SWIPE_RIGHT";
if (f.deltaPos < -3.0f && f.avgVelocity < -8.0f) return "SWIPE_LEFT";
if (f.peakEnergy > 200 && fabsf(f.deltaPos) < 1.5f) return "HOVER";
if (f.duration > 800 && fabsf(f.deltaPos) < 2.0f) return "HOLD";
return "UNKNOWN";
}
k-NN Gesture Classifier
For more robust classification, record labelled examples and classify new gestures by their nearest neighbour in feature space:
struct GestureExample {
float features[6]; // deltaPos, duration, peakEnergy, avgVelocity, startPos, endPos
const char* label;
};
// Collect examples by running this function for each labelled gesture
void recordExample(const char* label) {
GestureFeatures f = extractFeatures();
// Store: {f.deltaPos, f.duration, f.peakEnergy, f.avgVelocity, f.startPos, f.endPos}
// ... (store in EEPROM or PROGMEM for production)
}
float featureDistance(float* a, float* b, int len) {
float d = 0;
for (int i = 0; i < len; i++) d += (a[i]-b[i])*(a[i]-b[i]);
return sqrtf(d);
}
const char* knnClassify(GestureExample* examples, int n, GestureFeatures& f) {
float query[6] = { f.deltaPos, f.duration, f.peakEnergy,
f.avgVelocity, f.startPos, f.endPos };
float bestDist = 1e10f;
const char* bestLabel = "UNKNOWN";
for (int i = 0; i < n; i++) {
float d = featureDistance(query, examples[i].features, 6);
if (d < bestDist) { bestDist = d; bestLabel = examples[i].label; }
}
return bestLabel;
}
Normalise each feature (subtract mean, divide by standard deviation) before computing distances, or features with large ranges (like duration in milliseconds) will dominate the metric.
Adaptive Thresholds
Fixed touch/release thresholds fail when electrode material or environmental conditions change. Adapt thresholds based on observed proximity distribution:
float adaptiveThreshold[12];
float proximityHistory[12][100];
int histIdx = 0;
void updateAdaptiveThresholds() {
for (int i = 0; i < 12; i++) {
proximityHistory[i][histIdx] = proximity(i);
}
histIdx = (histIdx + 1) % 100;
// After filling the buffer, compute a threshold as mean + 2*std
if (histIdx == 0) {
for (int i = 0; i < 12; i++) {
float mean = 0, var = 0;
for (int j = 0; j < 100; j++) mean += proximityHistory[i][j];
mean /= 100;
for (int j = 0; j < 100; j++) {
float d = proximityHistory[i][j] - mean;
var += d * d;
}
var /= 100;
adaptiveThreshold[i] = mean + 2.0f * sqrtf(var);
}
}
}
bool adaptiveTouched(int electrode) {
return proximity(electrode) > adaptiveThreshold[electrode];
}
This method requires a calibration period (about 5 seconds at 20 Hz) during which the surface should not be touched. Call it once on startup and optionally re-run it when a recalibration button is pressed.
Sending Processed Data to a Host
Package the processed output as a compact binary protocol for high-speed transmission to a host computer over USB:
// Packet: [0xFF][electrode(1)][value_hi(1)][value_lo(1)][checksum(1)]
void sendPacket(uint8_t electrode, int16_t value) {
uint8_t hi = (value >> 8) & 0xFF;
uint8_t lo = value & 0xFF;
uint8_t cs = electrode ^ hi ^ lo;
Serial.write(0xFF);
Serial.write(electrode);
Serial.write(hi);
Serial.write(lo);
Serial.write(cs);
}
void loop() {
readAllChannels();
for (int i = 0; i < 12; i++) {
float smooth = kf[i].update((float)proximity(i));
sendPacket(i, (int16_t)smooth);
}
}
At 12 electrodes × 5 bytes/packet × 100 Hz = 6000 bytes/s — well within USB CDC bandwidth. On the host side, parse synchronisation by scanning for the 0xFF start byte.
Next Steps
- Implement a particle filter (sequential Monte Carlo) for robust 2D position tracking with occlusion handling
- Use dynamic time warping (DTW) for gesture classification — handles speed variation better than Euclidean distance
- Explore compressed sensing for reconstructing 2D touch distributions from fewer measurements
- Feed the Kalman-filtered proximity signals into a neural network for gesture recognition using TensorFlow Lite for Microcontrollers