MAX3010x Pulseoximetermodule (Teil 3)

Module Biometrie Filter

Unser bisheriger Ansatz zur Pulsmessung hat nicht wirklich gut funktioniert. Es ist Zeit für einen besseren Algorithmus.

Fortgeschrittene Herzschlagerkennung

Im letzten Teil dieses Tutorials haben wir unseren bisherigen Algorithmus zur Pulsmessung mit den MAX3010x-Sensoren getestet. Obwohl das Ganze einigermaßen funktionierte, war das Ergebnis nicht so gut wie gewünscht. Aus diesem Grund werden wir uns heute einen anderen, technischeren Ansatz ansehen.

Der neue Ansatz verwendet eine Verarbeitungspipeline, die aus mehreren Schritten besteht. Als erstes wenden wir unsere Fingererkennung an. Der zweite Schritt ist die Vorverarbeitung der Rohdaten für die Herzschlagerkennung. Dazu wenden wir sowohl einen digitalen Tiefpass- als auch einen digitalen Hochpassfilter an. Bei der Herzschlagerkennung selbst handelt es sich um einen Peak-Detektor. Hierfür suchen wir nach Nulldurchgängen in der Ableitung des Signals. So können wir die Hochpunkte im Signalverlauf genau bestimmen. Vielleicht kommt dir das bekannt vor. Es ist die übliche Methode zur Bestimmung des Maximums einer Funktion in der Mathematik oder, genauer gesagt, in der Analysis. Sobald die Herzschläge erkannt wurden, wird die Herzfrequenz berechnet. Als letzten Schritt wenden wir ein Algorithmus zur Mittelwertbildung an, um einen stabilen Wert für die Herzfrequenz zu ermitteln.

Verarbeitungspipeline

Der Code

Wie können wir das Ganze realisieren? Nun, es ist nicht sehr überraschend, dass der Code komplizierter ist als sonst. Aus diesem Grund habe ich den Code in zwei verschiedene Dateien aufgeteilt: eine mit dem Hauptprogramm und eine, die die Implementierung für die einzelnen Blöcke unserer Filterpipeline enthält.

Um eine zweite Datei zu erstellen, klicke auf den Pfeil auf der rechten Seite der Arduino IDE und dann auf Neuer Tab oder drücke einfach Strg+Shift+N. Nenne die Hilfsdatei filters.h.

Öffnen einer neuen Registerkarte in der Arduino IDE

Hier ist der Code für das Hauptprogramm und die Filterblöcke:

#include <MAX3010x.h>
#include "filters.h"

// Sensor (adjust to your sensor type)
MAX30105 sensor;
const auto kSamplingRate = sensor.SAMPLING_RATE_400SPS;
const float kSamplingFrequency = 400.0;

// Finger Detection Threshold and Cooldown
const unsigned long kFingerThreshold = 10000;
const unsigned int kFingerCooldownMs = 500;

// Edge Detection Threshold (decrease for MAX30100)
const float kEdgeThreshold = -2000.0;

// Filters
const float kLowPassCutoff = 5.0;
const float kHighPassCutoff = 0.5;

// Averaging
const bool kEnableAveraging = true;
const int kAveragingSamples = 50;
const int kSampleThreshold = 5;

void setup() {
  Serial.begin(9600);

  if(sensor.begin() && sensor.setSamplingRate(kSamplingRate)) { 
    Serial.println("Sensor initialized");
  }
  else {
    Serial.println("Sensor not found");  
    while(1);
  }
}

// Filter Instances
HighPassFilter high_pass_filter(kHighPassCutoff, kSamplingFrequency);
LowPassFilter low_pass_filter(kLowPassCutoff, kSamplingFrequency);
Differentiator differentiator(kSamplingFrequency);
MovingAverageFilter<kAveragingSamples> averager;

// Timestamp of the last heartbeat
long last_heartbeat = 0;

// Timestamp for finger detection
long finger_timestamp = 0;
bool finger_detected = false;

// Last diff to detect zero crossing
float last_diff = NAN;
bool crossed = false;
long crossed_time = 0;

