Files
PinPoint/app/src/main/java/com/example/jnicpp/bowling/StepCountingSession.kt
T

109 lines
4.7 KiB
Kotlin
Raw Normal View History

/**
* @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.
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-12 21:43:10 +08:00
fun onFrame(
landmarks: Map<Int, SmoothedLandmark>,
angles: PoseAngles,
timestampMs: Long,
isStartingPosition: Boolean = false,
) {
val smoothedAnkleHip = ankleHipSmoother.smooth(landmarks)
val frame = buildPoseFrame(
timestampMs = timestampMs,
landmarks = landmarks,
smoothedAnkleHip = smoothedAnkleHip,
2026-09-12 21:43:10 +08:00
angles = angles,
)
poseFrameBuffer.add(frame)
2026-09-12 21:43:10 +08:00
val result = liveStepDetector.update(frame, isStartingPosition = isStartingPosition)
if (result.wasReset) {
_stepEvents.value = emptyList()
}
if (result.newSteps.isNotEmpty()) {
2026-09-12 21:43:10 +08:00
_stepEvents.value += result.newSteps
}
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()
}
/**
* @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
)
_stepEvents.value = emptyList()
}
}