added working step counter

This commit is contained in:
harine
2026-09-07 16:10:45 +08:00
parent cfdd965cf9
commit 5fe424316e
2 changed files with 258 additions and 33 deletions
@@ -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
}
}