void loop() {
  auto sample = sensor.readSample(1000);
  float current_value = sample.red;
  
  // Detect Finger using raw sensor value
  if(sample.red > kFingerThreshold) {
    if(millis() - finger_timestamp > kFingerCooldownMs) {
      finger_detected = true;
    }
  }
  else {
    // Reset values if the finger is removed
    differentiator.reset();
    averager.reset();
    low_pass_filter.reset();
    high_pass_filter.reset();
    
    finger_detected = false;
    finger_timestamp = millis();
  }

  if(finger_detected) {
    current_value = low_pass_filter.process(current_value);
    current_value = high_pass_filter.process(current_value);
    float current_diff = differentiator.process(current_value);

    // Valid values?
    if(!isnan(current_diff) && !isnan(last_diff)) {
      
      // Detect Heartbeat - Zero-Crossing
      if(last_diff > 0 && current_diff < 0) {
        crossed = true;
        crossed_time = millis();
      }
      
      if(current_diff > 0) {
        crossed = false;
      }
  
      // Detect Heartbeat - Falling Edge Threshold
      if(crossed && current_diff < kEdgeThreshold) {
        if(last_heartbeat != 0 && crossed_time - last_heartbeat > 300) {
          // Show Results
          int bpm = 60000/(crossed_time - last_heartbeat);
          if(bpm > 50 && bpm < 250) {
            // Average?
            if(kEnableAveraging) {
              int average_bpm = averager.process(bpm);
  
              // Show if enough samples have been collected
              if(averager.count() > kSampleThreshold) {
                Serial.print("Heart Rate (avg, bpm): ");
                Serial.println(average_bpm);
              }
            }
            else {
              Serial.print("Heart Rate (current, bpm): ");
              Serial.println(bpm);  
            }
          }
        }
  
        crossed = false;
        last_heartbeat = crossed_time;
      }
    }

    last_diff = current_diff;
  }
}

Den Code verstehen

Wie funktioniert dieses Programm? Nun, ich werde in diesem Tutorial nicht auf alle Details eingehen können, aber ich werde mein Bestes geben, um zu erklären, wie das Hauptprogramm funktioniert. Wenn du dich für die Implementierung von digitalen Filtern interessierst, empfehle ich dir, den DSP Guide von Steven W. Smith zu lesen. Den theoretischen Hintergrund für den digitalen Tief- und Hochpassfilter findest du in Chapter 19 - Recursive Filters.

Werfen wir einen Blick auf das Programm. Der erste Schritt ist die Initialisierung in der setup-Prozedur. Es gibt nur eine kleine Änderung: die Abtastrate wird von 50 auf 400 Samples pro Sekunde erhöht. In der Hauptschleife ist der erste Schritt die Fingererkennung. Dies geschieht anhand eines einfachen Schwellenwerts. Liegt der gemessene Wert unter diesem Schwellenwert, liegt kein Finger auf den Sensor. In diesem Fall werden alle Filter zurückgesetzt, sodass sie mit frischen Werten starten, sobald jemand seinen Finger auf den Sensor legt. Dies ermöglicht eine schnellere Erkennung des ersten Herzschlags. Aus dem gleichen Grund gibt es einen Cooldown von 500 ms. Dadurch werden die instabilen und sich schnell ändernde Messwerte ignoriert, die beim Auflegen des Fingers auf den Sensor auftreten. Der Cooldown kann über die Variable kFingerCooldownMs angepasst werden. Nur wenn ein Finger erkannt wird, wird der Rest der Verarbeitungspipeline aktiv.

Schauen wir uns den interessanteren Teil an: den neuen Erkennungsalgorithmus und die digitalen Filter. Der erste Filter, der angewendet wird, ist ein Tiefpassfilter. Ein Tiefpassfilter dämpft die hohen Frequenzanteile eines Signals und lässt die niedrigen Frequenzanteile ungehindert hindurch. Die Grenzfrequenz für diesen Filter kann über die Variable kLowPassCutoff eingestellt werden und ist standardmäßig auf 5 Hz festgelegt. Da 5 Hz 300 bpm entsprechen, können wir davon ausgehen, dass alle höherfrequenten Signalkomponenten Sensorrauschen sind und uns nicht wirklich interessieren. Der Hochpassfilter funktioniert in umgekehrter Weise. Seine Grenzfrequenz liegt standardmäßig bei 0,5 Hz (30 bpm) und wird in der Variablen kHighPassCutoff definiert. Dieser Filter entfernt den DC-Offset und den durch Temperaturänderungen verursachten langsamen Sensordrift. Die Funktionsweise dieses Filters ähnelt der, der dynamisch angepassten Schwellenwerte in unserer alten Lösung. Der Filter leidet auch unter einer langen Anpassungszeit nach einer plötzlichen Wertänderung beim Auflegen des Fingers auf den Sensor, was genau der Grund ist, warum ich die Filterwerte zurücksetze und einen Cooldown implementiert habe.

