2026-09-07 19:38:43 +08:00
|
|
|
/**
|
|
|
|
|
* @file StepCountingSession.kt
|
|
|
|
|
* @brief Owns pose-frame buffering and live step counting for one recording attempt.
|
|
|
|
|
*/
|
|
|
|
|
package com.example.jnicpp.bowling
|
|
|
|
|
|
|
|
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
|
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
|
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Bundles everything [CameraViewModel] needs to buffer pose frames
|
|
|
|
|
* and run live step counting during a recording, so that ViewModel
|
|
|
|
|
* stays a thin state machine rather than also holding this
|
|
|
|
|
* machinery directly.
|
|
|
|
|
*
|
|
|
|
|
* Knows nothing about CameraX/ML Kit or Android component lifecycle --
|
|
|
|
|
* same reasoning as [CameraXController] and [PoseAnalyzer] -- so it's
|
|
|
|
|
* trivially unit-testable and reusable if a second recording surface is
|
|
|
|
|
* ever added.
|
|
|
|
|
*/
|
|
|
|
|
class StepCountingSession {
|
|
|
|
|
|
|
|
|
|
// Extra SMA smoothing for ankle/hip landmarks specifically, on top of
|
|
|
|
|
// PoseLandmarkSmoother's per-frame EMA -- see AnkleHipMovingAverageFilter.
|
|
|
|
|
private val ankleHipSmoother = AnkleHipMovingAverageFilter()
|
|
|
|
|
|
|
|
|
|
// Live, incremental step counting -- see LiveStepDetector. Resets
|
|
|
|
|
// mid-recording when the bowler holds a hand raised for the gesture's
|
|
|
|
|
// full hold duration, so one recording can capture several practice
|
|
|
|
|
// approaches back to back. Rebuilt fresh (not just .reset()) in
|
|
|
|
|
// startNewSession from whatever DetectorSettings are current at that
|
|
|
|
|
// moment, so tuning changes made in ParameterEditorActivity take
|
|
|
|
|
// effect on the very next recording without needing an app restart.
|
|
|
|
|
private var liveStepDetector = LiveStepDetector()
|
|
|
|
|
|
|
|
|
|
// Time-ordered pose samples for the current/most recent recording
|
|
|
|
|
// session, one appended per analyzed frame while actually recording --
|
|
|
|
|
// see [onFrame]. Cleared at the start of each new session (see
|
|
|
|
|
// [startNewSession]). Exposed as a read-only snapshot;
|
|
|
|
|
// StepDetector.detect() can consume it once a recording finishes.
|
|
|
|
|
private val poseFrameBuffer = mutableListOf<PoseFrame>()
|
|
|
|
|
/** @brief Time-ordered pose samples buffered for the current/most recent recording session. */
|
|
|
|
|
val poseFrames: List<PoseFrame> get() = poseFrameBuffer
|
|
|
|
|
|
2026-09-12 21:43:10 +08:00
|
|
|
// Steps detected so far in the current attempt (since the last reset).
|
|
|
|
|
// The UI reads events.size as the "Step N" counter.
|
2026-09-07 19:38:43 +08:00
|
|
|
private val _stepEvents = MutableStateFlow<List<StepEvent>>(emptyList())
|
|
|
|
|
/** @brief Steps detected so far in the current attempt, since the last reset. */
|
|
|
|
|
val stepEvents: StateFlow<List<StepEvent>> = _stepEvents.asStateFlow()
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Feeds one analyzed frame's landmarks/angles into buffering and
|
|
|
|
|
* live step counting. Only meant to be called while a recording
|
|
|
|
|
* is actually in progress -- see [CameraViewModel.onPoseFrameUpdated].
|
|
|
|
|
* @param landmarks EMA-smoothed landmarks for this frame, keyed by ML Kit's `PoseLandmark` type constant.
|
|
|
|
|
* @param angles Joint angles computed for this same frame.
|
|
|
|
|
* @param timestampMs Wall-clock time this frame was analyzed, in milliseconds.
|
2026-09-12 21:43:10 +08:00
|
|
|
* @param isStartingPosition Whether the bowler is currently in the starting position.
|
2026-09-07 19:38:43 +08:00
|
|
|
*/
|
2026-09-12 21:43:10 +08:00
|
|
|
fun onFrame(
|
|
|
|
|
landmarks: Map<Int, SmoothedLandmark>,
|
|
|
|
|
angles: PoseAngles,
|
|
|
|
|
timestampMs: Long,
|
|
|
|
|
isStartingPosition: Boolean = false,
|
|
|
|
|
) {
|
2026-09-07 19:38:43 +08:00
|
|
|
val smoothedAnkleHip = ankleHipSmoother.smooth(landmarks)
|
|
|
|
|
val frame = buildPoseFrame(
|
|
|
|
|
timestampMs = timestampMs,
|
|
|
|
|
landmarks = landmarks,
|
|
|
|
|
smoothedAnkleHip = smoothedAnkleHip,
|
2026-09-12 21:43:10 +08:00
|
|
|
angles = angles,
|
2026-09-07 19:38:43 +08:00
|
|
|
)
|
|
|
|
|
poseFrameBuffer.add(frame)
|
|
|
|
|
|
2026-09-12 21:43:10 +08:00
|
|
|
val result = liveStepDetector.update(frame, isStartingPosition = isStartingPosition)
|
2026-09-07 19:38:43 +08:00
|
|
|
if (result.wasReset) {
|
|
|
|
|
_stepEvents.value = emptyList()
|
|
|
|
|
}
|
|
|
|
|
if (result.newSteps.isNotEmpty()) {
|
2026-09-12 21:43:10 +08:00
|
|
|
_stepEvents.value += result.newSteps
|
2026-09-07 19:38:43 +08:00
|
|
|
}
|
2026-09-12 21:43:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Manually resets live step detector state and clears detected step events.
|
|
|
|
|
*/
|
|
|
|
|
fun resetStepCounter() {
|
|
|
|
|
liveStepDetector.reset()
|
|
|
|
|
_stepEvents.value = emptyList()
|
2026-09-07 19:38:43 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @brief Clears all buffering/detection state and rebuilds the step
|
|
|
|
|
* detector from [settings]; call when a new recording starts.
|
|
|
|
|
* @param settings Tuning parameters to build this session's [LiveStepDetector] with.
|
|
|
|
|
*/
|
|
|
|
|
fun startNewSession(settings: DetectorSettings = DetectorSettings.DEFAULT) {
|
|
|
|
|
poseFrameBuffer.clear()
|
|
|
|
|
ankleHipSmoother.reset()
|
|
|
|
|
liveStepDetector = LiveStepDetector(
|
|
|
|
|
minSpacingMs = settings.minSpacingMs,
|
|
|
|
|
minProminenceRatio = settings.minProminenceRatio,
|
2026-09-12 21:43:10 +08:00
|
|
|
maxFrameJumpRatio = settings.maxFrameJumpRatio
|
2026-09-07 19:38:43 +08:00
|
|
|
)
|
|
|
|
|
_stepEvents.value = emptyList()
|
|
|
|
|
}
|
|
|
|
|
}
|