/*
* Copyright (C) 2015 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.android.utils.picidae;
import android.util.Log;
import java.util.LinkedList;
/**
* Class to take updates from a sensor with three axes (accelerometer or gyroscope) and detect taps.
* <p>
* The detector consists of:
* <ul>
* <li>An infinite impulse response filter.
* <li>A history buffer for filtered samples whose length (in time) is configurable.
* <li>A noise detector that prevents taps from being detected when the average energy of the
* samples in the history buffer exceeds a threshold.
* <li>A state machine that looks at the signal after a candidate tap to see if it stays within
* specified envelopes.
* <li>A callback to a TapListener to report taps and their confidence.
* </ul>
* Two confidence values are possible: one for POSSIBLE_TAP and a higher one for DEFINITE_TAP. The
* two types of taps have different thresholds and different required envelopes. In addition, a
* POSSIBLE_TAP can be detected even when the signal is noisy if the signal jumps beyond a multiple
* of the noise.
*/
public class ThreeDSensorTapDetector {
private static final boolean DEBUG = false;
private static final int NUMBER_OF_DIMENSIONS = 3;
private static final double POSSIBLE_TAP_CONFIDENCE = 0.15;
private static final double DEFINITE_TAP_CONFIDENCE = 0.5;
/** If there is a gap larger than this between two samples, the state machine resets. */
private static final long MAX_PERMITTED_TIME_BETWEEN_SAMPLES_NANOS = 100 * 1000 * 1000;
/** Keep track of the energy of the filtered signal for this long. */
private static final long ENERGY_HISTORY_LENGTH_NANOS = 100 * 1000 * 1000;
/** Force the state machine to assume the signal is noisy until it has this much history. */
private static final long MIN_HISTORY_FOR_NOT_NOISY_NANOS = 80 * 1000 * 1000;
private final TapListener mTapListener;
/** The largest possible energy from the input signal */
private final float mLargestMagSq;
private float mConditionedSignalEnergy = 0f;
private float mLastConditionedMagnitudeSq;
/** The current state of the state machine */
private SensorDetectorState mCurrentState;
/** The last input received from the sensor */
private float[] mLastInput = {
0f, 0f, 0f };
private float[] mLastFilterOutput = {
0f, 0f, 0f };
private final LinkedList<EnergySamplePair> mEnergySamplesList;
private long mLastTimestamp;
private long mCandidateTapStart;
private ThreeDSensorTapDetectorType mDetectorType;
/**
* @param tapListener Receiver for tap updates
* @param sensorMaxScale The maximum value available from the sensor (each axis)
* @param type The type of detector - ACCELEROMETER or GYROSCOPE.
*/
public ThreeDSensorTapDetector(TapListener tapListener, float sensorMaxScale,
ThreeDSensorTapDetectorType type) {
mLargestMagSq = NUMBER_OF_DIMENSIONS * sensorMaxScale * sensorMaxScale;
mEnergySamplesList = new LinkedList<>();
mTapListener = tapListener;
mDetectorType = type;
mLastTimestamp = 0;
changeToNewCurrentState(0, SensorDetectorState.TOO_NOISY);
}
/** Call with updates from accelerometer sensor. Parameters are from the SensorEvent. */
public void onSensorChanged(long timestamp, float values[]) {
if (Math.abs(timestamp - mLastTimestamp) > MAX_PERMITTED_TIME_BETWEEN_SAMPLES_NANOS) {
clearEnergySamplesList();
if (DEBUG) {
Log.v("threeDSensorTapDetector", "Discontinuity in input time");
}
if (mCurrentState != SensorDetectorState.TOO_NOISY) {
changeToNewCurrentState(timestamp, SensorDetectorState.TOO_NOISY);
}
}
mLastTimestamp = timestamp;
/* High-pass filter each component, sum the squared magnitude */
mLastConditionedMagnitudeSq = 0f;
for (int i = 0; i < NUMBER_OF_DIMENSIONS; ++i) {
mLastFilterOutput[i] = mDetectorType.filterNum[0] * values[i]
+ mDetectorType.filterNum[1] * mLastInput[i]
- mDetectorType.filterDen[1] * mLastFilterOutput[i];
mLastInput[i] = values[i];
mLastConditionedMagnitudeSq += mLastFilterOutput[i] * mLastFilterOutput[i];
}
/*
* Track the signal energy (for high-pass signal, nearly identical to variance) for the
* recent past
*/
mConditionedSignalEnergy += mLastConditionedMagnitudeSq;
mEnergySamplesList.addLast(new EnergySamplePair(timestamp, mLastConditionedMagnitudeSq));
while (mEnergySamplesList.getFirst().mTime <= timestamp - ENERGY_HISTORY_LENGTH_NANOS) {
mConditionedSignalEnergy -= mEnergySamplesList.getFirst().mValue;
mEnergySamplesList.removeFirst();
}
/* State machine for tap processing */
if (DEBUG) {
Log.v("threeDSensorTapDetector", String.format(
"State %s, CurrentEnergy %f, size %d, limit %f, signal %f",
mCurrentState.name(), mConditionedSignalEnergy, mEnergySamplesList.size(),
mEnergySamplesList.size() * mDetectorType.energyPerSampleNoiseLimit,
mLastConditionedMagnitudeSq));
}
switch (mCurrentState) {
case NO_TAP:
stateMachineNoTap(timestamp);
break;
case PROCESSING_CANDIDATE_DEFINITE_TAP:
stateMachineProcessingDefiniteTap(timestamp);
break;
case PROCESSING_CANDIDATE_POSSIBLE_TAP:
stateMachineProcessingPossibleTap(timestamp);
break;
case TOO_NOISY:
stateMachineTooNoisy(timestamp);
break;
case DEFINITE_TAP:
case POSSIBLE_TAP:
/* Force signal to settle down after taps */
changeToNewCurrentState(timestamp, SensorDetectorState.TOO_NOISY);
break;
default:
}
}
// Visible for testing
/* package */ float getConditionedSignalEnergy() {
return mConditionedSignalEnergy;
}
// Visible for testing
/* package */ float getLastConditionedMagnitudeSq() {
return mLastConditionedMagnitudeSq;
}
/*
* Current state is NO_TAP.
* Transition to PROCESSING_CANDIDATE_DEFINITE_TAP or PROCESSING_CANDIDATE_POSSIBLE_TAP
* if above corresponding thresholds.
* Transition to TOO_NOISY if history buffer contains too much signal energy
*/
private void stateMachineNoTap(long timestamp) {
if (mLastConditionedMagnitudeSq > mDetectorType.thresholdForDefiniteTap) {
changeToNewCurrentState(timestamp,
SensorDetectorState.PROCESSING_CANDIDATE_DEFINITE_TAP);
mCandidateTapStart = timestamp;
} else if (mLastConditionedMagnitudeSq > mDetectorType.thresholdForPossibleTap) {
changeToNewCurrentState(timestamp,
SensorDetectorState.PROCESSING_CANDIDATE_POSSIBLE_TAP);
mCandidateTapStart = timestamp;
} else if (mConditionedSignalEnergy
> mEnergySamplesList.size() * mDetectorType.energyPerSampleNoiseLimit) {
changeToNewCurrentState(timestamp, SensorDetectorState.TOO_NOISY);
}
}
/*
* Current state is PROCESSING_CANDIDATE_DEFINITE_TAP.
* Transition to PROCESSING_CANDIDATE_POSSIBLE_TAP if the signal doesn't stay within the
* definite tap envelope. If this transition happens, call the state machine processing for
* PROCESSING_CANDIDATE_POSSIBLE_TAP. Transition to DEFINITE_TAP if we've been inside the
* envelope long enough.
*/
private void stateMachineProcessingDefiniteTap(long timestamp) {
/*
* Interpolate from full-scale down to low level. This limit will exceed full scale until
* highAmplitudeTime expires, and that's fine as the idea is that there is no limit at that
* point.
*/
long x1 = mCandidateTapStart + mDetectorType.definiteTapsHighAmplitudeTimeNanos;
float y1 = mLargestMagSq;
long x2 = x1 + mDetectorType.definiteTapsFallTimeNanos;
float y2 = mDetectorType.definiteTapsLowLevel;
float envelope = y1 + (y2 - y1) * ((float) timestamp - x1) / ((float) x2 - x1);
/* Force envelope to be at least lowLevel */
envelope = Math.max(envelope, mDetectorType.definiteTapsLowLevel);
if (mLastConditionedMagnitudeSq > envelope) {
/* The signal isn't dying down nicely. Downgrade the tap. */
if (DEBUG) {
Log.v("threeDSensorTapDetector", String.format(
"Tap downgraded to possible at %d. Signal %f limit %f", timestamp,
mLastConditionedMagnitudeSq, envelope));
}
changeToNewCurrentState(
timestamp, SensorDetectorState.PROCESSING_CANDIDATE_POSSIBLE_TAP);
stateMachineProcessingPossibleTap(timestamp);
} else if (timestamp > x2 + mDetectorType.definiteTapsLowTimeNanos) {
changeToNewCurrentState(mCandidateTapStart, SensorDetectorState.DEFINITE_TAP);
}
}
/*
* Current state is PROCESSING_CANDIDATE_POSSIBLE_TAP.
* Transition to TOO_NOISY if the signal doesn't stay within the possible tap envelope.
* Transition to POSSIBLE_TAP if we've been inside the envelope long enough.
*/
private void stateMachineProcessingPossibleTap(long timestamp) {
/*
* Interpolate from full-scale down to low level. This limit will exceed full scale until
* highAmplitudeTime expires, and that's fine as the idea is that there is no limit at that
* point.
*/
long x1 = mCandidateTapStart + mDetectorType.possibleTapsHighAmplitudeTimeNanos;
float y1 = mLargestMagSq;
long x2 = x1 + mDetectorType.possibleTapsFallTimeNanos;
float y2 = mDetectorType.possibleTapsLowLevel;
float envelope = y1 + (y2 - y1) * ((float) timestamp - x1) / ((float) x2 - x1);
/* Force envelope to be at least lowLevel */
envelope = Math.max(envelope, mDetectorType.possibleTapsLowLevel);
if (mLastConditionedMagnitudeSq > envelope) {
if (DEBUG) {
Log.v("threeDSensorTapDetector", String.format(
"Tap downgraded to noise at %d. Signal %f limit %f", timestamp,
mLastConditionedMagnitudeSq, envelope));
}
changeToNewCurrentState(timestamp, SensorDetectorState.TOO_NOISY);
} else if (timestamp > x2 + mDetectorType.possibleTapsLowTimeNanos) {
changeToNewCurrentState(mCandidateTapStart, SensorDetectorState.POSSIBLE_TAP);
}
}
/*
* Current state is TOO_NOISY.
* Stay in this state until we have decent amount of signal history.
* Transition to NO_TAP if the signal history has low energy.
* Transition to PROCESSING_CANDIDATE_POSSIBLE_TAP if we get a tap well above the current noise
* level.
*/
private void stateMachineTooNoisy(long timestamp) {
/* Stay in this state until we have enough history to judge the signal */
long timeSpanInHistoryNanos = mEnergySamplesList.getLast().mTime
- mEnergySamplesList.getFirst().mTime;
if (timeSpanInHistoryNanos < MIN_HISTORY_FOR_NOT_NOISY_NANOS) {
return;
}
/* Allow a possible tap on a sudden jump in signal energy */
if (mLastConditionedMagnitudeSq * mEnergySamplesList.size()
> mDetectorType.multipleOfNoiseForPossibleTap * mConditionedSignalEnergy) {
changeToNewCurrentState(
timestamp, SensorDetectorState.PROCESSING_CANDIDATE_POSSIBLE_TAP);
mCandidateTapStart = timestamp;
return;
}
/* Check if signal isn't noisy anymore */
if (mConditionedSignalEnergy <
mEnergySamplesList.size() * mDetectorType.energyPerSampleNoiseLimit) {
changeToNewCurrentState(timestamp, SensorDetectorState.NO_TAP);
}
}
private void changeToNewCurrentState(long timestamp, SensorDetectorState newState) {
mCurrentState = newState;
if (DEBUG) {
Log.v("threeDSensorTapDetector", String.format("Transition to State %s at time %d",
mCurrentState.name(), timestamp));
}
if (newState == SensorDetectorState.POSSIBLE_TAP) {
mTapListener.threeDSensorTapDetected(this, timestamp, POSSIBLE_TAP_CONFIDENCE);
}
if (newState == SensorDetectorState.DEFINITE_TAP) {
mTapListener.threeDSensorTapDetected(this, timestamp, DEFINITE_TAP_CONFIDENCE);
}
}
private void clearEnergySamplesList() {
mEnergySamplesList.clear();
mConditionedSignalEnergy = 0;
}
/**
* Listener to receive detected taps
*/
public interface TapListener {
/**
* Callback for a detected tap
*
* @param threeDSensorTapDetector The detector sending the update
* @param timestamp The timestamp of the detected tap
* @param tapConfidence The confidence of the tap. 0.0 is no confidence, and 1.0 is
* absolute certainty.
*/
public void threeDSensorTapDetected(ThreeDSensorTapDetector threeDSensorTapDetector,
long timestamp, double tapConfidence);
}
/**
* List of states that detectors for individual sensors can be in
*/
private enum SensorDetectorState {
/** Detector is confident that no tap has occurred */
NO_TAP,
/** Detector is confident a tap has occurred */
DEFINITE_TAP,
/** Detector thinks a tap may have occurred */
POSSIBLE_TAP,
/** Detector sees a signal too noisy to know whether or not a tap happened */
TOO_NOISY,
/** Intermediate step when detector is processing a candidate possible tap */
PROCESSING_CANDIDATE_POSSIBLE_TAP,
/** Intermediate step when detector is processing a candidate definite tap */
PROCESSING_CANDIDATE_DEFINITE_TAP,
}
/*
* Hold a (time, value) object for the history buffer
*/
private class EnergySamplePair {
public long mTime;
public float mValue;
public EnergySamplePair(long time, float value) {
mTime = time;
mValue = value;
}
}
}