added working step counter
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user