109 lines
5.1 KiB
Kotlin
109 lines
5.1 KiB
Kotlin
/**
|
|
* @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
|
|
|
|
// Steps detected so far in the current attempt (since the last reset,
|
|
// whether that reset was a new session starting or the bowler
|
|
// completing the hand-raise reset gesture mid-recording). The UI reads
|
|
// events.size as the "Step N" counter. Stays populated after recording
|
|
// stops so the last attempt's count remains visible.
|
|
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()
|
|
|
|
// How far through the hold-to-reset gesture the bowler currently is --
|
|
// see LiveStepDetector.Result.handRaiseProgress. Drives the on-screen
|
|
// hold indicator so a reset is never a surprise; 0 whenever not recording.
|
|
private val _handRaiseProgress = MutableStateFlow(0f)
|
|
/** @brief Progress toward the hold-to-reset gesture, from 0 to 1. */
|
|
val handRaiseProgress: StateFlow<Float> = _handRaiseProgress.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.
|
|
*/
|
|
fun onFrame(landmarks: Map<Int, SmoothedLandmark>, angles: PoseAngles, timestampMs: Long) {
|
|
val smoothedAnkleHip = ankleHipSmoother.smooth(landmarks)
|
|
val frame = buildPoseFrame(
|
|
timestampMs = timestampMs,
|
|
landmarks = landmarks,
|
|
smoothedAnkleHip = smoothedAnkleHip,
|
|
angles = angles
|
|
)
|
|
poseFrameBuffer.add(frame)
|
|
|
|
val result = liveStepDetector.update(frame)
|
|
if (result.wasReset) {
|
|
_stepEvents.value = emptyList()
|
|
}
|
|
if (result.newSteps.isNotEmpty()) {
|
|
_stepEvents.value = _stepEvents.value + result.newSteps
|
|
}
|
|
_handRaiseProgress.value = result.handRaiseProgress
|
|
}
|
|
|
|
/**
|
|
* @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,
|
|
maxFrameJumpRatio = settings.maxFrameJumpRatio,
|
|
handRaiseHoldMs = settings.handRaiseHoldMs,
|
|
handRaiseMarginRatio = settings.handRaiseMarginRatio
|
|
)
|
|
_stepEvents.value = emptyList()
|
|
_handRaiseProgress.value = 0f
|
|
}
|
|
}
|