Bare Conductive Expert Article 2

Advanced Capacitive Sensing Algorithms

Position interpolation, noise filtering, gesture classification, and signal processing techniques for the MPR121.

⏱ 80 min MPR121 signal processing interpolation Kalman gesture recognition algorithms

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