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
@@ -0,0 +1,164 @@
package com.example.jnicpp.bowling
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class LiveStepDetectorTest {
private val shoulder = LandmarkPoint(400f, 500f)
private val noAngles = PoseAngles(null, null, null, null)
private fun frame(t: Long, ankleL: Float, ankleR: Float, hipX: Float, hipY: Float) = PoseFrame(
timestampMs = t,
leftAnkle = null,
rightAnkle = null,
leftAnkleRaw = LandmarkPoint(390f, ankleL),
rightAnkleRaw = LandmarkPoint(410f, ankleR),
leftKnee = null,
rightKnee = null,
leftHip = null,
rightHip = null,
leftHipRaw = LandmarkPoint(hipX, hipY),
rightHipRaw = null,
leftShoulder = shoulder,
rightShoulder = null,
leftElbow = null,
rightElbow = null,
leftWrist = null,
rightWrist = null,
angles = noAngles
)
/**
* Reproduces the failure seen on a real device recording: a run of
* bit-identical pose frames (a stalled camera/analysis pipeline, not a
* stationary bowler) landing right after real steps were counted. Before
* the isStalledFrame fix, StillnessTracker read that frozen run as a
* held "ready" stance and reset the count back to zero.
*/
@Test
fun stalledFramesDoNotResetAnInProgressCount() {
val detector = LiveStepDetector()
// Left-foot step: rising, peak, falling -- confirms on the 3rd call.
detector.update(frame(0, ankleL = 1000f, ankleR = 700f, hipX = 400f, hipY = 800f))
detector.update(frame(50, ankleL = 1100f, ankleR = 700f, hipX = 402f, hipY = 802f))
var result = detector.update(frame(100, ankleL = 1000f, ankleR = 700f, hipX = 405f, hipY = 805f))
assertEquals(1, result.stepCount)
// Right-foot step, past the 300ms refractory window.
detector.update(frame(350, ankleL = 1000f, ankleR = 700f, hipX = 410f, hipY = 810f))
detector.update(frame(400, ankleL = 1000f, ankleR = 800f, hipX = 415f, hipY = 815f))
result = detector.update(frame(450, ankleL = 1000f, ankleR = 700f, hipX = 420f, hipY = 820f))
assertEquals(2, result.stepCount)
// Pipeline stall: the exact same frame re-delivered for 700ms,
// well past the 600ms default stillness window.
val frozen = frame(500, ankleL = 1000f, ankleR = 700f, hipX = 420f, hipY = 820f)
for (t in longArrayOf(500, 600, 700, 800, 900, 1000, 1100, 1200)) {
result = detector.update(frozen.copy(timestampMs = t))
assertFalse("frame at t=$t should not read as a held stance", result.wasReset)
}
assertEquals(2, result.stepCount)
}
/**
* Control case: genuinely near-static hip positions -- sub-pixel jitter
* every frame, never bit-identical -- should still trigger a reset, so
* the stall filter above isn't just disabling resets outright.
*/
@Test
fun genuineStillnessStillResets() {
val detector = LiveStepDetector()
detector.update(frame(0, ankleL = 1000f, ankleR = 700f, hipX = 400f, hipY = 800f))
detector.update(frame(50, ankleL = 1100f, ankleR = 700f, hipX = 402f, hipY = 802f))
val afterStep = detector.update(frame(100, ankleL = 1000f, ankleR = 700f, hipX = 405f, hipY = 805f))
assertEquals(1, afterStep.stepCount)
var sawReset = false
var lastStepCount = afterStep.stepCount
var y = 805f
var t = 150L
while (t <= 1200L) {
y += if ((t / 50L) % 2L == 0L) 0.2f else -0.2f
val result = detector.update(frame(t, ankleL = 1000f, ankleR = 700f, hipX = 405f, hipY = y))
if (result.wasReset) sawReset = true
lastStepCount = result.stepCount
t += 50L
}
assertEquals(true, sawReset)
assertEquals(0, lastStepCount)
}
/**
* Reproduces the real gap found by replaying an actual device recording
* against the detector: a genuine footfall doesn't peak as a single
* sharp frame, it climbs to a noisy plateau and holds there for many
* frames before descending. A peak check that only ever compares a
* sample to its immediate left/right neighbor sees near-zero prominence
* across that whole plateau and never confirms, even though the true
* peak is 100px above the surrounding valleys (well past the 45px
* threshold at this torsoScale). torsoScale here is a fixed 300
* (shoulder(400,500)/hip(400,800)), so minProminenceRatio's default
* 0.15 gives a 45px threshold.
*/
@Test
fun gradualPeakOnAPlateauIsDetected() {
val detector = LiveStepDetector()
// First step: rise to ~600-601, hold on a noisy plateau, descend.
val firstCycle = listOf(
0L to 500f, 30L to 520f, 60L to 540f, 90L to 560f, 120L to 580f, 150L to 600f,
180L to 601f, 210L to 599f, 240L to 600f, 270L to 601f, 300L to 599f, 330L to 600f,
360L to 580f, 390L to 560f, 420L to 540f, 450L to 520f, 480L to 500f
)
var result = LiveStepDetector.Result(0, emptyList(), false)
// Hip drifts steadily throughout (a real bowler's hip keeps moving
// during the approach) -- constant hip position would itself read
// as a held "ready" stance once enough time elapses and wipe out
// the very step this test is confirming, before the assertion below
// even runs.
for ((t, y) in firstCycle) {
result = detector.update(frame(t, ankleL = y, ankleR = 700f, hipX = 400f, hipY = 800f + t * 0.05f))
}
assertEquals("plateaued peak should confirm as a step", 1, result.stepCount)
// Second step, same shape, well past the refractory window.
val secondCycle = listOf(
510L to 500f, 540L to 501f, 570L to 499f, 600L to 520f, 630L to 540f, 660L to 560f,
690L to 580f, 720L to 600f, 750L to 601f, 780L to 599f, 810L to 600f, 840L to 601f,
870L to 599f, 900L to 600f, 930L to 580f, 960L to 560f, 990L to 540f
)
for ((t, y) in secondCycle) {
result = detector.update(frame(t, ankleL = y, ankleR = 700f, hipX = 400f, hipY = 800f + t * 0.05f))
}
assertEquals("second plateaued peak should also confirm", 2, result.stepCount)
}
/**
* Control case for the same fix: pure jitter that never moves more than
* a few pixels from baseline (well under the 45px threshold at this
* torsoScale) should never be read as a step, however long it runs --
* the running-extremum tracker isn't just trigger-happy on any wiggle.
*/
@Test
fun jitterBelowThresholdNeverConfirms() {
val detector = LiveStepDetector()
var result = LiveStepDetector.Result(0, emptyList(), false)
var y = 600f
var t = 0L
val deltas = floatArrayOf(3f, -5f, 2f, -1f, 6f, -4f, 1f, -2f, 4f, -3f)
for (i in 0 until 60) {
y = 600f + deltas[i % deltas.size]
result = detector.update(frame(t, ankleL = y, ankleR = 700f, hipX = 400f, hipY = 800f))
t += 30L
}
assertEquals(0, result.stepCount)
}
}