added working step counter
This commit is contained in:
@@ -96,6 +96,12 @@ class LiveStepDetector(
|
||||
// momentary drop in torso-landmark confidence doesn't stall detection.
|
||||
private var lastKnownTorsoScale: Float? = null
|
||||
|
||||
// Previous call's raw ankle/hip readings, used only to detect a stalled
|
||||
// pipeline -- see the isStalledFrame check in [update].
|
||||
private var lastLeftAnkleRaw: LandmarkPoint? = null
|
||||
private var lastRightAnkleRaw: LandmarkPoint? = null
|
||||
private var lastHipMid: Pair<Float, Float>? = null
|
||||
|
||||
/**
|
||||
* @brief Feeds one frame's pose data into the detector, updating step
|
||||
* count/stillness state and returning what happened this call.
|
||||
@@ -104,6 +110,32 @@ class LiveStepDetector(
|
||||
*/
|
||||
fun update(frame: PoseFrame): Result {
|
||||
val newSteps = mutableListOf<StepEvent>()
|
||||
val hipMid = hipMidpoint(frame)
|
||||
|
||||
// ML Kit's STREAM_MODE detector re-runs inference on every frame it's
|
||||
// handed, so even a genuinely motionless bowler produces a pixel or
|
||||
// two of per-frame detection noise -- real landmark positions don't
|
||||
// repeat bit-for-bit. When every landmark this frame exactly matches
|
||||
// the previous frame's, the camera/analysis pipeline stalled (frame
|
||||
// backlog, autofocus hunt, ...) and re-delivered a stale pose rather
|
||||
// than a fresh one, rather than the bowler actually holding still.
|
||||
// Confirmed against a real device trace: a run of 15+ frames spanning
|
||||
// over a second with bit-identical ankle/hip values, which
|
||||
// StillnessTracker read as a held "ready" stance and used to wipe out
|
||||
// an in-progress step count moments after it was earned. Treat a
|
||||
// stalled frame like a dropped one -- skip peak/stillness tracking
|
||||
// for it entirely rather than feed it stale data.
|
||||
val isStalledFrame = (frame.leftAnkleRaw != null || frame.rightAnkleRaw != null || hipMid != null) &&
|
||||
frame.leftAnkleRaw == lastLeftAnkleRaw &&
|
||||
frame.rightAnkleRaw == lastRightAnkleRaw &&
|
||||
hipMid == lastHipMid
|
||||
lastLeftAnkleRaw = frame.leftAnkleRaw
|
||||
lastRightAnkleRaw = frame.rightAnkleRaw
|
||||
lastHipMid = hipMid
|
||||
|
||||
if (isStalledFrame) {
|
||||
return Result(stepCount = stepCount, newSteps = emptyList(), wasReset = false)
|
||||
}
|
||||
|
||||
torsoScale(frame)?.let { lastKnownTorsoScale = it }
|
||||
val scale = lastKnownTorsoScale
|
||||
@@ -129,7 +161,6 @@ class LiveStepDetector(
|
||||
}
|
||||
|
||||
var wasReset = false
|
||||
val hipMid = hipMidpoint(frame)
|
||||
if (hipMid != null && scale != null) {
|
||||
val isStill = stillness.update(frame.timestampMs, hipMid.first, hipMid.second, scale)
|
||||
if (isStill && !wasStillLastFrame && stepCount > 0) {
|
||||
@@ -160,6 +191,9 @@ class LiveStepDetector(
|
||||
stillness.reset()
|
||||
stepCount = 0
|
||||
wasStillLastFrame = true
|
||||
lastLeftAnkleRaw = null
|
||||
lastRightAnkleRaw = null
|
||||
lastHipMid = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,21 +233,31 @@ class LiveStepDetector(
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Which extremum [FootPeakTracker] is currently tracking toward. */
|
||||
private enum class TrackingMode { SEEKING_PEAK, SEEKING_VALLEY }
|
||||
|
||||
/**
|
||||
* @brief Per-foot streaming peak detector.
|
||||
*
|
||||
* Confirms a local maximum with a one-frame lag -- the sample *after* a
|
||||
* candidate is what proves it was actually a peak and not still rising --
|
||||
* then gates it by [minSpacingMs] (refractory period since the last
|
||||
* accepted peak) and, if a torso-scale reference is available, prominence
|
||||
* relative to it (see [LiveStepDetector]'s class doc for why this isn't a
|
||||
* cumulative range). The `torsoScale` parameter to [update] is nullable
|
||||
* because it may not have been established yet (e.g. the very first frames
|
||||
* of a session, before torso landmarks have ever cleared the confidence
|
||||
* bar) -- in that case prominence is skipped rather than blocking detection
|
||||
* entirely, so the very first step or two can still register even before
|
||||
* there's a scale reference, at the cost of being more jitter-prone until
|
||||
* one is.
|
||||
* A real footfall's ankle-y curve doesn't reach its extremum as a single
|
||||
* sharp spike -- the foot decelerates approaching the ground/top of swing,
|
||||
* so several consecutive frames sit on a noisy plateau near the true peak
|
||||
* before the next clear descent. A candidate that only compares a sample
|
||||
* against its *immediate* left/right neighbors sees near-zero prominence
|
||||
* across that plateau (each frame differs from the next by noise-level
|
||||
* amounts) and never confirms, even though the peak is tens of pixels above
|
||||
* the surrounding valleys -- confirmed against real device recordings where
|
||||
* a clearly step-shaped ~20-45px bounce, sustained over a second-plus
|
||||
* plateau, produced zero confirmed peaks under that approach.
|
||||
*
|
||||
* Tracks a running extremum instead (the standard streaming "zigzag" turning-
|
||||
* point algorithm): while [mode] is SEEKING_PEAK, [extreme] follows the
|
||||
* highest y seen; once y has dropped away from that running high by at
|
||||
* least the prominence threshold, the high is confirmed as a peak and
|
||||
* tracking flips to SEEKING_VALLEY to find the next low the same way. This
|
||||
* naturally tolerates an arbitrarily long noisy plateau at the top (nothing
|
||||
* about it looks like a drop until the foot actually lifts again) while
|
||||
* still rejecting pure jitter that never clears the threshold either way.
|
||||
*
|
||||
* @param minSpacingMs Minimum time, in milliseconds, between two accepted peaks.
|
||||
* @param minProminenceRatio Minimum required peak prominence, as a fraction of torso scale.
|
||||
@@ -222,8 +266,8 @@ private class FootPeakTracker(
|
||||
private val minSpacingMs: Long,
|
||||
private val minProminenceRatio: Float
|
||||
) {
|
||||
private var beforeCandidate: Pair<Long, Float>? = null
|
||||
private var candidate: Pair<Long, Float>? = null
|
||||
private var mode = TrackingMode.SEEKING_PEAK
|
||||
private var extreme: Pair<Long, Float>? = null
|
||||
private var lastAcceptedMs: Long? = null
|
||||
|
||||
/**
|
||||
@@ -234,33 +278,50 @@ private class FootPeakTracker(
|
||||
* @return The confirmed peak's timestamp, or null if this call didn't confirm one.
|
||||
*/
|
||||
fun update(timestampMs: Long, y: Float, torsoScale: Float?): Long? {
|
||||
val current = extreme
|
||||
if (current == null) {
|
||||
extreme = timestampMs to y
|
||||
return null
|
||||
}
|
||||
|
||||
// No scale reference yet (see the class doc's SEEKING_PEAK/VALLEY
|
||||
// paragraph for when this happens): fall back to confirming on any
|
||||
// move away from the running extremum at all, same tradeoff the
|
||||
// previous implementation made in this situation.
|
||||
val threshold = if (torsoScale != null && torsoScale > 0f) torsoScale * minProminenceRatio else 0f
|
||||
|
||||
var confirmedAtMs: Long? = null
|
||||
val before = beforeCandidate
|
||||
val mid = candidate
|
||||
if (before != null && mid != null && mid.second > before.second && mid.second > y) {
|
||||
val refractoryOk = lastAcceptedMs?.let { mid.first - it >= minSpacingMs } ?: true
|
||||
// Both neighboring dips must clear the threshold -- subtracting
|
||||
// the shallower (larger-y) of the two neighbors is equivalent
|
||||
// to requiring min(mid-before, mid-after) >= threshold.
|
||||
val prominenceOk = if (torsoScale != null && torsoScale > 0f) {
|
||||
(mid.second - maxOf(before.second, y)) >= torsoScale * minProminenceRatio
|
||||
} else {
|
||||
true
|
||||
when (mode) {
|
||||
TrackingMode.SEEKING_PEAK -> {
|
||||
if (y > current.second) {
|
||||
extreme = timestampMs to y
|
||||
} else if (current.second - y >= threshold) {
|
||||
val peakTime = current.first
|
||||
val refractoryOk = lastAcceptedMs?.let { peakTime - it >= minSpacingMs } ?: true
|
||||
if (refractoryOk) {
|
||||
lastAcceptedMs = peakTime
|
||||
confirmedAtMs = peakTime
|
||||
}
|
||||
mode = TrackingMode.SEEKING_VALLEY
|
||||
extreme = timestampMs to y
|
||||
}
|
||||
}
|
||||
if (refractoryOk && prominenceOk) {
|
||||
lastAcceptedMs = mid.first
|
||||
confirmedAtMs = mid.first
|
||||
TrackingMode.SEEKING_VALLEY -> {
|
||||
if (y < current.second) {
|
||||
extreme = timestampMs to y
|
||||
} else if (y - current.second >= threshold) {
|
||||
mode = TrackingMode.SEEKING_PEAK
|
||||
extreme = timestampMs to y
|
||||
}
|
||||
}
|
||||
}
|
||||
beforeCandidate = candidate
|
||||
candidate = timestampMs to y
|
||||
return confirmedAtMs
|
||||
}
|
||||
|
||||
/** @brief Clears all sample/refractory state; call at the start of a new attempt. */
|
||||
fun reset() {
|
||||
beforeCandidate = null
|
||||
candidate = null
|
||||
mode = TrackingMode.SEEKING_PEAK
|
||||
extreme = null
|
||||
lastAcceptedMs = null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user