Kommen wir nun zum eigentlichen Erkennungsalgorithmus. Für die Peak-Erkennung müssen wir die Ableitung von unserem Signal berechnen. Glücklicherweise ist dies bei diskreten Werten mit konstanter Abtastrate sehr einfach. Die einzige Aufgabe des Differenzierers besteht darin, den vorherigen Wert vom aktuellen Wert zu subtrahieren und das Ergebnis durch die vergangene Zeit zu teilen. So erhalten wir einen von der Samplerate unabhängigen Wert für die Steigung unseres Signals. Bei einem Nulldurchgang befinden wir uns an einem lokalen Maximum (positiv zu negativ) oder Minimum (negativ zu positiv). Für unsere Peak-Erkennung müssen wir auf einen Nulldurchgang von positiv nach negativ achten. Genau das tut der Code. Wir müssen jedoch auch sicherstellen, dass der Peak wirklich ein Herzschlag ist und nicht nur durch Signalrauschen verursacht wird. Neben dem Tiefpassfilter verwenden wir dazu einen einfachen Schwellenwert, der sicherstellt, dass der Signalpegel nach dem Maximum weiter fällt, und zwar schnell genug. Der Schwellenwert dafür wird in der Variablen kEdgeThreshold eingestellt. Wenn du einen MAX30100 verwendest, musst du diesen Schwellenwert möglicherweise verringern, damit die Erkennung funktioniert.

Nach der Herzschlagerkennung erfolgt lediglich noch die Berechnung der Herzfrequenz auf der Grundlage der Zeitdifferenz und die optionale Mittelwertbildung. Standardmäßig ist die Mittelwertbildung eingeschaltet. Du kannst sie deaktivieren, indem du kEnableAveraging auf false setzt. Der verwendete Algorithmus zur Mittelwertbildung ist ein simpler gleitender Mittelwert. Die Anzahl der Samples, über die gemittelt werden soll, kann in der Variablen kAveragingSamples festgelegt werden und ist standardmäßig auf 50 Samples eingestellt. Die Variable kSampleThreshold bestimmt, wie viele Samples gesammelt werden sollen, bevor die ersten Ergebnisse angezeigt werden. Die Genauigkeit des Ergebnisses nimmt zu, je mehr Messwerte gesammelt werden, und erreicht ihr Maximum bei kAveragingSamples gesammelten Samples.

Genug der Theorie. Wenn du sehen möchtest, wie sich die Filter auf das Signal und die Herzschlagerkennung auswirken, kannst du den unten stehenden Simulator verwenden. Wenn du möchtest, kannst du auch deine Daten importieren, indem du das mitgelieferte Messprogramm verwendest und die aufgezeichneten Werte in die Registerkarte "Daten" einfügst.

Grenzfrequenz (Hz)
Grenzfrequenz (Hz)
Schwellwert für Signalabfall


Das Ergebnis

Lohnt sich der Aufwand? Ja, die Herzschlagerkennung ist nun zuverlässiger und man muss nicht mehr 20 Sekunden auf die ersten Ergebnisse warten. Ohne Mittelwertbildung erscheinen die ersten Messungen nach 2-3 Sekunden. Mit Mittelwertbildung werden die Ergebnisse nach 7-8 Sekunden angezeigt, da standardmäßig fünf Messwerte erfasst werden müssen, bis der Durchschnitt zum ersten Mal angezeigt wird. Durch die Mittelwertbildung ist der ausgegebene Wert jetzt stabil und erlaubt ein einfaches Ablesen der durchschnittlichen Herzfrequenz. Wenn du dich für die Herzfrequenzvariabilität interessierst, kannst du die Mittelwertbildung ausschalten und erhältst dann die Rohdaten basierend auf dem Intervall zwischen jeweils zwei Herzschlägen.

Pulsmessungen im seriellen Monitor

Vorheriger Beitrag Nächster Beitrag